Arquitetura de software

Design de APIs REST: recursos, semântica HTTP, versionamento e formatos de erro

Intermediário11 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Uma API REST é um contrato com o qual você vai conviver por anos, e a maior parte do seu custo de longo prazo é decidida na primeira semana. Este guia cobre a modelagem de recursos, a semântica HTTP definida pela RFC 9110, o design de representações e de erros com problem details, paginação e filtragem, cache com ETags e as estratégias de versionamento que permitem que uma API mude sem quebrar seus consumidores.

O que você vai aprender

  • Modelar recursos e URIs que reflitam o domínio, e não o banco de dados
  • Aplicar corretamente a semântica HTTP de métodos, códigos de status e requisições condicionais
  • Projetar paginação, filtragem e ordenação que continuem corretas quando os dados mudam
  • Retornar erros legíveis por máquina usando os problem details da RFC 9457
  • Evoluir uma API de forma compatível e escolher uma estratégia de versionamento sustentável

Uma API HTTP é a interface mais duradoura que a maioria dos sistemas chega a expor. O serviço por trás dela será reescrito, migrado para outra plataforma e talvez substituído por completo, enquanto a API continua atendendo clientes construídos por pessoas que saíram da empresa há muito tempo. Essa assimetria é o motivo para investir um esforço real de design logo no início: o código interno pode ser refatorado numa terça à tarde, mas um nome de campo publicado é praticamente permanente.

Um bom design REST consiste principalmente em usar o HTTP como ele foi especificado, em vez de encapsular nele um protocolo feito sob medida. A RFC 9110 define o que os métodos significam, o que os códigos de status significam e como funcionam as requisições condicionais, e uma API que respeita essas definições ganha de graça cache, segurança para novas tentativas, comportamento correto de proxies e suporte das bibliotecas cliente. Uma API que as ignora precisa reinventar tudo isso, e mal.

Recursos e URIs

Um recurso é algo do domínio que merece ter um endereço: um pedido, um cliente, um pagamento, a coleção de transações de uma conta. Não é uma tabela do banco de dados, e modelar uma URI por tabela é justamente o que leva APIs a expor decisões de schema que depois não podem mais mudar.

Convenções que se sustentam bem:

  • Substantivos no plural para coleções, com o identificador acrescentado para um item: /orders, /orders/8f21c. A consistência importa mais do que a escolha em si.
  • Aninhamento só quando o filho não pode existir sozinho. /orders/8f21c/lines é razoável, porque uma linha não tem sentido fora do seu pedido. /customers/42/orders fica melhor como /orders?customer=42 quando os pedidos passam a ser endereçáveis por conta própria, e aninhar três níveis é sempre um erro.
  • Nada de verbos nos caminhos. O verbo é o método. /orders/8f21c/cancel é uma exceção pragmática comum, à qual vale resistir pelo menos uma vez: a alternativa é um campo de estado atualizado com PATCH, ou um sub-recurso /orders/8f21c/cancellation criado com POST, que ainda te dá um lugar para registrar quem cancelou e por quê.
  • Kebab-case, minúsculas, sem extensão de arquivo e sem barra no final, escolhidos uma vez e aplicados em todo lugar.
  • Identificadores opacos. Inteiros sequenciais revelam volumes e convidam à enumeração. UUIDs ou identificadores aleatórios curtos não custam nada a mais.

O ponto de fundo é que as URIs fazem parte do contrato público. Se uma URI codifica a sua hierarquia interna, reorganizar essa hierarquia vira uma breaking change.

Semântica dos métodos

A RFC 9110 atribui a cada método duas propriedades que importam muito na prática: se ele é seguro (não altera estado) e se é idempotente (repeti-lo tem o mesmo efeito que executá-lo uma vez). A infraestrutura inteira depende disso — proxies podem repetir requisições idempotentes, navegadores podem fazer prefetch das seguras —, então violar essas propriedades causa bugs que você nunca vai reproduzir localmente.

MétodoSeguroIdempotenteUso
GETSimSimObter uma representação; nunca altera estado, nunca recebe corpo
HEADSimSimSó headers — verificar tamanho ou existência
POSTNãoNãoCriar um recurso subordinado ou enviar uma solicitação de processamento
PUTNãoSimCriar ou substituir um recurso em uma URI conhecida
PATCHNãoNãoAplicar uma modificação parcial
DELETENãoSimRemover um recurso; uma segunda chamada retorna 404 ou 204
OPTIONSSimSimInformar os métodos suportados; usado pelo preflight de CORS

