Jason W
Jason W

Reputation: 13179

Customize attribute names with Web API default model binder?

I have a request model class that I'm trying to use the default Web API 2 model binding (.NET 4.6.1). Some of the query string parameters match the model properties, but some do not.

public async Task<IHttpActionResult> Get([FromUri]MyRequest request) {...}

Sample query string:

/api/endpoint?country=GB

Sample model property:

public class MyRequest
{
    [JsonProperty("country")] // Did not work
    [DataMember(Name = "country")] // Also did not work
    public string CountryCode { get; set; }
    // ... other properties
}

Is there a way to use attributes on my model (like you might use [JsonProperty("country")]) to avoid implementing a custom model binding? Or is best approach just to use create a specific model for the QueryString to bind, and then use AutoMapper to customize for the differences?

Upvotes: 6

Views: 2451

Answers (3)

berserker
berserker

Reputation: 214

Had the same problem, tried all of these with no luck:

  • [ModelBinder(Name = "country")]
  • [BindProperty(Name = "country")]
  • [DataMember(Name="country")]

Turns out that if you use:

  • System.Text.Json, [JsonPropertyName("country")] should do the trick
  • Newtonsoft.Json, [JsonProperty(PropertyName = "country")] should work

Upvotes: 0

Andrew Cachia
Andrew Cachia

Reputation: 151

Late answer but I bumped into this issue recently also. You could simply use the BindProperty attribute:

public class MyRequest
{
    [BindProperty(Name = "country")]
    public string CountryCode { get; set; }
}

Tested on .NET Core 2.1 and 2.2

Upvotes: 5

Jason W
Jason W

Reputation: 13179

Based on further research, the default model binding behavior in Web API does not support JsonProperty or DataMember attributes, and most likely solutions seem to be either (1) custom model binder or (2) maintaining 2 sets of models and a mapping between them.

I opted for the custom model binder (implementation below) so I could re-use this and not have to duplicate all my models (and maintain mappings between every model).

Usage

The implementation below allows me to let any model optionally use JsonProperty for model binding, but if not provided, will default to just the property name. It supports mappings from standard .NET types (string, int, double, etc). Not quite production ready, but it meets my use cases so far.

[ModelBinder(typeof(AttributeModelBinder))]
public class PersonModel
{
    [JsonProperty("pid")]
    public int PersonId { get; set; }

    public string Name { get; set; }
}

This allows the following query string to be mapped in a request:

/api/endpoint?pid=1&name=test

Implementation

First, the solution defines a mapped property to track the source property of the model and the target name to use when setting the value from the value provider.

public class MappedProperty
{
    public MappedProperty(PropertyInfo source)
    {
        this.Info = source;
        this.Source = source.Name;
        this.Target = source.GetCustomAttribute<JsonPropertyAttribute>()?.PropertyName ?? source.Name;
    }
    public PropertyInfo Info { get; }
    public string Source { get; }
    public string Target { get; }
}

Then, a custom model binder is defined to handle the mapping. It caches the reflected model properties to avoid repeating the reflection on subsequent calls. It may not be quite production ready, but initial testing has been promising.

public class AttributeModelBinder : IModelBinder
{
    public static object _lock = new object();
    private static Dictionary<Type, IEnumerable<MappedProperty>> _mappings = new Dictionary<Type, IEnumerable<MappedProperty>>();


    public IEnumerable<MappedProperty> GetMapping(Type type)
    {
        if (_mappings.TryGetValue(type, out var result)) return result; // Found
        lock (_lock)
        {
            if (_mappings.TryGetValue(type, out result)) return result; // Check again after lock
            return (_mappings[type] = type.GetProperties().Select(p => new MappedProperty(p)));
        }
    }

    public object Convert(Type target, string value)
    {
        try
        {
            var converter = TypeDescriptor.GetConverter(target);
            if (converter != null)
                return converter.ConvertFromString(value);
            else
                return target.IsValueType ? Activator.CreateInstance(target) : null;
        }
        catch (NotSupportedException)
        {
            return target.IsValueType ? Activator.CreateInstance(target) : null;
        }
    }

    public void SetValue(object model, MappedProperty p, IValueProvider valueProvider)
    {
        var value = valueProvider.GetValue(p.Target)?.AttemptedValue;
        if (value == null) return;
        p.Info.SetValue(model, this.Convert(p.Info.PropertyType, value));
    }

    public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
    {
        try
        {
            var model = Activator.CreateInstance(bindingContext.ModelType);
            var mappings = this.GetMapping(bindingContext.ModelType);
            foreach (var p in mappings)
                this.SetValue(model, p, bindingContext.ValueProvider);
            bindingContext.Model = model;
            return true;
        }
        catch (Exception ex)
        {
            return false;
        }
    }
}

Upvotes: 3

Related Questions