eddiewould
eddiewould

Reputation: 1633

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:

Upvotes: 1

Views: 2706

Answers (2)

eddiewould
eddiewould

Reputation: 1633

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;
    }
}

Upvotes: 0

Chris Pratt
Chris Pratt

Reputation: 239290

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.

Upvotes: 2

Related Questions