Customize attribute names with Web API default model binder?
Asked Answered
R

3

6

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?

Ruthenium answered 28/6, 2018 at 16:2 Comment(4)
Have you tried using JsonProperty...?Pandemonium
I did, but I wasn't able to get it to work with [FromUri]Ruthenium
Use DataMemberAttribute otherwise you have to write modelbinder for this class.Quality
[DataMember(Name="country")] does not work either. The Web API default model binder does not seem to look into JsonPropertyAttribute or DataMemberAttribute decorations...unfortunately :)Ruthenium
P
5

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

Pneumothorax answered 20/2, 2019 at 13:48 Comment(1)
Even though my OP was for 4.6.1, we have migrated to .net core ourselves, and this is approach we used.Ruthenium
R
3

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;
        }
    }
}
Ruthenium answered 28/6, 2018 at 16:45 Comment(0)
S
0

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
Shulock answered 16/5, 2023 at 11:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.