Parameter Binding and Endpoint Filters
Objective
A minimal API handler is an ordinary delegate, and the framework builds the
glue that fills each of its parameters from the request. That glue has a fixed
precedence, a fixed set of failure codes, and three extension points: your own
TryParse, your own BindAsync, and endpoint filters that wrap the handler.
The routing concept showed the everyday cases. This one is about what happens at
the edges: when a value does not convert, when a type has to read two places at
once, when a form needs antiforgery, and when a rule should run before the
handler for one endpoint or a whole group.
Use Cases
- Accepting a domain value in a URL (
/map?p=12.3,10.1, aMoney, anOrderId) without parsing strings inside every handler. - Collapsing a six-parameter handler into one
[AsParameters]request type. - Building a value from several places at once, such as a tenant resolved from a header with a query fallback.
- Handling an HTML form post, with or without a file, safely.
- Applying one rule (normalize, reject, log) around many handlers without touching them.
Deep Dive
The order the framework tries
Per the documentation, a parameter's source is decided in this order: an
explicit [From*] attribute; then special types (HttpContext, HttpRequest,
CancellationToken, ClaimsPrincipal, IFormFile, Stream...); then a type with
a static BindAsync; then string or a type with a static TryParse (route if
the name is in the template, otherwise query string); then a service registered
in the container; and only then the JSON body. The last two matter most: the
service check comes before the body, and the body is the fallback for anything left over, which explains the surprise below.
I ran these on SDK 10.0.100 (file-based apps, curl against Kestrel). The
failure codes were:
textGET /items/abc?p=2 ([FromRoute] int id) -> 400 GET /items/7 (missing required int) -> 400 GET /map?p=12.3 (TryParse returns false) -> 400 GET /who (BindAsync returns null) -> 400 GET /who X-Caller: boom (BindAsync throws) -> 500 POST /post-body text/plain -> 415 POST /post-body {bad -> 400 POST /post-unreg (service never registered, no body) -> 400 POST /post-unreg {} (same, body "{}") -> 200 "handler ran"
The last two lines are the trap. A service you forgot to register is not
reported as a missing service: the parameter falls through to the body. With no
body you get a puzzling 400. With a JSON body, even {}, the framework creates
an empty instance and your handler runs with a useless object. Register the
service, and mark the parameter [FromServices]: with the attribute, the same
request failed with No service for type 'Svc' has been registered (a 500)
instead of binding an empty body.
One bad signature takes the whole app down
The routing concept mentions that a complex type on a GET fails with
Body was inferred but the method does not allow inferred body parameters.
Two details from running it. GET, HEAD, OPTIONS and DELETE never infer a
body, while [FromBody] on them is allowed. And the failure is not local: the
endpoints are built together on the first request, so with one bad handler
among three, /healthy and even an unmatched /nope answered 500:
csharpapp.MapGet("/healthy", () => "I am fine");
app.MapGet("/bad", (Filter f) => f); // inferred body on GETtextGET /healthy -> 500 GET /nope -> 500 (log: InvalidOperationException, Body was inferred ...)
The app starts without complaint in Production, so a smoke test that calls
any endpoint once, for example with WebApplicationFactory, catches it in CI.
Explicit sources and [AsParameters]
Attributes are worth writing wherever a refactor could silently change the source. A header can bind straight to a number or an array, and repeated query keys bind to arrays:
csharpapp.MapGet("/arr", (int[] ids, string[] tags, [FromHeader(Name = "X-Nums")] int[] nums) => ...);
// GET /arr?ids=1&ids=2&tags=a&tags=b X-Nums: 3, 4 -> ids=[1,2] tags=[a,b] nums=[3,4]
// GET /arr?ids=1&ids=x -> 400
// GET /arr -> 200, all three emptyNote the last line: a missing array is empty, not an error, while a missing
int is a 400. [AsParameters] expands one type into its constructor
parameters, each bound by the normal rules, including services and the
cancellation token:
csharpapp.MapGet("/search/{category}", ([AsParameters] SearchRequest r) => r.ToString());
record SearchRequest([FromRoute] string Category, [FromQuery] int Page,
[FromHeader(Name = "X-Tenant")] string Tenant, Clock Clock, CancellationToken Ct);
// GET /search/books?page=3 X-Tenant: acme -> Category = books, Page = 3, Tenant = acme
// GET /search/books -> 400It is a flat expansion, not recursive model binding: nested complex types are not walked. The docs note that a struct can be cheaper than a record here.
Custom binding: TryParse, BindAsync, IBindableFromHttpContext
For one value coming from one string (route, query or header), add a static
TryParse. The two-argument form receives an IFormatProvider, which in my run
was the invariant culture, so parsing does not depend on the server's locale:
csharpreadonly record struct Point(double X, double Y)
{
public static bool TryParse(string? s, IFormatProvider? fp, out Point r) { /* split, double.TryParse(..., InvariantCulture) */ }
}
app.MapGet("/map", (Point p) => FormattableString.Invariant($"Point: {p.X}, {p.Y}"));
// GET /map?p=12.3,10.1 -> Point: 12.3, 10.1 GET /map -> 400 GET /map-opt?p=bad -> 400When the value needs the whole request, implement BindAsync(HttpContext) on
the type, or IBindableFromHttpContext<T> (static abstract, C# 11) which also
receives the ParameterInfo. Returning null becomes a 400, but throwing
becomes a 500, so validate and return null instead of throwing. Whichever you
pick, the result of a custom binder is checked before the handler, so the
handler can trust it.
Forms and antiforgery
[FromForm] binds application/x-www-form-urlencoded and multipart data, and the
documentation says parameters bound with [FromForm] include an antiforgery
token that is validated when the request is processed. With AddAntiforgery() and
UseAntiforgery() in place, a form post without a token was rejected:
textPOST /form name=ana&age=30 -> 400 (no token) POST /form same, cookie + __RequestVerificationToken -> 200 name=ana age=30 POST /form age=abc (with token) -> 400 POST /form Content-Type: application/json -> 415
The token comes from IAntiforgery.GetAndStoreTokens(context) when you render the
page. .DisableAntiforgery() turns the check off per endpoint (right for an API
called by a non-browser client with its own auth, wrong for a cookie-authenticated
form). New in .NET 10, per the release notes: when [FromForm] binds a complex
type, an empty string for a nullable value type becomes null instead of a
parse failure. I confirmed DueDate= gives due=null on a TodoForm record,
while a top-level [FromForm] DateOnly? due parameter still answered 400 for
the same empty value, so do not generalize.
Endpoint filters: wrapping the handler
AddEndpointFilter receives an EndpointFilterInvocationContext and the next
delegate. Filters run first-in, first-out on the way in and in reverse on the
way out, and a filter added to a MapGroup is the outermost: the trace for a
grouped endpoint with two filters was group> f1> f2> handler. Returning without
calling next short-circuits, and what you return becomes the response:
csharpapp.MapPost("/args", (int id, Clock clock, CreateDto dto) => ...)
.AddEndpointFilter(async (ctx, next) =>
{
var dto = ctx.GetArgument<CreateDto>(2); // index counts services too
return dto.Name.Contains('<') ? Results.Problem("bad chars", statusCode: 422)
: await next(ctx);
});ctx.Arguments listed Int32, Clock, CreateDto: the index follows the handler
signature, services included. Asking for the wrong index threw
InvalidCastException and answered 500, which is why a filter factory is
safer. AddEndpointFilterFactory runs once per endpoint when it is built and
receives the MethodInfo, so it can locate the parameter by type and return next
untouched when the handler has none. A class implementing IEndpointFilter
(AddEndpointFilter<T>()) is constructed per request, with constructor
injection, scoped services included, and it does not need a registration.
Three behaviors worth knowing. A route or query value that fails to convert (or is
missing) still runs the filter, with the default value (arg0=0), and the 400
is applied after; a JSON body that fails to parse answers 400 without running the filter.
So a filter must never assume an argument is non-null. Second, with
AddValidation() a DTO that violates its annotations returned 400 and my
custom filter did not run, so your filters see only valid input. That holds
only for discovered types: the same record declared internal returned 200
with the invalid body, as the validation concept warns, so keep request DTOs
public. Third, a filter
can translate exceptions: wrapping next in a try and returning
TypedResults.NotFound(...) for a KeyNotFoundException turned a throw into a
404. After next, the value is the handler's raw return: a String for a
lambda returning text, Ok<string> for TypedResults.Ok.
Trade-offs
- Convention binding is compact and silent. A forgotten registration or a
moved parameter changes the source with no compile error. Use
[FromServices]and[FromBody]where being wrong is expensive, and keep a test per endpoint.csharpapp.MapPost("/orders", ([FromBody] CreateOrder dto, [FromServices] IOrderService svc) => ...); TryParsegives a bare400. There is no message and no field name. If clients need a reason, bind astringand parse in a filter, so you control the problem body.BindAsynchides I/O in a parameter. It is handy for a tenant or a caller, but a handler signature that reads three headers through a type is harder to see in the OpenAPI document than three explicit parameters.- Index-based
GetArgumentis brittle. Reordering handler parameters breaks the filter at runtime. Prefer a factory that finds the parameter by type, or an[AsParameters]request type whose position never moves. - Filters are per endpoint; middleware is global. A filter sees typed
arguments and results, middleware sees only
HttpContext. Use middleware for host-wide rules and filters for rules that depend on a handler's inputs. - Disabling antiforgery is a security decision, not a convenience. It is fine for a token-authenticated API, and an open door for a cookie-authenticated HTML form.