O fato de POST não ser idempotente é o problema prático: um cliente que sofre um timeout não tem como saber se o pedido foi criado. A solução é uma chave de idempotência — um identificador gerado pelo cliente, enviado em um header, armazenado no servidor junto com o resultado e reaproveitado em caso de repetição. A implementação é discutida em padrões de design de microsserviços.

Para PATCH, escolha um media type definido em vez de inventar uma semântica. application/merge-patch+json envia um objeto com os campos a alterar, usando null para remover; application/json-patch+json (RFC 6902) envia uma lista ordenada de operações e consegue apontar posições de um array. O merge patch atende à maioria das necessidades; o JSON Patch vale a pena quando os clientes editam listas.

Códigos de status

Use um conjunto pequeno com precisão, em vez de usar um conjunto grande por aproximação.

CódigoSignificado na prática
200 OKSucesso com corpo
201 CreatedRecurso criado; inclua um header Location apontando para ele
202 AcceptedRequisição aceita para processamento assíncrono; retorne uma URI de status
204 No ContentSucesso sem nada a retornar, tipicamente em DELETE ou PUT
304 Not ModifiedGET condicional cujo validador ainda confere
400 Bad RequestSintaxe malformada ou corpo que não pode ser parseado
401 UnauthorizedCredenciais ausentes ou inválidas — a autenticação falhou
403 ForbiddenAutenticado, mas sem permissão
404 Not FoundO recurso não existe ou está deliberadamente oculto para este chamador
409 ConflictA requisição conflita com o estado atual — duplicidade, versão divergente
412 Precondition FailedO If-Match de uma requisição condicional não foi satisfeito
422 Unprocessable ContentSintaticamente válido, semanticamente inválido — falhas de validação
429 Too Many RequestsLimite de taxa atingido; inclua Retry-After
500 / 502 / 503 / 504Falha do servidor, resposta inválida do upstream, indisponível, timeout do upstream

Duas distinções causam a maior parte da confusão. 401 significa "não sei quem você é"; 403 significa "sei, e você não pode". E 400 versus 422: reserve 400 para corpos que o parser rejeita e use 422 quando o JSON foi parseado sem problemas, mas o conteúdo viola uma regra de negócio. Nunca retorne 200 com um objeto de erro no corpo — toda biblioteca cliente, todo proxy e todo sistema de monitoramento trata 200 como sucesso, e você terá tornado as falhas invisíveis. O catálogo completo está em códigos de status HTTP.

Erros com os quais o cliente consegue agir

A RFC 9457 define application/problem+json, uma estrutura padrão e enxuta que dispensa cada API de inventar seu próprio envelope de erro.

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient funds",
  "status": 422,
  "detail": "Account FR76…0189 has a balance of 12.40 EUR; the transfer requires 250.00 EUR.",
  "instance": "/transfers/9c1a4b7e",
  "balance": "12.40",
  "currency": "EUR",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

As regras que a tornam útil: type é uma URI estável que identifica o tipo de problema e é nela que os clientes se baseiam para decidir o que fazer, então ela nunca deve mudar de significado; title é um resumo curto e legível que permanece constante para um mesmo type; detail explica esta ocorrência específica e pode variar; membros de extensão carregam os dados estruturados de que o cliente precisa. Inclua um identificador de trace para que um chamado de suporte possa ser associado a um trace distribuído.

Em falhas de validação, retorne todos os problemas de uma vez, e não só o primeiro, para que um formulário possa destacar todos os campos inválidos em uma única ida e volta.

{
  "type": "https://api.example.com/problems/validation",
  "title": "Request validation failed",
  "status": 422,
  "errors": [
    { "field": "email",     "code": "format",  "message": "must be a valid email address" },
    { "field": "quantity",  "code": "min",     "message": "must be at least 1" }
  ]
}

Nunca deixe vazar stack traces, trechos de SQL ou nomes de hosts internos em um detail. Registre isso no log com o identificador de trace e retorne o identificador no lugar.

Coleções: paginação, filtragem e ordenação

Todo endpoint de coleção vai acabar contendo mais linhas do que uma resposta deveria carregar, então pagine desde o primeiro dia e nunca retorne uma lista sem limite.

A paginação por offset (?page=3&size=50 ou ?offset=100&limit=50) é simples e permite ao cliente pular para uma página qualquer. Suas fraquezas são reais: o banco percorre e descarta tudo o que vem antes do offset, então a página 500 é muito mais lenta que a página 1, e inserções concorrentes deslocam linhas entre as requisições, fazendo itens serem pulados ou duplicados.

A paginação por cursor retorna um token opaco que codifica a posição em uma ordenação estável, e o cliente o envia de volta para obter a próxima página. Páginas profundas custam o mesmo que as iniciais, e escritas concorrentes não causam saltos.

GET /orders?status=confirmed&sort=-created_at&limit=50 HTTP/1.1
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json

{
  "items": [ { "id": "8f21c", "status": "confirmed", "total": "89.90" } ],
  "next":  "/orders?status=confirmed&sort=-created_at&limit=50&cursor=eyJjIjoiMjAyNi0wOS0wN1QwOTo0MSJ9"
}

Seja qual for a escolha, aplique a mesma disciplina: ordene sempre por algo único (acrescente a chave primária como critério de desempate, ou a paginação se torna não determinística), limite limit a um máximo documentado e use um vocabulário fixo de query parameters para os filtros, em vez de aceitar expressões arbitrárias — uma API que aceita uma string de filtro é uma API cujo planejador de consultas passou a fazer parte do seu contrato público.

Representações e negociação de conteúdo

Os clientes informam o que aceitam com Accept; os servidores descrevem o que enviaram com Content-Type. Suporte bem um media type principal — quase sempre application/json — e só acrescente outros quando um consumidor real precisar.

Algumas convenções valem ser definidas cedo, porque mudá-las depois quebra todo mundo: um único padrão de nomenclatura para os campos (camelCase ou snake_case, escolhido uma vez); timestamps RFC 3339 em UTC com offset explícito; valores monetários como strings com um campo de moeda explícito, nunca como números de ponto flutuante; enumerações como tokens estáveis em maiúsculas, e não como texto de exibição; e significados distintos e documentados para campo ausente e campo nulo.

Não embrulhe toda resposta em um envelope genérico como {"data": …, "meta": …}. O HTTP já fornece status, headers e corpo; o envelope acrescenta uma camada que os clientes precisam desembrulhar e duplica informações que o protocolo já carrega. Metadados que realmente acompanham uma coleção — links de paginação, contagens totais — são a exceção razoável.

Cache e concorrência

Respostas de GET podem carregar validadores que permitem a clientes e proxies evitar retransferir dados que não mudaram, e esses mesmos validadores resolvem o problema de atualizações perdidas nas escritas.

GET /orders/8f21c HTTP/1.1
If-None-Match: "v14"

HTTP/1.1 304 Not Modified
ETag: "v14"
Cache-Control: private, max-age=60

Um ETag é um validador opaco para uma representação específica — um número de versão, um hash do conteúdo. Um cliente que tem um ETag envia If-None-Match e recebe 304 sem corpo quando nada mudou. Nas escritas, o cliente envia If-Match: "v14" no seu PUT ou PATCH; se o recurso tiver mudado nesse meio-tempo, o servidor retorna 412 Precondition Failed e o cliente pode recarregar e tentar de novo, em vez de sobrescrever silenciosamente a alteração de outra pessoa. É controle de concorrência otimista implementado no próprio protocolo, e adicioná-lo custa quase nada.

Defina Cache-Control de forma deliberada em toda resposta: private para tudo o que for específico de um usuário, no-store para tudo o que for sensível e um max-age modesto para dados de referência lidos por muitos clientes.

Versionamento e compatibilidade

A versão mais barata é aquela que você nunca precisa lançar, então esgote primeiro a evolução compatível. Adicionar um campo opcional, adicionar um endpoint, adicionar um valor de enum que a documentação diz que os clientes devem tolerar, relaxar uma regra de validação — tudo isso é compatível se os clientes ignorarem campos desconhecidos, o que a sua documentação precisa exigir. Remover um campo, renomeá-lo, mudar um tipo, endurecer uma validação ou alterar um código de status são todos breaking changes.

Quando a quebra é inevitável:

EstratégiaExemploTrade-off
Caminho da URI/v2/ordersÓbvio, fácil de rotear e de cachear, mas a versão fica gravada em toda URI do cliente
Media typeAccept: application/vnd.example.order.v2+jsonPreciso e por recurso; clientes e ferramentas costumam lidar mal com ele
Header customizadoX-API-Version: 2Mantém as URIs estáveis; invisível em logs e em testes pelo navegador, a menos que você procure
Query parameter/orders?version=2Simples; interage mal com o cache

Versionar pelo caminho vence em simplicidade operacional para a maioria dos times, e seu principal custo — a granularidade grossa — é administrável se você versionar raramente. Seja qual for a escolha, versione a API inteira em vez de endpoints isolados, mantenha a versão anterior no ar enquanto os consumidores migram, publique um header Sunset e uma data de descontinuação na versão antiga e meça o tráfego por versão, para que a desativação seja uma decisão baseada em evidências e não em esperança.

Hipermídia, na medida certa

A restrição de hipermídia diz que um cliente deve descobrir o que pode fazer em seguida a partir dos links na resposta, e não de URIs fixas no código. Aplicada por completo, ela desacopla os clientes da sua estrutura de URIs de forma permanente.

Sendo honesto, pouquíssimos clientes são escritos para navegar dinamicamente, e os formatos genéricos de hipermídia acrescentam uma cerimônia que a maioria dos times acaba abandonando. O que compensa é o uso direcionado: links de paginação (os clientes realmente seguem next), links para recursos relacionados que poupam uma consulta e links que representam as transições de estado disponíveis, o que permite ao servidor dizer a uma interface que um pedido pode ser cancelado mas não reembolsado, sem que o cliente precise reimplementar essa regra.

{
  "id": "8f21c",
  "status": "CONFIRMED",
  "total": { "amount": "89.90", "currency": "EUR" },
  "_links": {
    "self":    { "href": "/orders/8f21c" },
    "lines":   { "href": "/orders/8f21c/lines" },
    "cancel":  { "href": "/orders/8f21c/cancellation", "method": "POST" }
  }
}

Descrevendo a API

Um documento OpenAPI é o contrato legível por máquina: caminhos, operações, schemas, esquemas de segurança, exemplos. Ele gera bibliotecas cliente, alimenta servidores de mock, viabiliza testes de contrato e abastece o portal do desenvolvedor de uma plataforma de API management.

Existem duas abordagens. Design first significa escrever o documento OpenAPI antes do código, revisá-lo com os consumidores e gerar os stubs do servidor — melhor para APIs públicas ou de parceiros, em que o contrato é negociado. Code first gera o documento a partir da implementação anotada, como faz o springdoc, o que o mantém preciso com menos esforço e combina com APIs internas. Em qualquer um dos casos, mantenha o documento no controle de versão, revise o diff dele no code review para que as breaking changes fiquem visíveis e inclua exemplos realistas — o exemplo é a parte que os desenvolvedores de fato leem.

Principais pontos

  • Projete as URIs em torno de recursos do domínio, não de tabelas, e trate todo caminho e todo nome de campo publicados como praticamente permanentes.
  • Respeite as propriedades de segurança e idempotência definidas na RFC 9110; onde POST não puder ser idempotente, adicione uma chave de idempotência.
  • Use um conjunto pequeno e preciso de códigos de status e nunca sinalize uma falha com 200.
  • Retorne erros como problem details da RFC 9457, com uma URI type estável, extensões estruturadas e um identificador de trace.
  • Pagine toda coleção, ordene por uma chave única e prefira cursores quando os dados mudam durante a leitura ou as páginas ficam profundas.
  • Evolua de forma compatível adicionando em vez de alterando; quando a quebra for inevitável, versione a API inteira, anuncie uma data de desativação e meça o uso antes de desativar qualquer coisa.

Fontes e leituras complementares

Perguntas frequentes

Devo usar PUT ou PATCH para atualizações?
PUT substitui o recurso inteiro pela representação que você envia, então omitir um campo significa apagá-lo. PATCH aplica uma modificação parcial descrita no corpo da requisição. A maioria dos clientes reais quer atualizações parciais, por isso PATCH é a escolha comum; defina o media type explicitamente: application/merge-patch+json para atualizações simples de campos ou application/json-patch+json quando você precisar de operações sobre arrays.
Paginação por offset é tão ruim assim?
Ela funciona bem para conjuntos de dados pequenos e estáveis e para interfaces que precisam pular direto para a página 40. Em escala, tem dois problemas reais: o banco precisa percorrer e descartar tudo o que vem antes do offset, então as páginas profundas ficam mais lentas, e linhas inseridas ou removidas entre requisições fazem itens serem pulados ou repetidos. A paginação por cursor baseada em uma chave de ordenação estável evita os dois.
Preciso implementar HATEOAS para chamar a API de REST?
Pela definição original, sim: é a restrição de hipermídia que permite aos clientes descobrir as transições em vez de fixar URIs no código. Na prática, pouquíssimos clientes navegam pelos links dinamicamente, e a hipermídia completa raramente compensa o custo. O meio-termo pragmático é incluir links para as transições de estado reais e para a paginação, que os clientes de fato seguem, e documentar todo o resto no OpenAPI.
Por quanto tempo uma versão antiga da API deve ser mantida?
Pelo tempo necessário para o consumidor mais lento que importa para você: em APIs públicas, isso costuma significar pelo menos doze meses e, em clientes mobile, no mínimo o tempo de vida da versão mais antiga do app que você ainda suporta. Anuncie uma data de desativação quando a nova versão for lançada, publique o header Sunset nos endpoints descontinuados e instrumente o uso por versão para provar quem ainda está chamando antes de desligar qualquer coisa.