在 ASP .NET Core 中在运行时设置模型绑定表单字段名称

Set model binding form field name at runtime in ASP .NET Core

与其对 DTO 的预期表单字段名称进行硬编码,它们是否有可能在 运行 时间 时动态/确定?

背景:我正在实现一个 webhook,它将使用 form-url 编码的数据调用(webhook 将使用的数据的形状不受我的控制)。

目前我的控制器动作的签名如下所示:

public async Task<IActionResult> PerformSomeAction([FromForm]SomeWebhookRequestDto request)

DTO 大部分具有如下属性:

    [ModelBinder(Name = "some_property")]
    [BindRequired]
    public string SomeProperty { get; set; }

预先知道表单字段名称是 "some_property"(永远不会更改)

但是对于一些属性,我想在运行时间确定表单字段名称:

    [ModelBinder(Name = "field[xxx]")]
    [BindRequired]
    public DateTime? AnotherProperty { get; set; }

注意xxx会被数字代替(会根据URL中的信息变化)。

请注意,如果可以的话,我宁愿避免实施自定义模型绑定器 - 看来我应该能够挂钩 IValueProvider - 我已经尝试过这样做(添加了 IValueProviderFactory,注册在位置 0) - 但似乎 [FromForm] 很贪心,所以我的 IValueProvider(Factory) 永远没有机会。

澄清几点:

您违反了一些好的 API 设计规则,这里只是简单地进行一般设计。

首先,DTO 的全部要点是以一种形式接受数据,因此您可以潜在地以另一种形式操纵它。换句话说,如果您在不同的请求中有不同的数据,则每种数据类型应该有不同的 DTO。

其次,API 的全部意义在于它是一个应用程序编程接口。就像编程中的实际接口一样,它定义了一个契约。客户端必须以定义的格式发送数据,否则服务器将拒绝它。时期。 API 没有责任接受客户端决定发送的任何 willy-nilly 数据并尝试对其进行处理;相反,遵守接口是客户的责任。

第三,如果您确实需要接受不同类型的数据,那么您的 API 需要额外的端点。每个端点应该处理一个资源。客户端不应该向同一个端点提交多种不同类型的资源。因此,应该不需要 "dynamic" 属性。

最后,如果情况只是所有数据都针对相同的资源类型,但只有部分数据可能随任何给定请求提交,您的 DTO 应该仍然 容纳所有潜在的属性。不需要在请求中提供所有可能的属性; modelbinder 将尽其所能。那么,您的操作应该接受 HTTP 方法 PATCH,根据定义,这意味着您只处理特定资源的一部分。

通过删除 [FromForm] 属性并实施 IValueProvider + IValueProviderFactory.

解决
internal class CustomFieldFormValueProvider : IValueProvider
{
    private static readonly Regex AliasedFieldValueRegex = new Regex("(?<prefix>.*)(?<fieldNameAlias>\%.*\%)$");
    private readonly KeyValuePair<string, string>[] _customFields;
    private readonly IRequestCustomFieldResolver _resolver;
    private readonly ILogger _logger;

    public CustomFieldFormValueProvider(IRequestCustomFieldResolver resolver, KeyValuePair<string, string>[] customFields) {
        _resolver = resolver;
        _customFields = customFields;
        _logger = Log.ForContext(typeof(CustomFieldFormValueProvider));
    }

    public bool ContainsPrefix(string prefix) {
        return AliasedFieldValueRegex.IsMatch(prefix);
    }

    public ValueProviderResult GetValue(string key) {
        var match = AliasedFieldValueRegex.Match(key);
        if (match.Success) {
            var prefix = match.Groups["prefix"].Value;
            var fieldNameAlias = match.Groups["fieldNameAlias"].Value;

            // Unfortunately, IValueProvider::GetValue does not have an async variant :(
            var customFieldNumber = Task.Run(() => _resolver.Resolve(fieldNameAlias)).Result;
            var convertedKey = ConvertKey(prefix, customFieldNumber);

            string customFieldValue = null;
            try {
                customFieldValue = _customFields.Single(pair => pair.Key.Equals(convertedKey, StringComparison.OrdinalIgnoreCase)).Value;
            } catch (InvalidOperationException) {
                _logger.Warning("Could not find a value for '{FieldNameAlias}' - (custom field #{CustomFieldNumber} - assuming null", fieldNameAlias, customFieldNumber);
            }

            return new ValueProviderResult(new StringValues(customFieldValue));
        }

        return ValueProviderResult.None;
    }

    private string ConvertKey(string prefix, int customFieldNumber) {
        var path = prefix.Split('.')
                         .Where(part => !string.IsNullOrWhiteSpace(part))
                         .Concat(new[] {
                             "fields",
                             customFieldNumber.ToString()
                         })
                         .ToArray();
        return path[0] + string.Join("", path.Skip(1).Select(part => $"[{part}]"));
    }
}

public class CustomFieldFormValueProviderFactory : IValueProviderFactory
{
    private static readonly Regex
        CustomFieldRegex = new Regex(".*[\[]]?fields[\]]?[\[]([0-9]+)[\]]$");

    public Task CreateValueProviderAsync(ValueProviderFactoryContext context) {
        // Get the key/value pairs from the form which look like our custom fields
        var customFields = context.ActionContext.HttpContext.Request.Form.Where(pair => CustomFieldRegex.IsMatch(pair.Key))
                                  .Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value.First()))
                                  .ToArray();

        // Pull out the service we need
        if (!(context.ActionContext.HttpContext.RequestServices.GetService(typeof(IRequestCustomFieldResolver)) is IRequestCustomFieldResolver resolver)) {
            throw new InvalidOperationException($"No service of type {typeof(IRequestCustomFieldResolver).Name} available");
        }

        context.ValueProviders.Insert(0, new CustomFieldFormValueProvider(resolver, customFields));
        return Task.CompletedTask;
    }
}