Todos os artigos
74 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutoriais

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.

COVER · Tutoriais

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.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil