Minimal APIs are great. I love Minimal APIs.

One of the things I like is how they have discarded the controller concept (which I always felt was a remnant of MVC, and just put there to keep Web APIs looking like MVC).

But, not everything works as you would expect, for instance model bindings.

Problem

When trying to use the [FromQuery] attribute (from the Microsoft.AspNetCore.Mvc package) with a complex type in an endpoint:

app.MapGet("/", ([FromQuery]QueryParameters? parameters) => parameters);

This will fail with the following error message:

System.InvalidOperationException: No public static bool QueryParameters.TryParse(string, out QueryParameters) method found for parameters

This is because Minimal API does not support complex types when using the [FromQuery] attribute.

Solution

The Microsoft documentation describes how to mitigate this using custom bindings, choosing one of two ways:

💡
1. For route, query, and header binding sources, bind custom types by adding a static TryParse method for the type.
2. Control the binding process by implementing a BindAsync method on a type.

I've created a code example using a record QueryParameters which contains a list of id's, skip and take, relevant for filtering/paging.
The record also contains a method BindAsyc which does the "magic" by parsing the Http Context and mapping its values to the record values.
Voilá - the QueryParameters record can now be used by the endpoint handler.

Code:

using Microsoft.AspNetCore.Http.Json;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<JsonOptions>(options =>
{
    options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});

var app = builder.Build();
app.UseHttpsRedirection();

app.MapGet("/", (QueryParameters? parameters) => parameters);

app.Run();


public record QueryParameters(IReadOnlyList<string>? Ids, int? Skip, int? Take)
{
    public static ValueTask<QueryParameters?> BindAsync(HttpContext context)
        => ValueTask.FromResult<QueryParameters?>(new (
            Ids: !string.IsNullOrEmpty(context.Request.Query["ids"]) ? context.Request.Query["ids"].ToString().Split(',').ToList().AsReadOnly() : null,
            Skip: int.TryParse(context.Request.Query["skip"], out var skip) ? skip : null,
            Take: int.TryParse(context.Request.Query["take"], out var take) ? take : null));
}

The code is available on GitHub.

Update 2. August 2022:
David Fowler (!) actually tweeted about this blog post, pointing to how this will be handled in .NET7 using an [AsParameters] attribute.

The blog post:
https://jaliyaudagedara.blogspot.com/2022/07/net-7-preview-5-using-asparameters.html

The tweet: