Mr Wolf
Mr Wolf

Reputation: 41

Custom Model Binder AspNet Core 2.2 for complex nested property

I have an Angular client and create a POST request with this body:

{"Name":"example","Currency":"EUR"}

I Use Odata protocol and my Controller is:

    [HttpPost, ODataRoute("Templates")]
    public IActionResult Insert([FromBody] Template value)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);

        value.Id = Guid.NewGuid();

        _context.Templates.Add(value);
        _context.SaveChanges();
        return Created(value);
    }

with Template:

public class Template
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public Currency Currency { get; set; }
}

and Currency:

[Serializable]
public class Currency : StringEnumeration<Currency>
{
    public static Currency EUR = new Currency("EUR", "EUR");
    public static Currency USD = new Currency("USD", "USD");

    Currency() { }
    Currency(string code, string description) : base(code, description) { }
}

Currency is a particular class because it has private constructors and for this reason i can't create a new instance of Currency. I want use ones of the existing instances (EUR or USD).

(StringEnumeration support a Parse and TryParse Method and return the correct instance)

Standard Configuration:

    public void ConfigureServices(IServiceCollection services)
    {
        services.ConfigureCors();

        services.AddOData();

        services.ConfigureIISIntegration();

        services.AddMvc()                
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

        services.AddDbContext<GpContext>(option => option
            .UseSqlServer(Configuration.GetConnectionString(GpConnection)));
    }

My problem is when the client call POST on http://localhost:4200/template with the body: {"Name":"example","Currency":"EUR"}

The Model Bindel cannot Convert "EUR" in Currency.EUR instance, so i want provide something to help model binder to create Template with Currency property with the instance Currency.EUR

This is the error generated: A 'PrimitiveValue' node with non-null value was found when trying to read the value of the property 'Currency'; however, a 'StartArray' node, a 'StartObject' node, or a 'PrimitiveValue' node with null value was expected.

In my project i have many classes with Currency property inside.

I tryed to use IModelBinder on Template class and it works, but i dont want write a modelBinder for any Currency Property.

I tried with JsonConverter, but it doesn't work for me (maybe something wrong)

My Expected result is a Template instance with this values:

Id = defaluf(Guid)
Name = "example"
Currency = Currency.EUR

Upvotes: 3

Views: 2339

Answers (2)

Mr Wolf
Mr Wolf

Reputation: 41

I try this implementation and i have same error.

I set breakpoints in CurrencyModelBinder and in the CurrencyModelBinderProvider

Breakpoint on Model Binder Provider

The problem is in the compare: context.Metadata.ModelType = "Template" and the CurrencyModelBinder is called only for Currency.

I solved with this workarond:

  1. Get RawValue from Body Request
  2. Deserialize with JsonConverter

    [HttpPost, ODataRoute("Templates")]
    public IActionResult Insert([FromBody] object value)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);
    
        var template = JsonConvert.DeserializeObject<Template>(value.ToString());
        template.Id = Guid.NewGuid();
    
        _context.Templates.Add(template);
        _context.SaveChanges();
        return Created(value);
    }
    

The Currency class now is

[Serializable]
[JsonConverter(typeof(CurrencyJsonConverter))]
public class Currency : StringEnumeration<Currency>
{
    public static Currency CHF = new Currency("CHF", "CHF");
    public static Currency EUR = new Currency("EUR", "EUR");
    public static Currency USD = new Currency("USD", "USD");

    Currency() { }
    Currency(string code, string description) : base(code, description) { }
}

and JsonConverter

public class CurrencyJsonConverter : JsonConverter
{
    public override bool CanWrite => true;

    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof(Currency);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null) return null;
        var value = reader.Value as string;
        return Currency.Parse(value);
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        if (value is Currency currency)
            serializer.Serialize(writer, currency.Code);
    }
}

I don't understand why Defaul Model Binder don't use Json deserialization.

I remain waiting for your kind reply.

Upvotes: 1

poke
poke

Reputation: 388303

If you already have a working model binder implemented for your Currency type, then you can just implement an IModelBinderProvider that provides the model binder automatically whenever MVC needs to bind to the Currency type:

public class CurrencyModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType == typeof(Currency))
            return new BinderTypeModelBinder(typeof(CurrencyModelBinder));
        return null;
    }
}

You then need to register this in your Startup’s ConfigureServices:

services.AddMvc(options =>
{
    options.ModelBinderProviders.Insert(0, new CurrencyModelBinderProvider());
});

And then, all Currency elements will be automatically bound using your CurrencyModelBinder without you having to use the [ModelBinder] attribute everywhere.

This is also described in “custom model binder sample” section of the documentation.


Just for completeness, a possible implementation of CurrencyModelBinder:

public class CurrencyModelBinder : IModelBinder
{
    private static readonly Currency[] _currencies = new Currency[]
    {
        Currency.EUR,
        Currency.USD,
    };

    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var modelName = bindingContext.ModelName;
        var providerResult = bindingContext.ValueProvider.GetValue(modelName);
        if (providerResult == ValueProviderResult.None)
        {
            return Task.CompletedTask;
        }

        var value = providerResult.FirstValue;
        if (string.IsNullOrEmpty(value))
        {
            return Task.CompletedTask;
        }

        var currency = _currencies
            .FirstOrDefault(c => c.Code.Equals(value, StringComparison.OrdinalIgnoreCase));

        if (currency != null)
            bindingContext.Result = ModelBindingResult.Success(currency);
        else
            bindingContext.ModelState.TryAddModelError(modelName, "Unknown currency");

        return Task.CompletedTask;
    }
}

Upvotes: 1

Related Questions