Documentação e OpenAPI
O contrato da API como arquivo versionado, de onde saem documentação, clientes e testes.
As cinco APIs desta página foram executadas, e as especificações que elas publicam foram testadas com o Schemathesis. O contrato escrito à mão passou pelo Redocly CLI, pelo Schemathesis contra as cinco APIs, pelo oasdiff e pelo openapi-typescript.
A especificação gerada pelo código, em cada linguagem
A mesma API nas cinco versões: POST /pedidos cria um pedido e GET /pedidos/{id} o devolve. Cada uma usa o gerador de OpenAPI mais comum da linguagem e publica o arquivo numa rota.
import swagger from "@fastify/swagger";
import Fastify from "fastify";
const app = Fastify();
await app.register(swagger, {
openapi: { openapi: "3.1.0", info: { title: "API de Pedidos", version: "1.0.0" } },
});
// Os esquemas validam a entrada, formatam a saída e viram a documentação
const Pedido = {
type: "object",
required: ["id", "cliente_id", "status", "total", "criado_em"],
properties: {
id: { type: "integer" },
cliente_id: { type: "integer" },
status: { type: "string", enum: ["aberto", "pago", "enviado"] },
total: { type: "string", pattern: "^[0-9]+\\.[0-9]{2}$", description: "Valor em reais" },
criado_em: { type: "string", format: "date-time" },
},
} as const;
const NovoPedido = {
type: "object",
required: ["cliente_id", "total"],
additionalProperties: false,
properties: {
cliente_id: { type: "integer", minimum: 1 },
total: Pedido.properties.total,
},
} as const;
const Problema = {
type: "object",
properties: { title: { type: "string" }, status: { type: "integer" } },
} as const;
type Pedido = { id: number; cliente_id: number; status: string; total: string; criado_em: string };
const pedidos = new Map<number, Pedido>();
app.get<{ Params: { id: number } }>("/pedidos/:id", {
schema: {
operationId: "buscarPedido",
params: { type: "object", properties: { id: { type: "integer", minimum: 1 } } },
response: { 200: Pedido, 404: Problema },
},
}, async (req, reply) => {
const pedido = pedidos.get(req.params.id);
if (!pedido) return reply.code(404).type("application/problem+json")
.send({ title: "Pedido não encontrado", status: 404 });
return pedido;
});
app.post<{ Body: { cliente_id: number; total: string } }>("/pedidos", {
schema: { operationId: "criarPedido", body: NovoPedido, response: { 201: Pedido } },
}, async (req, reply) => {
const pedido = {
id: pedidos.size + 1,
...req.body,
status: "aberto",
criado_em: new Date().toISOString(),
};
pedidos.set(pedido.id, pedido);
return reply.code(201).send(pedido);
});
app.get("/openapi.json", { schema: { hide: true } }, async () => app.swagger());
await app.listen({ port: 3000, host: "0.0.0.0" });
console.log("ouvindo");from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="API de Pedidos", version="1.0.0")
class Status(str, Enum):
aberto = "aberto"
pago = "pago"
enviado = "enviado"
class NovoPedido(BaseModel):
cliente_id: int = Field(gt=0)
total: Decimal = Field(gt=0, description="Valor em reais")
class Pedido(BaseModel):
id: int
cliente_id: int
status: Status
total: Decimal
criado_em: datetime
pedidos: dict[int, Pedido] = {}
@app.get("/pedidos/{id}", responses={404: {"description": "Pedido não encontrado"}})
def buscar(id: int) -> Pedido:
if id not in pedidos:
raise HTTPException(404, "Pedido não encontrado")
return pedidos[id]
@app.post("/pedidos", status_code=201)
def criar(novo: NovoPedido) -> Pedido:
pedido = Pedido(id=len(pedidos) + 1, cliente_id=novo.cliente_id, status=Status.aberto,
total=novo.total, criado_em=datetime.now(timezone.utc))
pedidos[pedido.id] = pedido
return pedidopackage main
import (
"context"
"fmt"
"net/http"
"sync"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
)
type Pedido struct {
ID int `json:"id"`
ClienteID int `json:"cliente_id"`
Status string `json:"status" enum:"aberto,pago,enviado"`
Total string `json:"total" pattern:"^[0-9]+\\.[0-9]{2}$" doc:"Valor em reais"`
CriadoEm time.Time `json:"criado_em"`
}
type BuscarEntrada struct {
ID int `path:"id" minimum:"1"`
}
type CriarEntrada struct {
Body struct {
ClienteID int `json:"cliente_id" minimum:"1"`
Total string `json:"total" pattern:"^[0-9]+\\.[0-9]{2}$" doc:"Valor em reais"`
}
}
type PedidoSaida struct {
Body Pedido
}
func main() {
var (
mu sync.Mutex
pedidos = map[int]Pedido{}
)
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("API de Pedidos", "1.0.0")) // publica /openapi.json
huma.Register(api, huma.Operation{
OperationID: "buscarPedido", Method: http.MethodGet, Path: "/pedidos/{id}",
Errors: []int{404},
}, func(ctx context.Context, e *BuscarEntrada) (*PedidoSaida, error) {
mu.Lock()
defer mu.Unlock()
p, ok := pedidos[e.ID]
if !ok {
return nil, huma.Error404NotFound("Pedido não encontrado")
}
return &PedidoSaida{Body: p}, nil
})
huma.Register(api, huma.Operation{
OperationID: "criarPedido", Method: http.MethodPost, Path: "/pedidos",
DefaultStatus: http.StatusCreated,
}, func(ctx context.Context, e *CriarEntrada) (*PedidoSaida, error) {
mu.Lock()
defer mu.Unlock()
p := Pedido{ID: len(pedidos) + 1, ClienteID: e.Body.ClienteID, Status: "aberto",
Total: e.Body.Total, CriadoEm: time.Now().UTC()}
pedidos[p.ID] = p
return &PedidoSaida{Body: p}, nil
})
fmt.Println("ouvindo")
http.ListenAndServe(":3000", mux)
}package pedidos;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@SpringBootApplication
@RestController
public class App {
enum Status { aberto, pago, enviado }
record NovoPedido(
@JsonProperty("cliente_id") @NotNull @Min(1) Integer clienteId,
@NotNull @Pattern(regexp = "^[0-9]+\\.[0-9]{2}$")
@Schema(description = "Valor em reais") String total) {}
record Pedido(
int id,
@JsonProperty("cliente_id") int clienteId,
Status status,
String total,
@JsonProperty("criado_em") OffsetDateTime criadoEm) {}
private final Map<Integer, Pedido> pedidos = new ConcurrentHashMap<>();
@GetMapping("/pedidos/{id}")
@Operation(operationId = "buscarPedido")
@ApiResponse(responseCode = "200", description = "O pedido")
@ApiResponse(responseCode = "404", description = "Pedido não encontrado")
public ResponseEntity<Pedido> buscar(@PathVariable @Min(1) int id) {
var pedido = pedidos.get(id);
return pedido == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(pedido);
}
@PostMapping("/pedidos")
@ResponseStatus(HttpStatus.CREATED)
@Operation(operationId = "criarPedido")
public Pedido criar(@Valid @RequestBody NovoPedido novo) {
var pedido = new Pedido(pedidos.size() + 1, novo.clienteId(), Status.aberto,
novo.total(), OffsetDateTime.now());
pedidos.put(pedido.id(), pedido);
return pedido;
}
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}using Microsoft.AspNetCore.Http.HttpResults;
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(); // gera o documento a partir das rotas
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
var app = builder.Build();
app.MapOpenApi(); // publica em /openapi/v1.json
var pedidos = new ConcurrentDictionary<int, Pedido>();
app.MapGet("/pedidos/{id}", Results<Ok<Pedido>, NotFound> (int id) =>
pedidos.TryGetValue(id, out var p) ? TypedResults.Ok(p) : TypedResults.NotFound())
.WithName("buscarPedido");
app.MapPost("/pedidos", (NovoPedido novo) =>
{
var p = new Pedido(pedidos.Count + 1, novo.ClienteId, Status.Aberto, novo.Total,
DateTimeOffset.UtcNow);
pedidos[p.Id] = p;
return TypedResults.Created($"/pedidos/{p.Id}", p);
})
.WithName("criarPedido");
app.Run("http://0.0.0.0:3000");
enum Status { Aberto, Pago, Enviado }
record NovoPedido(
[property: Range(1, int.MaxValue)] int ClienteId,
[property: Range(0.01, double.MaxValue)] decimal Total);
record Pedido(int Id, int ClienteId, Status Status, decimal Total, DateTimeOffset CriadoEm);Versões usadas no teste: Fastify 5.12 com @fastify/swagger 9.9, FastAPI 0.163 com Pydantic 2.14, huma 2.40, Spring Boot 3.5.7 com springdoc-openapi 2.8.13 e ASP.NET Core 9 com Microsoft.AspNetCore.OpenApi.
O resultado, nas cinco linguagens
Cada servidor recebeu um pedido válido, um pedido com cliente_id: 0 e total: "-1", e depois o Schemathesis, com a especificação publicada pelo próprio servidor, 50 exemplos por operação e todas as verificações ligadas:
| Versão gerada | Recusou o pedido inválido? | O que o Schemathesis encontrou | |
|---|---|---|---|
| TypeScript | 3.1.0 | Sim, 400 | 400 não documentado; 404 com tipo de conteúdo diferente do declarado; 400 a um método inexistente, em vez de 405 |
| Python | 3.1.0 | Sim, 422 | total documentado como "número ou texto qualquer"; 400 não documentado para JSON malformado |
| Go | 3.1.0 | Sim, 422 | 7.0 recusado como inteiro |
| Java | 3.1.0 | Sim, 400 | 400 não documentado |
| C# | 3.0.4 | Não, 201 | 400 não documentado; pedido sem os campos obrigatórios aceito com 201 |
A versão em C# mostrou o problema mais sério. As anotações [Range] aparecem no arquivo como minimum, mas as rotas mínimas do ASP.NET Core 9 não aplicam essas anotações. Um corpo {} criou um pedido com clienteId: 0 e total: 0. No .NET 9, a validação precisa ser feita à mão ou com uma biblioteca; segundo a documentação da Microsoft, o .NET 10 acrescenta a validação das rotas mínimas (AddValidation) e gera OpenAPI 3.1.
A especificação do springdoc tem dois defeitos que o Schemathesis não reprovou, mas que um gerador de clientes sente: o 404 aparece com o esquema de Pedido (a resposta real não tem corpo) e o Pedido não tem nenhum campo em required. Testamos a correção: com content = @Content no @ApiResponse do 404 e @Schema(requiredProperties = {"id", "cliente_id", "status", "total", "criado_em"}) no record, o 404 passou a aparecer sem corpo e os cinco campos, como obrigatórios.
O que muda de uma linguagem para outra
| De onde vem o esquema | Validação | Onde fica o arquivo | |
|---|---|---|---|
| TypeScript | JSON Schema escrito na rota | O mesmo esquema, pelo Fastify | app.swagger(), publicado numa rota |
| Python | Modelos Pydantic e anotações de tipo | Os mesmos modelos | /openapi.json, automático |
| Go | Tags das structs | As mesmas tags, pelo huma | /openapi.json, automático |
| Java | Classes, anotações de validação e do Swagger | Bean Validation, só com @Valid | /v3/api-docs |
| C# | Tipos e anotações | Nenhuma nas rotas mínimas do .NET 9 | /openapi/v1.json |
Nos três primeiros, o esquema que documenta é o mesmo que valida, e as divergências foram de detalhe. Nos dois últimos, documentação e validação são caminhos separados, e é aí que elas se afastam.
O contrato escrito à mão
O mesmo contrato, escrito antes do código, com as decisões explícitas: erros em Problem Details, dinheiro como texto, inteiros com limite e campos obrigatórios.
openapi: 3.1.0
info:
title: API de Pedidos
version: 1.0.0
description: Pedidos da loja.
license: { name: Proprietária, identifier: LicenseRef-Proprietaria }
servers:
- url: https://api.exemplo.com
tags:
- name: pedidos
description: Criação e consulta de pedidos
security: []
paths:
/pedidos:
post:
operationId: criarPedido
summary: Cria um pedido
tags: [pedidos]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NovoPedido" }
example: { cliente_id: 7, total: "10.50" }
responses:
"201":
description: Pedido criado
content:
application/json:
schema: { $ref: "#/components/schemas/Pedido" }
"400": { $ref: "#/components/responses/Problema" }
"422": { $ref: "#/components/responses/Problema" }
/pedidos/{id}:
get:
operationId: buscarPedido
summary: Busca um pedido
tags: [pedidos]
parameters:
- name: id
in: path
required: true
schema: { type: integer, minimum: 1, maximum: 2147483647 }
responses:
"200":
description: O pedido
content:
application/json:
schema: { $ref: "#/components/schemas/Pedido" }
"404": { $ref: "#/components/responses/Problema" }
"422": { $ref: "#/components/responses/Problema" }
components:
schemas:
Dinheiro:
type: string
pattern: "^[0-9]{1,10}\\.[0-9]{2}$"
description: Valor em reais, como texto, para não perder centavos
examples: ["10.50"]
NovoPedido:
type: object
additionalProperties: false
required: [cliente_id, total]
properties:
cliente_id: { type: integer, minimum: 1, maximum: 2147483647 }
total: { $ref: "#/components/schemas/Dinheiro" }
Pedido:
type: object
required: [id, cliente_id, status, total, criado_em]
properties:
id: { type: integer }
cliente_id: { type: integer }
status: { type: string, enum: [aberto, pago, enviado] }
total: { $ref: "#/components/schemas/Dinheiro" }
criado_em: { type: string, format: date-time }
responses:
Problema:
description: Erro no formato de Problem Details (RFC 9457)
content:
application/problem+json:
schema:
type: object
properties:
type: { type: string }
title: { type: string }
status: { type: integer }
detail: { type: string }Verificar, testar e comparar
Os três comandos abaixo foram executados com o contrato acima. Na integração contínua, cada um falha a etapa quando encontra um problema.
Verificar o estilo com o Redocly CLI (versão 2.78 no teste):
npx @redocly/cli lint openapi.yamlO contrato acima passou sem erros nem avisos. O contrato ruim do nível Conceito falhou com quatro erros e três avisos.
Testar a API contra o contrato com o Schemathesis (versão 4.33 no teste), com a API em execução:
schemathesis run openapi.yaml --url http://localhost:3000 --checks all --max-examples 50Contra as cinco APIs de exemplo, o teste achou divergências em todas: erros em application/json em vez de application/problem+json, o cliente_id acima do limite aceito pelo Go, o status "Aberto" do C#. E achou uma falha no próprio contrato, que permitia um total "0.00".
Comparar com a versão publicada com o oasdiff (versão 1.38 no teste):
oasdiff breaking publicado/openapi.yaml openapi.yaml --fail-on ERRCom as quatro mudanças do nível Conceito, o comando listou 5 problemas e saiu com código 1. Só com o campo opcional novo, respondeu que não havia mudanças incompatíveis e saiu com código 0.
Tipos gerados para o cliente
O openapi-typescript (versão 7.13) transforma o contrato em tipos, e o openapi-fetch (versão 0.17) usa esses tipos para conferir cada chamada:
npx openapi-typescript openapi.yaml -o api.d.tsimport createClient from "openapi-fetch";
import type { paths } from "./api.d.ts";
const api = createClient<paths>({ baseUrl: process.env.API_URL });
const { data, error } = await api.POST("/pedidos", {
body: { cliente_id: 7, total: "10.50" },
});
if (error) throw new Error(error.title);
console.log(data.id, data.status, data.criado_em);
const busca = await api.GET("/pedidos/{id}", { params: { path: { id: data.id } } });
console.log(busca.data?.total);O cliente acima compilou com tsc --strict e rodou contra a API em Python. Numa cópia com total: 10.5 e data.criadoEm, o compilador recusou as duas linhas:
errado.ts(7,26): error TS2322: Type 'number' is not assignable to type 'string'.
errado.ts(10,40): error TS2551: Property 'criadoEm' does not exist on type '{ id: number;
cliente_id: number; status: "aberto" | "pago" | "enviado"; total: string; criado_em: string; }'.
Did you mean 'criado_em'?O tipo de status saiu como a união dos três valores permitidos. Se o contrato ganhar um valor novo, o compilador aponta os switch que não tratam o valor.
Lista de revisão
Contrato
- Existe um arquivo OpenAPI versionado no repositório, gerado ou escrito à mão?
- Todo esquema de objeto declara
required? - Dinheiro é texto ou inteiro em centavos, e datas têm
format: date-time? - Toda operação documenta as respostas de erro, num formato único?
- Cada operação tem
operationIde um exemplo?
Verdade
- O esquema que documenta é o mesmo que valida? Se não, as anotações de validação são realmente aplicadas?
- O teste de contrato roda na integração contínua, contra a API em execução?
- Divergências do teste são resolvidas decidindo quem está certo, o código ou o contrato?
Evolução
- A comparação com a versão publicada falha a integração em mudanças incompatíveis?
- Os
enumque vão crescer estão avisados como extensíveis? - Os tipos dos clientes são gerados do contrato, e não escritos à mão?
Publicação
- A documentação pública mostra só as operações públicas?
- O servidor de mentira e as coleções de pedidos vêm do mesmo arquivo?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Publicar a especificação a partir do código
Quero que a minha API publique uma especificação OpenAPI correta, gerada pelo código. Pergunte qual linguagem e framework eu uso. Vamos em etapas: 1. Configurar o gerador e publicar o arquivo numa rota. 2. Garantir que o esquema que documenta é o mesmo que valida a entrada, e testar com um pedido inválido. 3. Documentar as respostas de erro, com um formato único. 4. Garantir que os campos obrigatórios aparecem em required. 5. Rodar o Schemathesis contra a API usando o arquivo publicado, e corrigir o que ele encontrar. Em cada etapa, me passe os comandos para conferir o resultado.
Montar as verificações do contrato na integração contínua
Quero montar as verificações do contrato OpenAPI na integração contínua do meu projeto. Pergunte qual serviço de integração contínua eu uso e como a API sobe nos testes. Monte comigo, com a configuração completa: 1. O verificador de estilo, com as regras do time. 2. O teste de contrato com o Schemathesis, contra a API em execução. 3. A comparação com a versão publicada com o oasdiff, falhando em mudanças incompatíveis. 4. A geração dos tipos para os clientes. 5. A publicação da documentação.
Revisar um contrato OpenAPI
Vou colar abaixo um arquivo OpenAPI, e se possível o código da API. Revise para produção. Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho: 1. Todo objeto declara required? 2. As respostas de erro estão documentadas, num formato único? 3. Dinheiro, datas e identificadores têm tipos e formatos adequados? 4. Há operationId, resumo e exemplos? 5. Os limites (mínimos, máximos, padrões) estão declarados e batem com o código? 6. Há rotas internas expostas na documentação? 7. Se o código veio junto: as regras do arquivo são de fato aplicadas? Para cada problema, explique o efeito para quem integra e mostre a correção. Arquivo e código: [cole aqui]