Como vincular um objeto complexo da query string com [AsParameters] em uma minimal API no ASP.NET Core 11
Coloque [AsParameters] em uma classe ou record para vincular um filtro inteiro da query string em uma minimal API do ASP.NET Core 11. Aborda valores padrão, arrays, enums, objetos aninhados, validação, OpenAPI e um bug do gerador de Native AOT com records posicionais.
Resposta curta: declare as chaves da query como propriedades de uma classe (ou como parâmetros do construtor de um record) e coloque [AsParameters] no parâmetro do handler: app.MapGet("/products", ([AsParameters] ProductFilter filter) => ...). O ASP.NET Core achata o tipo em parâmetros individuais, então ?search=lamp&page=2&tags=a&tags=b é vinculado a Search, Page e Tags pelo nome, sem diferenciar maiúsculas de minúsculas. Ele só lida com tipos planos: um objeto aninhado precisa do seu próprio TryParse ou BindAsync, e um valor opcional precisa ser anulável ou ter um valor padrão no construtor, porque um inicializador de propriedade como = 1 não torna a propriedade opcional.
Tudo o que vem a seguir foi executado no .NET 11 RC 1 (SDK 11.0.100-rc.1.26425.128, ASP.NET Core 11.0.0-rc.1.26425.128). O [AsParameters] chegou no .NET 7 e suas regras não mudaram desde então, então o mesmo código roda no .NET 8, 9 e 10. A única exceção que vale conhecer, um bug do gerador de código-fonte no caminho do Native AOT, também se reproduz no SDK 10.0.302.
Por que [FromQuery] ProductFilter não funciona
Para quem vem de controllers MVC, o reflexo é escrever [FromQuery] ProductFilter filter. Em uma minimal API do .NET 11 isso nem compila. O analisador ASP0020 reporta como erro de build:
error ASP0020: Parameter 'f' of type ProductFilter should define a bool
TryParse(string, IFormatProvider, out ProductFilter) method, or implement IParsable<ProductFilter>
Suprima o analisador e o app passa a falhar na inicialização, quando o endpoint é montado:
InvalidOperationException: f must have a valid TryParse method to support converting from a string.
No public static bool ProductFilter.TryParse(string, out ProductFilter) method found for f.
Remova o atributo e fica ainda mais confuso. Um tipo complexo sem fonte de binding é inferido como o corpo da requisição, e o MapGet recusa corpos inferidos:
InvalidOperationException: Body was inferred but the method does not allow inferred body parameters.
Isso é intencional. As minimal APIs dispensam o model binder recursivo do MVC, e [FromQuery] significa “uma chave da query, convertida com TryParse”. A documentação de binding de parâmetros diz que AsParametersAttribute habilita binding simples de parâmetros para tipos, e não model binding complexo ou recursivo. O que ele faz é achatar: a factory de request delegate trata cada membro do tipo como um parâmetro separado do handler e depois aplica as regras normais (rota, query, header, serviços, tipos especiais) a cada membro. Esse modelo mental explica todos os comportamentos do restante deste post.
Vincule um filtro da query string passo a passo
- Crie um tipo com um membro por chave da query.
- Torne anulável cada membro opcional, ou dê a ele um valor padrão em um parâmetro do construtor de um record.
- Coloque
[AsParameters]no parâmetro do handler. - Renomeie ou mude a fonte de membros individuais com
[FromQuery(Name = ...)],[FromRoute]ou[FromHeader]. - Adicione DataAnnotations e chame
AddValidation()se precisar de verificações de intervalo.
Este é o filtro que usei:
// ASP.NET Core 11 (.NET 11 RC 1), C# 15, <Nullable>enable</Nullable>
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/products", ([AsParameters] ProductFilter f) => f);
app.Run();
enum SortOrder { Asc, Desc }
class ProductFilter
{
public string? Search { get; set; }
public int? Page { get; set; }
public int? PageSize { get; set; }
public SortOrder? Sort { get; set; }
public DateOnly? Since { get; set; }
public string[] Tags { get; set; } = [];
[FromQuery(Name = "q")] public string? Keyword { get; set; }
}
E as respostas reais:
GET /products?search=lamp&page=2&pageSize=10&sort=Desc&since=2026-01-31&tags=a&tags=b&q=kw
200 {"search":"lamp","page":2,"pageSize":10,"sort":1,"since":"2026-01-31","tags":["a","b"],"keyword":"kw"}
GET /products?SEARCH=lamp&PAGE=3
200 {"search":"lamp","page":3,"pageSize":null,"sort":null,"since":null,"tags":[],"keyword":null}
GET /products
200 {"search":null,"page":null,"pageSize":null,"sort":null,"since":null,"tags":[],"keyword":null}
A chave da query é o nome do membro, comparado sem diferenciar maiúsculas de minúsculas, e [FromQuery(Name = "q")] a substitui para um membro. Chaves repetidas preenchem um array. DateOnly interpreta uma string ISO yyyy-MM-dd. Qualquer tipo de membro com um TryParse estático (todos os primitivos, Guid, DateTimeOffset, enums e os seus próprios tipos IParsable<T>) é vinculado a partir de uma única chave.
Obrigatório vs opcional: a armadilha do inicializador de propriedade
Esse é o erro que mais vejo. Parece uma classe com valores padrão sensatos:
// ASP.NET Core 11 (.NET 11 RC 1)
class RequiredFilter
{
public int Page { get; set; } = 1;
public bool InStock { get; set; }
public string Search { get; set; } = "";
}
GET /required sem query string retorna 400:
BadHttpRequestException: Required parameter "int Page" was not provided from query string.
A factory decide se um membro é opcional pela sua anulabilidade e, no caso de parâmetros de construtor, por um valor padrão declarado. Um inicializador de propriedade é apenas código dentro do construtor, invisível para reflection, então uma propriedade int, bool ou string não anulável é obrigatória independentemente do que você atribua a ela. O documento OpenAPI gerado concorda e marca as três como required: true.
Existem duas correções. Torne os membros anuláveis e aplique o valor padrão no handler (f.Page ?? 1), ou mude para um record posicional, onde os valores padrão do construtor contam:
// ASP.NET Core 11 (.NET 11 RC 1)
record ProductQuery(string? Search, int Page = 1, int PageSize = 20,
SortOrder Sort = SortOrder.Asc, string[]? Tags = null);
app.MapGet("/products-record", ([AsParameters] ProductQuery q) => q);
GET /products-record -> {"search":null,"page":1,"pageSize":20,"sort":0,"tags":[]}
GET /products-record?page=4 -> {"search":null,"page":4,"pageSize":20,"sort":0,"tags":[]}
Repare que Tags voltou como [], e não null, apesar do padrão = null: um array sem chaves correspondentes é vinculado como array vazio. Um record struct PagingStruct(int Page = 1, int PageSize = 20) se comporta da mesma forma e retornou {"page":1,"pageSize":20}. A documentação observa que uma struct pode ter melhor desempenho do que uma classe record porque evita uma alocação por requisição; eu não fiz benchmark disso, então trate como uma afirmação da documentação.
Vale saber como a factory escolhe os membros. A lógica em PropertyAsParameterInfo.cs é: se o tipo tem um único construtor público com parâmetros, vincula os parâmetros dele (associados às propriedades pelo nome). Caso contrário, usa o construtor sem parâmetros e vincula todas as propriedades graváveis. Uma propriedade somente leitura em uma classe sem esse tipo de construtor é ignorada silenciosamente. Dois construtores públicos com parâmetros falham com Only a single public parameterized constructor is allowed for type 'TwoCtors'., e um tipo abstrato falha com The abstract type 'AbstractFilter' is not supported.
Misture valores de rota, headers e serviços no mesmo tipo
Como cada membro passa pelas regras normais de binding, um único tipo [AsParameters] pode reunir a lista inteira de argumentos, e não só a query string:
// ASP.NET Core 11 (.NET 11 RC 1)
app.MapGet("/tenants/{tenantId:int}/orders", ([AsParameters] OrderRequest r) => new
{
r.TenantId, r.Region, r.Status, r.Ids, r.UserAgent,
Logger = r.Logger.GetType().Name,
Path = r.Http.Request.Path.Value,
CanCancel = r.Ct.CanBeCanceled
});
enum OrderStatus { Pending, Shipped, Cancelled }
class OrderRequest
{
[FromRoute(Name = "tenantId")] public int TenantId { get; set; }
[FromHeader(Name = "X-Region")] public string? Region { get; set; }
public OrderStatus? Status { get; set; }
public int[] Ids { get; set; } = [];
[FromHeader(Name = "User-Agent")] public string? UserAgent { get; set; }
public ILogger<OrderRequest> Logger { get; set; } = default!; // from DI
public HttpContext Http { get; set; } = default!; // special type
public CancellationToken Ct { get; set; } // RequestAborted
}
GET /tenants/42/orders?status=Shipped&ids=1&ids=2 (X-Region: eu-west)
200 {"tenantId":42,"region":"eu-west","status":1,"ids":[1,2],"userAgent":"curl/8.7.1",
"logger":"Logger`1","path":"/tenants/42/orders","canCancel":true}
Esse é o caso de uso com que o próprio exemplo da Microsoft abre: condensar uma assinatura longa de handler (int id, TodoDb db, ...) em um único tipo. Ele combina bem com agrupar endpoints com MapGroup, onde o prefixo da rota já carrega um valor {tenantId}.
Valores inválidos, enums que diferenciam maiúsculas e listas separadas por vírgula
Um valor que falha no TryParse produz um 400 antes que o seu handler seja executado:
GET /products?page=abc -> 400 Failed to bind parameter "Nullable<int> Page" from "abc".
GET /products?sort=Desc -> 200
GET /products?sort=desc -> 400 Failed to bind parameter "Nullable<SortOrder> Sort" from "desc".
O resultado do enum surpreende as pessoas: o binding usa a sobrecarga de Enum.TryParse que diferencia maiúsculas de minúsculas, então desc é rejeitado enquanto Desc funciona. Se os seus clientes enviam valores em minúsculas, vincule um string? e chame Enum.TryParse<SortOrder>(value, ignoreCase: true, out var sort) você mesmo, ou envolva o enum em um tipo pequeno com o seu próprio TryParse.
Em Development, a página de exceção do desenvolvedor mostra o texto de BadHttpRequestException acima. Fora de Development, o cliente recebe um 400 sem detalhes e o motivo vai apenas para o log de debug, então não use essa mensagem como contrato da sua API.
Listas separadas por vírgula não são divididas:
GET /products?tags=a,b -> 200 "tags":["a,b"] (one element)
GET /tenants/42/orders?ids=1,2 -> 400 Failed to bind parameter "int[] Ids" from "1,2".
Arrays só são vinculados a partir de chaves repetidas (?ids=1&ids=2). Se você precisa aceitar ids=1,2, vincule um string? e divida você mesmo, ou dê a um tipo personalizado um TryParse que faça a divisão.
Objetos aninhados precisam do seu próprio parser
Este é o formato que as pessoas realmente querem vincular:
class Money { public decimal Min { get; set; } public decimal Max { get; set; } }
class NestedFilter { public string? Q { get; set; } public Money? Price { get; set; } }
Price é um tipo complexo sem fonte de binding, então a factory o infere como o corpo, e em um GET o endpoint falha na inicialização com o mesmo erro Body was inferred but the method does not allow inferred body parameters. visto antes. Chaves no estilo ?price.min=10, que o model binder do MVC entende, não significam nada aqui. Colocar [AsParameters] em um membro aninhado também não ajuda. Isso lança NotSupportedException: Nested AsParametersAttribute is not supported and should be used only for handler parameters.
Você tem três opções, na ordem em que eu recorreria a elas.
Achate o objeto. decimal? MinPrice e decimal? MaxPrice é sem graça, gera a melhor saída de OpenAPI e não exige código.
Interprete uma chave com IParsable<T>. Um membro cujo tipo tem um TryParse estático é vinculado a partir de uma única chave:
// ASP.NET Core 11 (.NET 11 RC 1), C# 15
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
record PriceRange(decimal Min, decimal Max) : IParsable<PriceRange>
{
public static bool TryParse(string? s, IFormatProvider? provider,
[MaybeNullWhen(false)] out PriceRange result)
{
result = null;
if (s?.Split('-', 2) is not [var lo, var hi]) return false;
if (!decimal.TryParse(lo, NumberStyles.Number, CultureInfo.InvariantCulture, out var min)) return false;
if (!decimal.TryParse(hi, NumberStyles.Number, CultureInfo.InvariantCulture, out var max)) return false;
if (min > max) return false;
result = new PriceRange(min, max);
return true;
}
public static PriceRange Parse(string s, IFormatProvider? provider) =>
TryParse(s, provider, out var r) ? r : throw new FormatException($"'{s}' is not a price range.");
}
?price=10-50 é vinculado a {"min":10,"max":50}, e ?price=50-10 retorna 400 Failed to bind parameter "PriceRange Price" from "50-10".
Leia chaves com ponto usando BindAsync. Se o formato na requisição é fixo em budget.min=5&budget.max=99, implemente BindAsync(HttpContext, ParameterInfo). Dentro de um tipo [AsParameters], parameter.Name é o nome da propriedade, então o prefixo vem de graça:
// ASP.NET Core 11 (.NET 11 RC 1)
using System.Globalization;
using System.Reflection;
record DottedRange(decimal? Min, decimal? Max)
{
public static ValueTask<DottedRange?> BindAsync(HttpContext context, ParameterInfo parameter)
{
var q = context.Request.Query;
decimal? Read(string key) => decimal.TryParse(q[$"{parameter.Name}.{key}"],
NumberStyles.Number, CultureInfo.InvariantCulture, out var v) ? v : null;
var (min, max) = (Read("min"), Read("max"));
return ValueTask.FromResult(min is null && max is null ? null : new DottedRange(min, max));
}
}
class RangeFilter
{
public PriceRange? Price { get; set; }
public DottedRange? Budget { get; set; }
public string? Q { get; set; }
}
GET /by-range?price=10-50&budget.min=5&budget.max=99&q=chair
200 {"price":{"min":10,"max":50},"budget":{"min":5,"max":99},"q":"chair"}
O custo do BindAsync é a documentação: o gerador de OpenAPI embutido listou Price (como string) e Q para esse endpoint e deixou Budget totalmente de fora. Se você precisa que ele apareça documentado, adicione-o com um transformer de operação.
Mais uma restrição: o próprio parâmetro [AsParameters] não pode ser anulável. [AsParameters] ProductFilter? f falha com The nullable type 'ProductFilter' is not supported, mark the parameter as non-nullable.
Validação e saída de OpenAPI
A validação embutida das minimal APIs entende os membros de [AsParameters], incluindo parâmetros do construtor de records. Com builder.Services.AddValidation() registrado (a mesma configuração usada para validar corpos de requisição):
// ASP.NET Core 11 (.NET 11 RC 1)
using System.ComponentModel.DataAnnotations;
record ValidatedPaging([Range(1, 1000)] int Page = 1, [Range(1, 100)] int PageSize = 20);
app.MapGet("/validated", ([AsParameters] ValidatedPaging p) => p);
GET /validated?pageSize=500
400 {"title":"One or more validation errors occurred.","status":400,
"errors":{"PageSize":["The field PageSize must be between 1 and 100."]}}
O Microsoft.AspNetCore.OpenApi 11.0.0-rc.1 transforma o mesmo tipo em parâmetros de query com minimum, maximum e default. No .NET 10, essa combinação exata (um atributo de validação em um parâmetro do construtor primário de um record [AsParameters]) fazia a geração do documento lançar InvalidCastException; esse era o dotnet/aspnetcore#65348, corrigido para o .NET 11 pelo PR #67284. Se você ainda está no .NET 10, coloque os atributos em uma classe com propriedades. O documento também mostra a armadilha do array vista antes, pelo outro lado. Um string[] Tags { get; set; } = [] não anulável é documentado como required: true, mesmo que o runtime vincule sem problemas uma chave ausente como array vazio, então clientes gerados vão insistir em enviá-lo. Declare arrays opcionais como string[]? e o documento os marca como opcionais.
Native AOT: records posicionais com tipos de referência anuláveis não compilam
Com <PublishAot>true</PublishAot>, o build ativa o Request Delegate Generator, que substitui a factory de runtime por código gerado (a stack abordada em Native AOT com minimal APIs). A maioria dos erros de inicialização acima vira aviso de build nesse caso: RDG009 para [AsParameters] aninhado, RDG010 para um parâmetro anulável, RDG005 para um tipo abstrato e RDG008 para múltiplos construtores. Isso é uma melhoria.
Ele também tem um bug. Este endpoint:
// .NET 11 RC 1 and SDK 10.0.302, <PublishAot>true</PublishAot> or <EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
app.MapGet("/a", ([AsParameters] F f) => f.Page?.ToString() ?? "none");
record F(string? Q, int? Page);
falha ao compilar com quatro cópias de:
GeneratedRouteBuilderExtensions.g.cs: error CS8639: The typeof operator cannot be used on a nullable reference type
O gerador localiza o construtor do record emitindo typeof(F).GetConstructor(new[] { typeof(string?), typeof(int?) }), e typeof(string?) não é C# válido. int? funciona porque é Nullable<int>. Compilei seis variantes do mesmo endpoint com o gerador ativado:
Tipo [AsParameters] | Compila? |
|---|---|
record F(string? Q, int? Page) | Não, CS8639 |
record F(string[]? Tags, int? Page) | Não, CS8639 |
record F(string Q = "", int? Page = null) | Sim |
record struct F(string? Q, int? Page) | Sim |
record F { public string? Q { get; init; } ... } | Sim |
| O mesmo record posicional, gerador desativado (build JIT comum) | Sim |
Então o gatilho é uma classe record posicional cujo construtor tem um parâmetro de tipo de referência anulável. O build JIT comum funciona, e é por isso que o problema costuma aparecer só quando alguém ativa o PublishAot (a documentação diz que o trimming também ativa o gerador). Até que seja corrigido, use uma classe com propriedades graváveis, um record com propriedades init ou um record struct posicional para tipos [AsParameters] em projetos AOT. Não encontrei uma issue de acompanhamento para isso no dotnet/aspnetcore no momento em que escrevi.
Leia a seguir
- Minimal APIs vs controllers no ASP.NET Core 11, incluindo onde o model binder do MVC ainda leva vantagem.
- Unions do C# no ASP.NET Core 11, outro caso em que a query string é a única fonte de binding que não coopera.
- Paginação por keyset (cursor) no EF Core 11, um consumidor natural para o filtro de paginação construído aqui.
- Por que um dicionário
[FromForm]é sempre null em uma minimal API, o primo do problema de objetos aninhados no binding de formulários.
Fontes
- Microsoft Learn, Parameter binding in Minimal API applications (a seção de
[AsParameters]e a lista de precedência das fontes de binding). - Microsoft Learn, referência da API
AsParametersAttribute. - Microsoft Learn, ASP0020: Complex types referenced by route parameters must be parsable.
- Microsoft Learn, diagnósticos do Request Delegate Generator RDG009 e RDG010.
- dotnet/aspnetcore em
v11.0.0-rc.1.26425.128:PropertyAsParameterInfo.cs(achatamento de membros, verificação de anulável) eRequestDelegateFactory.cs(verificação de[AsParameters]aninhado).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.