September 2, 2021

Custom Model Binding in ASP.NET 6.0 Minimal APIs

My previous post provided a step-by-step guide for MVC developers new to ASP.NET 6.0 Minimal APIs. In the section on model binding I mentioned that unlike MVC, it was not possible to bind complex objects from query string values using the [FromQuery] attribute. Fortunately .NET 6.0 rc1 introduces two extensibility points for customizing model binding that enable you to achieve the same goal.

You can download .NET 6.0 rc1 from GitHub. Make sure you add a nuget.config with the necessary internal feeds.

BindAsync

BindAsync is a static method that can be added to your types in order to take full control of model binding. It has the following signature:

public static ValueTask<TModel?> BindAsync(HttpContext httpContext, ParameterInfo parameter)

During the construction of the RequestDelegate (the handler for your Minimal API endpoint), ASP.NET 6.0 will look for this method on the given parameter type and execute it if present. Since it provides access to HttpContext you can use data from any part of the request to bind the target type.

In the previous post I mentioned a search endpoint with multiple, optional criteria as a good use case for binding query string values to a complex type. Below is the updated example making use of BindAsync:

app.MapGet("/search", (SearchParams? search) => search); // Echo the response back for demo purposes

app.Run();

public record SearchParams(string? Term, int Page, int PageSize)
{
    public static ValueTask<SearchParams?> BindAsync(HttpContext httpContext, ParameterInfo parameter)
    {
        int.TryParse(httpContext.Request.Query["page"], out var page);
        int.TryParse(httpContext.Request.Query["page-size"], out var pageSize);

        return ValueTask.FromResult<SearchParams?>(
            new SearchParams(
                httpContext.Request.Query["term"], 
                page == 0 ? 1 : page, 
                pageSize == 0 ? 10 : pageSize
            )
        );
    }
}

When you consider the ceremony involved to achieve custom model binding in MVC this approach is far cleaner and easier to implement.

Damian Edwards takes this one step further in his Minimal API playground leveraging the C# proposal for static members in interfaces to provide an interface for developers to implement:

    public interface IExtensionBinder<TSelf> where TSelf : IExtensionBinder<TSelf>
    {
        /// <summary>
        /// The method discovered by RequestDelegateFactory on types used as parameters of route
        /// handler delegates to support custom binding.
        /// </summary>
        /// <param name="context">The <see cref="HttpContext"/>.</param>
        /// <param name="parameter">The <see cref="ParameterInfo"/> for the parameter being bound to.</param>
        /// <returns>The value to assign to the parameter.</returns>
        static abstract ValueTask<TSelf?> BindAsync(HttpContext context, ParameterInfo parameter);
    }

This is certainly preferable as it would make this extensibility point easier to discover and less error prone.

TryParse

The other extensibility point you can leverage for parameter binding is another static method, TryParse that has the following signature:

public static bool TryParse(string? value, IFormatProvider? provider, out T parameter)

This time you don’t have access to HttpContext so this method is more useful for binding simple string values. Returning false will result in a HTTP 400 (Bad Request) response being returned (source).

Damian has a nice example in his repo of using TryParse to bind coordinates to a Point type:

app.MapGet("/point", (Point point) => $"Point: {point}");
app.Run()

public class Point
{
    public double X { get; set; }

    public double Y { get; set; }

    public override string ToString() => $"({X},{Y})";

    public static bool TryParse(string? value, IFormatProvider? provider, out Point point)
    {
        // Format is "(12.3,10.1)"
        var trimmedValue = value?.TrimStart('(').TrimEnd(')');
        var segments = trimmedValue?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (segments?.Length == 2
            && double.TryParse(segments[0], out var x)
            && double.TryParse(segments[1], out var y))
        {
            point = new Point { X = x, Y = y };
            return true;
        }

        point = new Point();
        return false;
    }
}

A request to /point?point=(12.3,10.1) returns:

Point: (12.3,10.1)

The name of the query parameter must match the name of the input variable, in this case point. You can also take advantage of TryParse for binding route parameters, for example:

app.MapGet("/map/{point}", (Point point) => $"Point: {point}");

This time requesting /map/(12.3,10.1) will yield the same result.

© 2022 Ben Foster