JSON Schema para API REST: as decisões que fazem o contrato funcionar
Como criar um JSON Schema para validar payload de API REST — de amount com multipleOf a enums, arrays e $ref — com as decisões de design explicadas.
Documentar um endpoint de API com JSON Schema não é difícil. O difícil é tomar as decisões certas ao longo do caminho — o que entra em required, quando usar $ref, como declarar enums, como lidar com campos que evoluem. Essas decisões determinam se o schema vai ser útil daqui a seis meses ou vai ser um obstáculo contornado com additionalProperties: true em tudo.
Este post parte de um endpoint real e percorre cada decisão de design, com o raciocínio por trás de cada uma.
O endpoint: POST /payments
Vamos usar como exemplo um endpoint de criação de pagamento — estrutura comum o suficiente para ser reconhecível, complexa o suficiente para cobrir os casos relevantes.
O payload esperado:
{
"amount": 150.00,
"currency": "BRL",
"payment_method": "credit_card",
"customer": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"email": "cliente@exemplo.com"
},
"items": [
{
"product_id": "prod_123",
"quantity": 2,
"unit_price": 75.00
}
],
"metadata": {
"order_ref": "ORD-2026-001"
}
}
Começando pelo nível raiz
O schema começa simples e vai crescendo. Nível raiz primeiro:
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"amount": {},
"currency": {},
"payment_method": {},
"customer": {},
"items": {},
"metadata": {}
},
"required": ["amount", "currency", "payment_method", "customer", "items"],
"additionalProperties": false
}
metadata não está em required — é opcional. additionalProperties: false rejeita qualquer campo fora dos listados em properties. Essa é a estrutura que vai ser preenchida.
A decisão de additionalProperties: false no nível raiz é defensiva: ela impede que campos inesperados entrem no sistema sem estarem declarados no schema. Em APIs públicas, isso é importante — sem essa restrição, um cliente pode enviar campos arbitrários que passam na validação silenciosamente.
Validando valores numéricos: amount
"amount": {
"type": "number",
"minimum": 0.01,
"multipleOf": 0.01
}
"type": "number" aceita inteiros e decimais. "minimum": 0.01 rejeita zero e negativos — você não quer processar um pagamento de R$0,00. "multipleOf": 0.01 garante que o valor tem no máximo duas casas decimais — R$10,123 não é um valor monetário válido.
Uma alternativa comum é representar valores monetários em centavos como integer: "amount": 1000 para R$10,00. Isso elimina problemas de precisão de ponto flutuante mas exige que o consumidor faça a conversão. A decisão depende da convenção da sua API — o importante é que o schema reflita o que você realmente aceita.
Enums para campos de status e tipo: currency e payment_method
"currency": {
"type": "string",
"enum": ["BRL", "USD", "EUR", "GBP"]
},
"payment_method": {
"type": "string",
"enum": ["credit_card", "debit_card", "pix", "boleto"]
}
enum define a lista exata de valores permitidos. Qualquer valor fora da lista é rejeitado. Para campos de status, tipo, categoria, moeda — enum é a escolha certa.
A armadilha: enums crescem. Daqui a seis meses você vai querer adicionar "crypto" ao payment_method. Adicionar ao enum é uma mudança retrocompatível — o schema aceita valores que antes rejeitava. Remover é breaking — o schema passa a rejeitar valores que antes aceitava. Evolua enums com cuidado.
Objetos aninhados: customer
"customer": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"email": {
"type": "string",
"format": "email"
}
},
"required": ["id", "email"],
"additionalProperties": false
}
Objeto aninhado com seu próprio required e additionalProperties: false. Cada nível tem suas próprias restrições — o required do nível raiz não se propaga para dentro do objeto customer.
"format": "uuid" e "format": "email" validam o formato semântico do valor. Atenção: no AJV padrão, format não é validado por padrão — você precisa do pacote ajv-formats e ativar com { formats: true }. Sem isso, format é ignorado silenciosamente e qualquer string passa. Se formato importa para você, confirme que o validador está com suporte ativado.
Arrays de objetos: items
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"product_id": { "type": "string", "minLength": 1 },
"quantity": { "type": "integer", "minimum": 1 },
"unit_price": { "type": "number", "minimum": 0 }
},
"required": ["product_id", "quantity", "unit_price"],
"additionalProperties": false
}
}
"minItems": 1 rejeita array vazio — um pagamento sem itens não faz sentido. "items" define o schema de cada elemento do array. Todos os elementos devem passar no mesmo schema.
"type": "integer" para quantity — quantidade não pode ser fracionária. "minimum": 1 rejeita zero. "minimum": 0 para unit_price — permite itens gratuitos mas rejeita preços negativos.
Campo opcional com estrutura livre: metadata
"metadata": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"maxProperties": 10
}
metadata é opcional (não está em required do nível raiz). Quando presente, aceita qualquer chave desde que o valor seja string. additionalProperties com um schema (em vez de false ou true) define o schema que todos os valores adicionais devem seguir. "maxProperties": 10 limita o tamanho do objeto para evitar abuso.
Esse padrão é útil para campos de dados livres onde você não quer enumerar todas as chaves possíveis mas ainda quer restrição no tipo dos valores.
Usando $ref para não repetir
Se customer aparece em vários endpoints, extraia para uma definição reutilizável:
{
"$schema": "https://json-schema.org/draft-07/schema#",
"definitions": {
"Customer": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" }
},
"required": ["id", "email"],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"customer": { "$ref": "#/definitions/Customer" }
}
}
$ref referencia o schema por path. #/definitions/Customer aponta para definitions.Customer no mesmo documento. Em schemas separados, o $ref pode apontar para um arquivo ou URL — o AJV resolve referências externas com ajv.addSchema().
Quando você tem $ref no schema e recebe um erro de validação, o schemaPath no objeto de erro aponta para onde no schema compilado a keyword falhou, não necessariamente onde o $ref está. Use o Validador JSON Schema para visualizar onde a falha está no schema antes de depurar no código.
O schema completo
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"amount": {
"type": "number",
"minimum": 0.01,
"multipleOf": 0.01
},
"currency": {
"type": "string",
"enum": ["BRL", "USD", "EUR", "GBP"]
},
"payment_method": {
"type": "string",
"enum": ["credit_card", "debit_card", "pix", "boleto"]
},
"customer": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" }
},
"required": ["id", "email"],
"additionalProperties": false
},
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"product_id": { "type": "string", "minLength": 1 },
"quantity": { "type": "integer", "minimum": 1 },
"unit_price": { "type": "number", "minimum": 0 }
},
"required": ["product_id", "quantity", "unit_price"],
"additionalProperties": false
}
},
"metadata": {
"type": "object",
"additionalProperties": { "type": "string" },
"maxProperties": 10
}
},
"required": ["amount", "currency", "payment_method", "customer", "items"],
"additionalProperties": false
}
Como esse schema evolui
JSON Schema é código. Ele vai mudar junto com a API. Algumas regras práticas para quando isso acontecer:
Mudanças retrocompatíveis (não quebram clientes existentes): adicionar campo opcional, ampliar um enum, relaxar um minimum, tornar um required opcional.
Mudanças breaking: remover campo de properties, remover valor de enum, adicionar campo ao required, tornar restrição mais estrita.
Versionar schemas junto com a API é a abordagem mais limpa. Se você usa OpenAPI, o schema vive dentro da spec e é versionado junto. Se você usa schemas standalone, trate como você trataria uma interface pública — changelog explícito para breaking changes.
O schema de entrada e o schema de saída são contratos diferentes. Um endpoint pode ter um schema de request mais restrito e um schema de response que inclui campos computados (id, created_at, status) que o cliente não envia mas recebe.
Nota: o conteúdo editorial acabou aqui. O que vem abaixo é uma indicação de ferramenta relacionada ao tema do post.
Ferramenta relacionada
Para validar o schema enquanto escreve — antes de integrar no código — use o Validador JSON Schema. Cole o schema e um payload de teste, e veja os erros com o path exato de cada falha. Útil para testar os casos de borda: array vazio, campo null, enum com valor fora da lista, objeto aninhado sem required.
- 01 diff antes de abrir PR: o hábito que reduz comentários de revisão Ler o próprio diff antes de abrir um PR muda o que você vê no código. Como e por que esse hábito reduz ciclos de revisão e melhora a qualidade dos PRs.
- 02 `P@ssw0rd1` passa na sua validação. Esse é o problema. O que o NIST mudou em 2024 sobre senhas: comprimento bate complexidade, leet speak está no rockyou.txt e rotação obrigatória enfraquece senhas.