Minimal APIs: Roteamento e Route Groups
Objective
Uma minimal API é uma aplicação ASP.NET Core cujos endpoints são delegates
simples mapeados para uma rota, sem uma classe de controller no meio. A
aplicação inteira começa no Program.cs: um builder registra os serviços,
Build() produz o WebApplication, e o código depois dele descreve o pipeline
de requisição e os endpoints. O objetivo é saber como uma URL vira uma chamada
de handler (templates de rota e binding de parâmetros), como nomear e agrupar
rotas para que continuem fáceis de manter, e como mover os endpoints para fora
do Program.cs antes que ele vire um arquivo de mil linhas.
Use Cases
- Construir uma API REST pequena (um CRUD sobre um recurso) com o mínimo de cerimônia que o framework permite.
- Dividir uma API que cresce por recurso, com um método de extensão e um route
group por recurso, em vez de um
Program.csgigante. - Devolver um header
Locationque aponta para o endpoint que lê o recurso recém-criado, sem fixar a URL no código. - Aplicar uma política (autorização, uma tag, um filtro) a todos os endpoints de um recurso num lugar só.
Deep Dive
Duas fases num arquivo
O Program.cs tem duas seções separadas por Build(). Antes dele você
configura o que a app tem (serviços, configuração, logging); depois, o que a
app faz com cada requisição:
csharpvar builder = WebApplication.CreateBuilder(args); // fase 1: serviços
builder.Services.AddValidation();
var app = builder.Build(); // fase 2: pipeline
app.MapGet("/", () => "Hello World!");
app.Run();CreateBuilder já liga o Kestrel, a configuração vinda de JSON e variáveis de
ambiente e o logging. Build() congela a coleção de serviços: registrar um
serviço depois dele é tarde demais.
Como uma URL chega ao handler: templates e binding
MapGet, MapPost, MapPut e MapDelete recebem um template de rota e um
delegate. Os parâmetros do delegate são ligados por convenção, sem atributos
nos casos comuns:
csharpapp.MapGet("/games/{id}", (int id) => ...); // valor da rota
app.MapGet("/games", (string? genre, int page = 1) => ...); // query string
app.MapPost("/games", (CreateGameDto dto) => ...); // corpo JSON
app.MapGet("/games", (GameStoreContext db) => ...); // serviço registradoAs regras, na ordem em que o framework as aplica:
- Um nome que aparece no template da rota é ligado a partir da rota.
- Um tipo simples (
int,string,Guid,DateOnly) que não está no template é ligado a partir da query string. - Um tipo registrado no container é injetado.
- Qualquer outro tipo complexo é ligado a partir do corpo JSON, mas só para
verbos que aceitam corpo (
POST,PUT,PATCH).
Falhas de conversão respondem por você. Com "/games/{id}" e um int id, uma
requisição para /games/abc devolve 400 Bad Request. Um parâmetro obrigatório
sem valor ((int page) e nenhum ?page=) também é 400. Se você quer que um
valor que não casa pareça uma rota inexistente, adicione uma constraint ao
template: "/games/{id:int}" faz /games/abc virar 404.
Rotas nomeadas e CreatedAtRoute
Uma rota pode ter um nome, e outro código pode pedir ao framework que monte a
URL dela. É assim que um POST aponta o cliente para o novo recurso:
csharpconst string GetGameEndpointName = "GetGame";
group.MapGet("/{id}", GetGame).WithName(GetGameEndpointName);
group.MapPost("/", async (CreateGameDto dto, GameStoreContext db) =>
{
// ... cria o jogo ...
return Results.CreatedAtRoute(
GetGameEndpointName, new { id = game.Id }, details);
});A resposta é 201 Created com um header Location: http://host/games/1 e o
recurso criado no corpo. O objeto anônimo fornece os valores da rota, então a
URL continua certa se você renomear o caminho depois. Mantenha o nome numa
constante: uma string digitada errada falha em runtime, não em compilação.
Route groups
MapGroup dá a um conjunto de endpoints um prefixo comum e um ponto comum de
configuração. Dentro do grupo, as rotas são relativas ao prefixo:
csharpvar group = app.MapGroup("/games").WithTags("Games");
group.MapGet("/", GetGames); // GET /games
group.MapGet("/{id}", GetGame); // GET /games/{id}
group.MapPost("/", CreateGame); // POST /games
group.MapPut("/{id}", UpdateGame); // PUT /games/{id}
group.MapDelete("/{id}", DeleteGame); // DELETE /games/{id}Tudo o que você encadeia no grupo vale para todos os endpoints dele:
RequireAuthorization(), WithTags(...), AddEndpointFilter(...),
ProducesProblem(...). Grupos podem ser aninhados (/api contendo /games), e
o prefixo é escrito uma vez, então não diverge entre cinco cópias.
Tirando os endpoints do Program.cs
O passo seguinte comum é uma classe estática por recurso, com um método de
extensão em IEndpointRouteBuilder. Receber a interface em vez de
WebApplication faz o método funcionar também dentro de outro grupo ou de um
módulo (veja os próximos conceitos), e não só na app:
csharppublic static class GamesEndpoints
{
const string GetGameEndpointName = "GetGame";
public static IEndpointRouteBuilder MapGamesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/games").WithTags("Games");
group.MapGet("/", GetGames);
group.MapGet("/{id}", GetGame).WithName(GetGameEndpointName);
// ...
return app;
}
static async Task<IResult> GetGame(int id, GameStoreContext db) { /* ... */ }
}Handlers declarados como métodos estáticos mantêm o método de mapeamento curto
e podem ser testados como qualquer outro método. O Program.cs volta a ser uma
lista de chamadas: app.MapGamesEndpoints(); app.MapGenresEndpoints();.
Onde o binding surpreende
- Um tipo complexo num
GET.app.MapGet("/x", (Dto d) => d)compila e a app sobe. A primeira requisição falha com500eInvalidOperationException: Body was inferred but the method does not allow inferred body parameters. Leia o filtro da query com[AsParameters]ou passe paraPOST. - Um serviço que nunca foi registrado. O parâmetro passa a ser tratado como parâmetro de corpo, então o erro que você vê é de binding de corpo, não de serviço ausente.
- Ordem de registro não é ordem de casamento. As rotas casam pela
especificidade do template, não pela ordem das chamadas
Map*, então/games/{id}e/games/searchnão se escondem uma à outra.
Trade-offs
- Nenhuma estrutura imposta. Controllers dão a todo time as mesmas pastas e nomes. Minimal APIs dão liberdade, o que significa que a disciplina (um arquivo por recurso, handlers estáticos, grupos) é sua para manter.
- Lambdas crescem. Uma lambda de vinte linhas dentro de
MapPostesconde a rota e dificulta a leitura do arquivo. Passando de poucas linhas, extraia um método estático nomeado.csharpgroup.MapPost("/", CreateGame); // lê como um índice - Binding por convenção é compacto e implícito.
(CreateGameDto dto)não precisa de[FromBody], o que é bom até um refactor mudar de onde um parâmetro vem. Use atributos explícitos ([FromQuery],[FromBody],[FromServices]) nas fronteiras que importam. - Filtros são por endpoint, não um pipeline visível. Filtros de endpoint rodam na ordem em que foram adicionados, em volta do handler. Bastam para validação e logging, mas comportamento transversal a tudo costuma pertencer a um middleware.
- Sem reflection não é custo zero. Minimal APIs montam o delegate da requisição no primeiro uso de cada endpoint. Para serverless e Native AOT existe um gerador separado de request delegates, com seus próprios limites sobre o binding suportado.