简体   繁体   中英

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

Rather than hard-coding the expected form field names for a DTO, is it possible for them to be dynamic / determined at run time ?

Background: I'm implementing a webhook which will be called with form-url-encoded data (the shape of the data the webhook will be invoked with is out of my control).

Currently the signature of my controller actions look something like below:

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

The DTO is for the most part has a bunch of properties like below:

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

where the form-field name is known to be "some_property" in advance (will never change)

However for some properties, I'll want to determine the form field name at runtime:

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

Note that xxx will be replaced by a number (will change according to information in the URL).

Note that I'd rather avoid implementing custom model binders if I can - it seems I should just be able to hook in a IValueProvider - I've had a go at doing that (added a IValueProviderFactory , registered at position 0) - but it seems that [FromForm] is greedy and so my IValueProvider(Factory) never gets a chance.

To clarify some points:

  • The requests all have the same intent (they're all a request asking my API to do a single specific thing)
  • The requests all have the same semantic shape (let's say there are 10 fields, all 10 must be populated with valid data for that field - dates where dates are supposed to go, strings where strings are supposed to go). The meaning of the field-values are consistent too.
  • For the fields with names that must be determined at runtime, the field name will be something like "field[132]" or "field[130]". The names of those fields will depend on information provided in the URL - my API will perform a look-up to determine what the final names should be.
  • There could be a very large number of these configurations so setting up separate endpoints for each is not feasible.
  • While the above is somewhat of a nightmare, short of refusing to work on the gig it is out of my control

You're breaking several rules of good API design and just simply design in general here.

First, the whole entire point of a DTO is accept data in one form so you can potentially manipulate it in another. In other words, if you have different data coming through in different requests there should be different DTOs for each type of data.

Second, the whole point of an API is that it's an application programming interface . Just as with an actual interface in programming, it defines a contract. The client must send data in a defined format or the server rejects it. Period. It is not the responsibility of an API to accept any willy-nilly data the client decides to send and attempt to do something with it; rather, it is the client's responsibility to adhere to the interface.

Third, if you do need to accept different kinds of data, then your API needs additional endpoints for that. Each endpoint should deal with one resource. A client should never submit multiple different kinds of resources to the same endpoint. Therefore, there should be no need for "dynamic" properties.

Finally, if the situation is simply that all the data is for the same resource type, but only some part of that data may be submitted with any given request, your DTO should still house all the potential properties. It is not required that all possible properties be supplied in the request; the modelbinder will fill what it can. Your action, then, should accept the HTTP method PATCH, which by very definition means you're dealing with only part of a particular resource.

Solved by removing the [FromForm] attribute and implementing 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;
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM