DevGuideTK
← Trilha Backend

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");

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 geradaRecusou o pedido inválido?O que o Schemathesis encontrou
TypeScript3.1.0Sim, 400400 não documentado; 404 com tipo de conteúdo diferente do declarado; 400 a um método inexistente, em vez de 405
Python3.1.0Sim, 422total documentado como "número ou texto qualquer"; 400 não documentado para JSON malformado
Go3.1.0Sim, 4227.0 recusado como inteiro
Java3.1.0Sim, 400400 não documentado
C#3.0.4Não, 201400 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 esquemaValidaçãoOnde fica o arquivo
TypeScriptJSON Schema escrito na rotaO mesmo esquema, pelo Fastifyapp.swagger(), publicado numa rota
PythonModelos Pydantic e anotações de tipoOs mesmos modelos/openapi.json, automático
GoTags das structsAs mesmas tags, pelo huma/openapi.json, automático
JavaClasses, anotações de validação e do SwaggerBean Validation, só com @Valid/v3/api-docs
C#Tipos e anotaçõesNenhuma 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.yaml

O 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 50

Contra 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 ERR

Com 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.ts
import 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 operationId e 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 enum que 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]