Polymorphic model binding in AspNet Core WebApi?

5.7k Views Asked by At

anyone has a working example of a custom model binding with polymorphic model binding? I'm trying this example (which is for Mvc not Api projects) with a web api project but it's not working for API projects. I think some steps are missing in terms of populating the ValueProvider but I can't find any resources related to this (AspNet Core 3.1).

My attempt so far:

Dtos:

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

Custom model binder implementation:

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(bindingContext.ModelName);

        IModelBinder modelBinder;
        ModelMetadata modelMetadata;
        if (modelTypeValue.FirstValue == "Laptop")
        {
            (modelMetadata, modelBinder) = binders[typeof(Laptop)];
        }
        else if (modelTypeValue.FirstValue == "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,
            };
        }
    }
}

I register the model binder provider like so:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers(o => o.ModelBinderProviders.Insert(0, new DeviceModelBinderProvider()));
    }

Then my controller:

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

I'm posting a json request body like:

{
    "ScreenSize": "1",
    "Kind": "SmartPhone"
}

Really fedup with the documentation on this as there's too much magic going on. My fallback is to manually parse the HttpContent from the request and deserialise. But I'm hoping to use the model binder approach like in the example. The only two strange things I'm seeing are, the bindingContext.ModelName is empty and bindingContext.ValueProvider only has a route value provider containing action and controller keys. So, it looks like the body is not even parsed into the value provider.

2

There are 2 best solutions below

11
Karan On

I have tried exact same code you posted and its working for me.

Here is image of its value.

enter image description here

and here is screenshot of postman request.

enter image description here

CURL request from postman.

curl --location --request POST 'https://localhost:44332/test' \
--header 'Content-Type: application/json' \
--form 'ScreenSize=1' \
--form 'Kind=SmartPhone'

And startup.cs as image below. enter image description here

1
richardsonwtr On

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.

More information:

Related info