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

Sunday, May 4, 2025

EF Core 10.0: Simplified LeftJoin and RightJoin

In this post, let's see a nice feature that is already available with EF Core 10.0 Preview.

For an example, consider the following DbContext Entities.

public record Customer
{
    public int Id { getset}

    public string Name { getset}
}

public record Order
{
    public int Id { getset}

    public string OrderNumber { getset}

    public Customer Customer { getset}

    public int CustomerId { getset}
}

Here, a Customer can have one or more Orders, or they even not have any Orders. Say we want to get all the Customers  and their Orders (if any).

So I want to write a LEFT JOIN query. Prior to EF Core 10.0, to write a LEFT JOIN, we needed to write a complex LINQ query, something like the following.

var query = context.Customers
    .GroupJoin(
        context.Orders,
        customer => customer.Id,
        order => order.CustomerId,
        (customerorders) => new
        {
            Customer = customer,
            Orders = orders
        })
    .SelectMany(
        x => x.Orders.DefaultIfEmpty(),
        (customerorder) => new
        {
            CustomerName = customer.Customer.Name,
            OrderNumber = order.OrderNumber ?? "N/A"
        });

foreach (var item in query)
{
    Console.WriteLine($"{item.CustomerName,-20} {item.OrderNumber}");
}

This would generate a SQL Query as follows:

SELECT [c].[Name] AS [CustomerName], COALESCE([o].[OrderNumber], N'N/A') AS [OrderNumber]
FROM [Customers] AS [c]
LEFT JOIN [Orders] AS [o] ON [c].[Id] = [o].[CustomerId]
The output would be:
Output
And with EF Core 10.0, we can rewrite the same query as follows:
var query = context.Customers
    .LeftJoin(
        context.Orders,
        customer => customer.Id,
        order => order.CustomerId,
        (customerorder) => new
        {
            CustomerName = customer.Name,
            OrderNumber = order.OrderNumber ?? "N/A"
        });

If you run this with an EF Core version prior to 10.0, you will be getting a could not be translated error). But with EF Core 10.0, this would generate the same SQL query as before and will give the same output. The same would be applicable for RIGHT JOIN.

That's neat.

Happy Coding,

Regards,
Jaliya

Saturday, May 3, 2025

ASP.NET Core 10.0: OpenAPI Document in YAML Format

ASP.NET Core 10.0, now supports OpenAPI specification in YAML format. YAML also supports multi-line strings, which can be useful for long descriptions.

To configure an app to serve the generated OpenAPI document in YAML format, specify the endpoint in the MapOpenApi call with a .yaml or .yml suffix, as shown in the following example:

app.MapOpenApi();
app.MapOpenApi("/openapi/v1.yaml");

Now we are exposing OpenAPI specification in both JSON (default) and YAML format.

OpenAPI Document in YAML Format

Hope this helps.

Happy Coding.

Regards,
Jaliya

Friday, May 2, 2025

ASP.NET Core 10.0: OpenAPI 3.1 Support

From ASP.NET Core 10.0, the default OpenAPI version would be 3.1.x. Prior to ASP.NET Core 10.0, the default OpenAPI version was 3.0.x for as long as I could remember.
OpenAPI: 3.1.x
While this looks like a minor version bump, this is a significant update with breaking changes. If you want to keep using OpenAPI version 3.0.x, you can do so as follows:
builder.Services.AddOpenApi(x =>
{
    x.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_0;
});
If you are generating the OpenAPI document at build time, you can add the following MSBiild item.
<OpenApiGenerateDocumentsOptions>--openapi-version OpenApi3_0</OpenApiGenerateDocumentsOptions>
Hope this helps.

Happy Coding.

Regards,
Jaliya