Geração de Documentos OpenAPI no ASP.NET Core 10
Objective
Um documento OpenAPI é o contrato legível por máquina de uma API HTTP: rotas,
parâmetros, corpos de requisição e resposta, status codes, schemas. Clientes,
UIs de documentação, testes de contrato e geradores de código leem esse
documento. Desde o .NET 9 o ASP.NET Core gera o documento sozinho, com o pacote
Microsoft.AspNetCore.OpenApi, e desde o .NET 10 ele mira OpenAPI 3.1 por
padrão, lê comentários XML e deixa os atributos de resposta carregarem uma
descrição. O objetivo é saber o que o gerador produz a partir do seu código,
quais partes dessa saída você controla e como (atributos, comentários XML,
transformers) e o que muda entre 3.0 e 3.1, usando saída real em vez de
suposição.
Use Cases
- Publicar
/openapi/v1.jsonde uma minimal API para o time de front end gerar um cliente tipado em vez de ler o seu código. - Separar uma aplicação em um documento público e um de admin, cada um só com os seus endpoints.
- Versionar o documento gerado no build, para ver mudanças de contrato no diff de um pull request ou alimentar um teste de contrato.
- Adicionar o que o framework não infere (título da API, flag de deprecação, um campo de extensão) sem mexer em cada endpoint.
Deep Dive
O setup mínimo
Duas chamadas: registrar os serviços e mapear o endpoint. Tudo abaixo foi
executado com Microsoft.AspNetCore.OpenApi 10.0.12 no SDK 10.0.100.
csharpvar builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(); // nome do documento "v1" por padrão
var app = builder.Build();
app.MapOpenApi(); // GET /openapi/{documentName}.json
app.MapOpenApi("/openapi/{documentName}.yaml"); // opcional: YAML em outra rotacurl localhost:5300/openapi/v1.json devolve um documento que começa assim:
plaintext{ "openapi": "3.1.1", "info": { "title": "api | v1", "version": "1.0.0" }, ... }
O título vem do nome do assembly e do nome do documento. A rota YAML serve o
mesmo conteúdo com openapi: '3.1.1' na primeira linha. Repare que o
MapOpenApi não verifica o ambiente: num processo sem ASPNETCORE_ENVIRONMENT
(ou seja, Production) o endpoint respondeu 200. Os templates envolvem a chamada
em if (app.Environment.IsDevelopment()), e essa proteção é decisão sua, não do
framework.
O que o gerador lê do seu código
O documento nasce dos metadados do endpoint, então quanto mais o handler diz,
menos você anota. Com um handler que retorna Results<Ok<GameDto>, NotFound>, o
gerador emitiu as duas respostas sem nenhum atributo:
csharppublic static Results<Ok<GameDto>, NotFound> GetGame(int id) => ...;plaintext"responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GameDto" } } } }, "404": { "description": "Not Found" } }
Um handler que retorna IResult esconde tudo isso, que é o caso prático em que
TypedResults (conceito anterior) compensa. Para o que os tipos não dizem, use
WithSummary, WithDescription, WithTags, WithName (vira o operationId)
e Produces<T>/ProducesProblem. No .NET 10, [ProducesResponseType],
[Produces] e [ProducesDefaultResponseType] também aceitam Description, que
substitui o "OK" genérico:
csharp[ProducesResponseType<string>(StatusCodes.Status200OK, Description = "A plain text greeting.")]plaintext"200": { "description": "A plain text greeting.", "content": { "text/plain": ... } }
As release notes apresentam isso para controllers; nesta execução também funcionou num handler de minimal API.
Comentários XML viram documentação
Com <GenerateDocumentationFile>true</GenerateDocumentationFile> no arquivo de
projeto, o pacote usa um source generator em build para pegar os comentários XML
e levá-los ao documento. O mapeamento, como observado na saída:
csharp/// <summary>Gets a game by id.</summary>
/// <remarks>Returns 404 when the id is unknown.</remarks>
/// <param name="id">The game identifier.</param>
public static Results<Ok<GameDto>, NotFound> GetGame(int id) => ...;
/// <summary>A game in the catalog.</summary>
/// <param name="Price">Price in euros.</param>
public record GameDto(int Id, string Name, decimal Price);plaintext"summary": "Gets a game by id.", "description": "Returns 404 when the id is unknown.", "parameters": [ { "name": "id", "in": "path", "description": "The game identifier.", ... } ] ... "GameDto": { "description": "A game in the catalog.", "properties": { "price": { "description": "Price in euros.", ... } } }
<summary> vira o summary da operação, <remarks> a description, <param> a
descrição do parâmetro, e os comentários de tipo e de membro viram descrições de
schema (num record posicional, as tags <param> documentam as propriedades). A
pegadinha que a documentação cita: o compilador não guarda comentários XML
escritos em lambdas, então um MapGet("/x", () => ...) inline não ganha nada.
Mova o handler para um método nomeado e referencie-o, como GamesApi.GetGame
acima. Uma lambda ainda aceita WithSummary e WithDescription.
Schemas: o que é copiado e o que não é
Data annotations nos DTOs chegam ao schema, com uma armadilha nos records. Num
record posicional, um atributo escrito no parâmetro mira o parâmetro do
construtor, não a propriedade, e o gerador lê propriedades. A primeira execução
com [Required, StringLength(50)] string Name gerou um schema sem maxLength.
Escrever [property: StringLength(50)] resolveu:
csharppublic record CreateGameDto(
[property: Required, StringLength(50)] string Name,
[property: Range(0, 1000)] decimal Price,
string? Genre);plaintext"name": { "maxLength": 50, "minLength": 0, "type": "string" }, "price": { "maximum": 1000, "minimum": 0, "type": ["number", "string"], "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", "format": "double" }, "genre": { "type": ["null", "string"] }
Dois detalhes que valem uma leitura atenta. Os números saem como
["number", "string"] com um pattern porque os defaults de JSON do ASP.NET
Core aceitam número escrito como string, e o schema descreve o que o servidor
aceita, não só o que ele emite. E genre aparece em required mesmo sendo
nullable: num record posicional o parâmetro do construtor não tem valor padrão,
então "obrigatório" e "nullable" são fatos separados.
3.0 versus 3.1
O OpenAPI 3.1 se alinha ao JSON Schema draft 2020-12, e isso aparece nos casos de
nullable e de união. O mesmo genre e o mesmo price com
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0:
plaintext"genre": { "type": "string", "nullable": true }, "price": { "anyOf": [ { "type": "number" }, { "type": "string" } ], ... }
O cabeçalho do documento virou 3.0.4. Fique no 3.0 só quando algum consumidor
da sua cadeia não lê 3.1. A atualização também aparece no código dos
transformers: o gerador migrou para Microsoft.OpenApi 2.x, onde OpenApiAny
deixou de existir e os valores de extensão são JsonNode. A documentação diz que
essas mudanças valem mesmo se você ficar no 3.0. Para registrar: a mesma página
diz que o .NET 11 muda o padrão para 3.2, então fixe OpenApiVersion se algum
consumidor depende de uma versão.
Transformers
Quando atributos não bastam, um transformer edita o documento. Há três tipos, cada um com forma por delegate, por instância e por classe ativada pelo DI:
csharpbuilder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, ct) =>
{
document.Info.Title = "Game Store API";
return Task.CompletedTask;
});
options.AddOperationTransformer((operation, context, ct) => { /* por operação */ return Task.CompletedTask; });
options.AddSchemaTransformer((schema, context, ct) =>
{
if (context.JsonTypeInfo.Type == typeof(decimal))
schema.Description = "Money, two decimal places."; // todo decimal do documento
return Task.CompletedTask;
});
});Segundo a documentação, os schema transformers rodam primeiro (conforme cada
schema é registrado), depois os operation transformers e por último os document
transformers, cada grupo na ordem em que foram adicionados, uma vez por
documento. Para mudar um endpoint só, AddOpenApiOperationTransformer prende o
transformer àquele endpoint:
csharpgames.MapGet("/ping", () => "pong")
.AddOpenApiOperationTransformer((op, ctx, ct) => { op.Deprecated = true; return Task.CompletedTask; });
// saída: "deprecated": trueOs usos reais mais comuns são título e servidores da API, um security scheme
(como Bearer) registrado uma vez com um requirement por operação, e uma
resposta de erro compartilhada. No .NET 10 o contexto do transformer oferece
GetOrCreateSchemaAsync para construir o schema de um tipo C# e adicioná-lo aos
components do documento.
Vários documentos e geração em build
AddOpenApi("admin") registra um segundo documento, servido em
/openapi/admin.json. Qual endpoint vai para qual documento é decidido por
ShouldInclude, cujo padrão compara o group name do endpoint com o nome do
documento. Um endpoint com .WithGroupName("admin") apareceu só no documento
admin; os endpoints sem group name apareceram nos dois:
textv1: /games/{id} /games /games/ping /games/legacy admin: /admin/games/{id} /games/{id} /games /games/ping /games/legacy
Para gravar os arquivos durante o dotnet build, adicione
Microsoft.Extensions.ApiDescription.Server (10.0.12 foi restaurado aqui) e, se
quiser, escolha a pasta:
plaintext<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.12"> <PrivateAssets>all</PrivateAssets> </PackageReference> <PropertyGroup> <OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/spec</OpenApiDocumentsDirectory> </PropertyGroup>
O log do build disse Writing document named 'admin' to .../spec/bt_admin.json
e gerou bt.json para o v1: o nome do projeto, mais _<documento> quando o
nome não é v1. O gerador sobe o ponto de entrada da sua app com um servidor de
mentira, então o código de startup que precisa de banco ou de segredo também
roda. Proteja esse código (a documentação compara
Assembly.GetEntryAssembly()?.GetName().Name com "GetDocument.Insider").
Trade-offs
- O documento descreve o que o código declara, não o que ele faz. Um handler
que retorna
IResulté documentado como um200vazio: sem schema de corpo e sem404, mesmo que o código possa devolver um.csharpapp.MapGet("/g/{id}", (int id) => id == 1 ? Results.Ok(new GameDto(1, "Zelda", 1m)) : Results.NotFound()); // gerado: "responses": { "200": { "description": "OK" } } - Comentários XML custam uma configuração de build e um método por endpoint. Lambdas ficam sem documentação, então o estilo empurra você para handlers nomeados. Em geral é uma boa estrutura de qualquer forma, mas é uma restrição.
- O 3.1 é a spec melhor, e o padrão pode mudar debaixo de você. Uma
ferramenta da sua cadeia pode só entender 3.0, e a documentação diz que o
padrão muda de novo no .NET 11. Defina
OpenApiVersionexplicitamente quando algum consumidor importar.csharpoptions.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0; // fixe no que seus consumidores leem - Transformers são poderosos e presos à versão da biblioteca. A migração para
Microsoft.OpenApi2.x quebrou código de transformer escrito para o .NET 9. Mantenha-os pequenos e cobertos por um teste que compara o documento gerado. - O schema espelha o serializador, não a sua intenção. O tratamento de número
vindo de string, as regras de construtor de record e o
requiredseguem o comportamento doSystem.Text.Json. Se um gerador de cliente produzstring | numberonde você querianumber, ajuste as opções de JSON ou use um schema transformer, não o cliente. - A geração em build executa o seu startup. Sem proteção, ela pode exigir uma connection string durante o build numa máquina que não tem nenhuma.
- Gerar o documento não é cumprir o contrato. O gerador só documenta. Nada confere a API em execução contra o arquivo, então adicione um teste de contrato ou compare o documento versionado no CI.