Aspnetcore: Polymorphic model binding in AspNetCore 3.1 Api

Created on 18 May 2020  路  10Comments  路  Source: dotnet/aspnetcore

Hi, I'm trying to get custom model binding working with an api project following the guide here. However, it's really not helping.

I'm pretty much copying and pasting that code in an api project but it's not working. I suspect it's to do with the value provider not filling up the properties in the binding context because I only see action and controller keys in the Items. But I can't find a good resource to debug this issue anywhere.

My controller:

[ApiController]
[Route("test")]
public class TestController : ControllerBase
{
    [HttpPost]
    public IActionResult Test([FromBody] Device dto)
    {
        return Ok();
    }
}

Here, the dto is always null. Model binders and the entity classes are:

public abstract class Device
    {
        public string Kind { get; set; }
    }

    public class Laptop : Device
    {
        public string CPUIndex { get; set; }
    }

    public class SmartPhone : Device
    {
        public string ScreenSize { get; set; }
    }

    public class DeviceModelBinderProvider : IModelBinderProvider
    {
        public IModelBinder GetBinder(ModelBinderProviderContext context)
        {
            if (context.Metadata.ModelType != typeof(Device))
            {
                return null;
            }

            var subclasses = new[] { typeof(Laptop), typeof(SmartPhone), };

            var binders = new Dictionary<Type, (ModelMetadata, IModelBinder)>();
            foreach (var type in subclasses)
            {
                var modelMetadata = context.MetadataProvider.GetMetadataForType(type);
                binders[type] = (modelMetadata, context.CreateBinder(modelMetadata));
            }

            return new DeviceModelBinder(binders);
        }
    }

    public class DeviceModelBinder : IModelBinder
    {
        private Dictionary<Type, (ModelMetadata, IModelBinder)> binders;

        public DeviceModelBinder(Dictionary<Type, (ModelMetadata, IModelBinder)> binders)
        {
            this.binders = binders;
        }

        public async Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var modelKindName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, nameof(Device.Kind));
            var modelTypeValue = bindingContext.ValueProvider.GetValue(modelKindName).FirstValue;

            IModelBinder modelBinder;
            ModelMetadata modelMetadata;
            if (modelTypeValue == "Laptop")
            {
                (modelMetadata, modelBinder) = binders[typeof(Laptop)];
            }
            else if (modelTypeValue == "SmartPhone")
            {
                (modelMetadata, modelBinder) = binders[typeof(SmartPhone)];
            }
            else
            {
                bindingContext.Result = ModelBindingResult.Failed();
                return;
            }

            var newBindingContext = DefaultModelBindingContext.CreateBindingContext(
                bindingContext.ActionContext,
                bindingContext.ValueProvider,
                modelMetadata,
                bindingInfo: null,
                bindingContext.ModelName);

            await modelBinder.BindModelAsync(newBindingContext);
            bindingContext.Result = newBindingContext.Result;

            if (newBindingContext.Result.IsModelSet)
            {
                // Setting the ValidationState ensures properties on derived types are correctly 
                bindingContext.ValidationState[newBindingContext.Result] = new ValidationStateEntry
                {
                    Metadata = modelMetadata,
                };
            }
        }
    }
area-mvc

All 10 comments

This issue seems to be a problem with value provider for "application/json". When I post it as form data, my dto is not null. But when I post data as "application/json", I get null. Please advise. Does this mean I need to implement a custom value provider for json if I need to leverage IModelBinder?

@smadurange formatters, which is what's used when JSON data, do not interact with the rest of the model binding \ value provider subsystem. For this scenario, you'd have to write a converter for the JSON library that you're using:

@pranavkm Thanks for the information. I implemented a custom Json converter for NewtonSoft Json as below:

public class CustomConverter : Newtonsoft.Json.JsonConverter
{
    public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }

    public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }

    public override bool CanConvert(Type objectType)
    {
        return objectType.IsAssignableFrom(typeof(Device));
    }
}

And registered in DI like so

public void ConfigureServices(IServiceCollection services)

{
    services.AddControllers(o => { o.ModelBinderProviders.Insert(0, new DeviceModelBinderProvider()); })
        .AddNewtonsoftJson(o => o.SerializerSettings.Converters.Insert(0, new CustomConverter()));
}

However, when deserializing the DTO, this formatter is not getting called. It does get called when serialising the response. My request from Postman looks like

curl --location --request POST 'http://localhost:5000/test' \
--header 'Content-Type: application/json' \
--data-raw '{
    "ScreenSize": "1",
    "Kind": "SmartPhone"
}'

It's just not quite clear what's really going on in the middleware with a lot of these terms. There are input formatters, jsonconverts, value providers and model binders. Which system dependends on what? It looks to me like the input formatter gets called first (which works fine without polymorphic model binding) which parses the json. So, if formatter/converter does not interact with the rest of the model binding and value provider system, why would adding a new model binder provider mess with parsing the json body? It's not very transparent what happens to the request body and I can't see how/where to start debugging an issue like this. DTO just ends up null and there's no exception or error otherwise.

It's not very transparent what happens to the request body and I can't see how/where to start debugging an issue like this

You should be able to turn up Debug logs and see what formatters \ model binders are being used. You could start off by trying to make sure this works without MVC involved with vanilla Newtonsoft.Json.

Thanks for the advice and the pointers. Decided to side step the issue by not using a polymorphic model. But will try this.

Any updates? ;)

@jacekkulis My team ran out of patience trying to get this to work :p We just added optional properties to the same model. Will probably come back to it at some point but it'd be quite helpful to have a tutorial/example on this in their documentation.

I have tried with custom JSON converters (attribute + DI) both Newtonsoft and core one, custom type converter and as you have noticed custom model binder cannot handle json body.. I've end up with converting it manually in the model binder which is not the best practice.

So, I'm still confused as to the right way to approach this. So, for the Polymorphic model binding tutorial here, does that just not apply at all if you're building a Web API, using JSON? So, we can get the correctly bound model to the controller by just using converters from System.Text.Json/Newtonsoft?

@pranavkm is that what you're saying? Do you have any more guidance as to how to get that to happen?

Also, do you have any insight as to why the model binding system doesn't support JSON? Or if that's something that could be happening in the future?

We don't decompose JSON payloads into key value pairs then proceed to model bind these, instead, model binding delegates to the formatter in its entirety. The formatter uses a deserializer implementation that is a black box to the modeling binding system.

Was this page helpful?
0 / 5 - 0 ratings