Binding de Parâmetros e Endpoint Filters
Objective
Um handler de minimal API é um delegate comum, e o framework monta a cola que
preenche cada parâmetro a partir da requisição. Essa cola tem uma precedência
fixa, um conjunto fixo de códigos de falha e três pontos de extensão: o seu
próprio TryParse, o seu próprio BindAsync e os endpoint filters que embrulham
o handler. O conceito de roteamento mostrou os casos do dia a dia. Este trata
das bordas: o que acontece quando um valor não converte, quando um tipo precisa
ler dois lugares ao mesmo tempo, quando um formulário exige antiforgery e quando
uma regra deve rodar antes do handler para um endpoint ou para um grupo inteiro.
Use Cases
- Aceitar um valor de domínio na URL (
/map?p=12.3,10.1, umMoney, umOrderId) sem fazer parse de string dentro de cada handler. - Reduzir um handler de seis parâmetros a um único tipo
[AsParameters]. - Montar um valor a partir de vários lugares, como um tenant lido de um header com fallback na query string.
- Tratar o post de um formulário HTML, com ou sem arquivo, de forma segura.
- Aplicar uma regra (normalizar, rejeitar, logar) em volta de muitos handlers sem mexer neles.
Deep Dive
A ordem que o framework tenta
Segundo a documentação, a origem de um parâmetro é decidida nesta ordem: um
atributo [From*] explícito; depois os tipos especiais (HttpContext,
HttpRequest, CancellationToken, ClaimsPrincipal, IFormFile, Stream...);
depois um tipo com BindAsync estático; depois string ou um tipo com
TryParse estático (route se o nome está no template, senão query string); depois
um serviço registrado no container; e só então o body JSON. Os dois últimos são
os que mais importam: a checagem de serviço vem antes do body, e o body é o
fallback para tudo que sobrou, o que explica a surpresa mais abaixo.
Rodei tudo no SDK 10.0.100 (file-based apps, curl contra o Kestrel). Os
códigos de falha foram:
textGET /items/abc?p=2 ([FromRoute] int id) -> 400 GET /items/7 (int obrigatório ausente) -> 400 GET /map?p=12.3 (TryParse devolve false) -> 400 GET /who (BindAsync devolve null) -> 400 GET /who X-Caller: boom (BindAsync lança exceção) -> 500 POST /post-body text/plain -> 415 POST /post-body {bad -> 400 POST /post-unreg (serviço nunca registrado, sem body) -> 400 POST /post-unreg {} (mesmo caso, body "{}") -> 200 "handler ran"
As duas últimas linhas são a armadilha. Um serviço que você esqueceu de
registrar não é reportado como serviço ausente: o parâmetro cai para o body. Sem
body você recebe um 400 confuso. Com um body JSON, mesmo {}, o framework cria
uma instância vazia e o handler roda com um objeto inútil. Registre o serviço e
marque o parâmetro com [FromServices]: com o atributo, a mesma requisição
falhou com No service for type 'Svc' has been registered (um 500) em vez de
ligar um body vazio.
Uma assinatura ruim derruba o app inteiro
O conceito de roteamento menciona que um tipo complexo num GET falha com
Body was inferred but the method does not allow inferred body parameters. Dois
detalhes de quando rodei. GET, HEAD, OPTIONS e DELETE nunca inferem body,
mas [FromBody] neles é permitido. E a falha não é local: os endpoints são
construídos juntos na primeira requisição, então com um handler ruim entre três,
/healthy e até um /nope sem rota responderam 500:
csharpapp.MapGet("/healthy", () => "I am fine");
app.MapGet("/bad", (Filter f) => f); // body inferido num GETtextGET /healthy -> 500 GET /nope -> 500 (log: InvalidOperationException, Body was inferred ...)
O app sobe sem reclamar em Production, então um smoke test que chame qualquer
endpoint uma vez, por exemplo com WebApplicationFactory, pega isso no CI.
Origens explícitas e [AsParameters]
Vale escrever atributos onde um refactor poderia mudar a origem em silêncio. Um header pode ligar direto a um número ou a um array, e chaves repetidas na query ligam a 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, os três vaziosRepare na última linha: um array ausente vem vazio, não é erro, enquanto um
int ausente é 400. [AsParameters] expande um tipo nos parâmetros do seu
construtor, cada um ligado pelas regras normais, inclusive serviços e o
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 -> 400É uma expansão plana, não um model binding recursivo: tipos complexos aninhados não são percorridos. A documentação observa que um struct pode ser mais barato que um record aqui.
Binding customizado: TryParse, BindAsync, IBindableFromHttpContext
Para um valor que vem de uma única string (route, query ou header), adicione um
TryParse estático. A forma com dois argumentos recebe um IFormatProvider, que
na minha execução foi a cultura invariante, então o parse não depende do locale
do servidor:
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 -> 400Quando o valor precisa da requisição inteira, implemente BindAsync(HttpContext)
no tipo, ou IBindableFromHttpContext<T> (static abstract, C# 11), que também
recebe o ParameterInfo. Devolver null vira 400, mas lançar exceção vira
500, então valide e devolva null em vez de lançar. Qualquer que seja a
escolha, o resultado de um binder customizado é checado antes do handler, e o
handler pode confiar nele.
Formulários e antiforgery
[FromForm] liga dados application/x-www-form-urlencoded e multipart, e a
documentação diz que os parâmetros ligados com [FromForm] incluem um token
antiforgery validado no processamento. Com AddAntiforgery() e
UseAntiforgery() configurados, um post sem token foi rejeitado:
textPOST /form name=ana&age=30 -> 400 (sem token) POST /form idem, cookie + __RequestVerificationToken -> 200 name=ana age=30 POST /form age=abc (com token) -> 400 POST /form Content-Type: application/json -> 415
O token vem de IAntiforgery.GetAndStoreTokens(context) quando você renderiza a
página. .DisableAntiforgery() desliga a checagem por endpoint (certo para uma
API chamada por cliente que não é navegador e tem autenticação própria, errado
para um formulário autenticado por cookie). Novidade do .NET 10, segundo as
release notes: quando [FromForm] liga um tipo complexo, uma string vazia para
um value type anulável vira null em vez de falha de parse. Confirmei que
DueDate= resulta em due=null num record TodoForm, enquanto um parâmetro
solto [FromForm] DateOnly? due ainda respondeu 400 para o mesmo valor vazio,
então não generalize.
Endpoint filters: embrulhando o handler
AddEndpointFilter recebe um EndpointFilterInvocationContext e o próximo
delegate. Os filtros rodam first-in, first-out na ida e na ordem inversa na
volta, e um filtro adicionado a um MapGroup é o mais externo: o trace de um
endpoint de grupo com dois filtros foi group> f1> f2> handler. Retornar sem
chamar next faz curto-circuito, e o que você retorna vira a resposta:
csharpapp.MapPost("/args", (int id, Clock clock, CreateDto dto) => ...)
.AddEndpointFilter(async (ctx, next) =>
{
var dto = ctx.GetArgument<CreateDto>(2); // o índice conta serviços também
return dto.Name.Contains('<') ? Results.Problem("bad chars", statusCode: 422)
: await next(ctx);
});ctx.Arguments listou Int32, Clock, CreateDto: o índice segue a assinatura do
handler, serviços incluídos. Pedir o índice errado lançou InvalidCastException
e respondeu 500, e é por isso que uma filter factory é mais segura.
AddEndpointFilterFactory roda uma vez por endpoint, quando ele é construído, e
recebe o MethodInfo, então pode localizar o parâmetro pelo tipo e devolver
next intacto quando o handler não tem esse parâmetro. Uma classe que implementa
IEndpointFilter (AddEndpointFilter<T>()) é construída por requisição, com
injeção no construtor, serviços scoped inclusive, e não precisa de registro.
Três comportamentos que valem conhecer. Um valor de route ou query que falha na
conversão (ou está ausente) ainda executa o filtro, com o valor default
(arg0=0), e o 400 é aplicado depois; um body JSON que não faz parse responde
400 sem executar o filtro. Logo, um filtro nunca deve assumir que um argumento
é não nulo. Segundo, com AddValidation() um DTO que viola as annotations
retornou 400 e o meu filtro customizado não rodou, então seus filtros só veem
entrada válida. Isso vale apenas para tipos descobertos: o mesmo record
declarado internal retornou 200 com o body inválido, como o conceito de
validação avisa, então mantenha os DTOs de request public. Terceiro, um filtro
pode traduzir exceções: envolver next num try e devolver
TypedResults.NotFound(...) para uma KeyNotFoundException transformou um throw
em 404. Depois de next, o valor é o retorno bruto do handler: um String
para uma lambda que devolve texto, Ok<string> para TypedResults.Ok.
Trade-offs
- O binding por convenção é compacto e silencioso. Um registro esquecido ou
um parâmetro movido muda a origem sem erro de compilação. Use
[FromServices]e[FromBody]onde errar é caro e mantenha um teste por endpoint.csharpapp.MapPost("/orders", ([FromBody] CreateOrder dto, [FromServices] IOrderService svc) => ...); TryParsedá um400seco. Não há mensagem nem nome de campo. Se o cliente precisa de um motivo, ligue umastringe faça o parse num filtro, e assim você controla o corpo do problema.BindAsyncesconde I/O dentro de um parâmetro. É prático para tenant ou caller, mas uma assinatura que lê três headers através de um tipo é mais difícil de ver no documento OpenAPI do que três parâmetros explícitos.GetArgumentpor índice é frágil. Reordenar os parâmetros do handler quebra o filtro em runtime. Prefira uma factory que ache o parâmetro pelo tipo, ou um tipo[AsParameters]cuja posição nunca muda.- Filtros são por endpoint; middleware é global. O filtro enxerga argumentos
e resultados tipados, o middleware só enxerga o
HttpContext. Use middleware para regras do host inteiro e filtros para regras que dependem das entradas de um handler. - Desligar o antiforgery é uma decisão de segurança, não uma conveniência. É aceitável numa API autenticada por token e uma porta aberta num formulário HTML autenticado por cookie.