Tuesday, May 6, 2025

ASP.NET Core 9.x: Support for Polymorphic Types with [FromForm]

I recently wanted to have an ASP.NET Core Web API endpoint, that accepts data via [FromForm] and the payload contains a Polymorphic Type.

For an example consider the following POCO types that the endpoint expects via [FromForm].

[JsonDerivedType(typeof(ClassificationOptions)nameof(ClassificationOptions))]
[JsonDerivedType(typeof(ExtractionOptions)nameof(ExtractionOptions))]
public class AnalyzeOptions
{
    public string? CommonOption1 { getset} = null;
}

public class ClassificationOptions : AnalyzeOptions
{
    public stringClassificationOption1 { getset} = null;
}

public class ExtractionOptions : AnalyzeOptions
{
    public string? ExtractionOption1 { getset} = null;
}

public class AnalyzeRequestModel
{
    public IFormFileCollection Files { getset}

    public AnalyzeOptions Options { getset}
}

And the endpoint looks like below:

[ApiController]
[Route("[controller]")]
public class ValuesController : ControllerBase
{
    [HttpPost("FromForm", Name = "PostFromForm")]
    public IActionResult PostFromForm([FromForm] AnalyzeRequestModel analyzeRequestModel)
    {
        // TODO: Process the files and options

        return Ok(analyzeRequestModel);
    }
}

And I was sending data as below, hoping System.Text.Json will kick in and do the deserialization as expected.

Postman
Not deserialized

But it seems [FromForm] model binding isn't honoring System.Text.Json support for Polymorphic Types which is working perfectly fine with [FromBody].

So in order to get this working, we can write a custom model binder for AnalyzeOptions.

public class AnalyzeOptionsModelBinder : IModelBinder
{
    private Dictionary<Type(ModelMetadataIModelBinder)> binders;

    public AnalyzeOptionsModelBinder(Dictionary<Type(ModelMetadataIModelBinder)> binders)
    {
        this.binders = binders;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        string modelKindName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, "$type");
        stringmodelTypeValue = bindingContext.ValueProvider.GetValue(modelKindName).FirstValue;

        IModelBinder modelBinder;
        ModelMetadata modelMetadata;
        if (modelTypeValue == nameof(ClassificationOptions))
        {
            (modelMetadatamodelBinder) = binders[typeof(ClassificationOptions)];
        }
        else if (modelTypeValue == nameof(ExtractionOptions))
        {
            (modelMetadatamodelBinder) = binders[typeof(ExtractionOptions)];
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }

        ModelBindingContext newBindingContext = DefaultModelBindingContext.CreateBindingContext(
            bindingContext.ActionContext,
            bindingContext.ValueProvider,
            modelMetadata,
            bindingInfonull,
            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.Model] = new ValidationStateEntry
            {
                Metadata = modelMetadata,
            };
        }
    }
}
public class AnalyzeOptionsModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType != typeof(AnalyzeOptions))
        {
            return null;
        }

        Type[] subclasses = [typeof(ClassificationOptions)typeof(ExtractionOptions),];

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

        return new AnalyzeOptionsModelBinder(binders);
    }
}

And then register this custom ModelBinderProvider.

builder.Services.AddControllers(options =>
{
    // Add the custom model binder to support [FromForm] with Polymorphic types
    options.ModelBinderProviders.Insert(0, new AnalyzeOptionsModelBinderProvider());
});

And now if I execute the request again,

Custom Model Binder
And that works.

Raised an issue in ASP.NET Core repo,
   [FromForm]: Support for Polymorphic Types #61378

Hope this helps.

Happy Coding.

Regards,
Jaliya

No comments:

Post a Comment