REST é o paradigma dominante para APIs HTTP, mas "RESTful" é um dos termos mais usados de forma equivocada em engenharia de software. Uma API que retorna JSON via HTTP não é necessariamente REST. Endpoints como POST /createUser ou GET /deleteOrder?id=123 não são REST. REST é um estilo arquitetural definido por Roy Fielding em sua tese de doutorado em 2000, com seis restrições que têm consequências específicas para escalabilidade, cacheabilidade e evolvabilidade. A diferença entre uma API que evolui graciosamente por anos e uma que acumula breaking changes não planejados está em entender essas restrições — não apenas seus nomes, mas o que cada uma significa em termos de design de sistema.
O Richardson Maturity Model organiza implementações REST em quatro níveis: 0 (HTTP como tubo), 1 (recursos), 2 (verbos HTTP com semântica), 3 (hipermídia). A maioria das APIs "REST" em produção está no nível 2 — usam recursos e verbos corretamente mas não implementam HATEOAS. Isso é pragmaticamente aceitável para a maioria dos casos, e entender exatamente onde o nível 2 termina clarifica quais garantias a API faz sobre descoberta e evolvabilidade. A questão não é "está certo?" mas "o que foi escolhido conscientemente e o que foi deixado de fora?"
OpenAPI 3.1 transformou APIs REST de artefatos de documentação em contratos executáveis. Uma spec bem escrita é simultaneamente documentação, servidor de mock, middleware de validação e gerador de SDK. Entender OpenAPI como contrato — não apenas como descrição do que já existe — muda como APIs são projetadas e como times coordenam ao longo de fronteiras de serviço.
As seis restrições de Fielding
Fielding definiu REST como um conjunto de seis restrições arquiteturais. Violar qualquer uma produz um sistema que não é REST no sentido original — pode ser uma API HTTP perfeitamente funcional, mas não REST.
Client-Server. Separação de responsabilidades entre interface e armazenamento. O cliente não se preocupa com como os dados são armazenados; o servidor não se preocupa com a interface. Isso permite evolução independente de cada lado, desde que o contrato entre eles seja mantido.
Stateless. Cada requisição deve conter toda a informação necessária para o servidor processá-la. O servidor não mantém estado de sessão do cliente entre requisições — toda a sessão state é mantida no cliente (cookie, token JWT). A consequência de escalabilidade é direta: qualquer instância do servidor pode processar qualquer requisição sem necessidade de sticky sessions. O contraponto: requisições carregam mais dados, e o cliente é responsável por manter seu próprio estado.
Cacheable. Respostas devem se declarar como cacheáveis ou não. HTTP oferece um vocabulário rico: Cache-Control, ETag, Last-Modified, Vary. Uma API REST que retorna dados sem Cache-Control desperdiça uma das maiores vantagens do protocolo. Caching não é um detalhe de implementação — é uma restrição arquitetural com implicações de design.
Uniform Interface. A restrição central que distingue REST de outras arquiteturas. Tem quatro sub-restrições: (1) identificação de recursos por URIs, (2) manipulação de recursos através de representações, (3) mensagens auto-descritivas via media types e headers, e (4) HATEOAS — hipermídia como motor do estado da aplicação. A interface uniforme simplifica o sistema e melhora a visibilidade, mas ao custo de eficiência — a interface genérica nem sempre é ótima para casos de uso específicos.
Layered System. Um cliente não pode dizer se está conectado ao servidor final ou a um intermediário (proxy, CDN, load balancer, API gateway). Isso permite inserir camadas de segurança, cache e balanceamento sem mudança no cliente.
Code on Demand (opcional). O servidor pode estender a funcionalidade do cliente enviando código executável (JavaScript em browsers). A única restrição opcional e raramente relevante para APIs de serviço a serviço.
Richardson Maturity Model
Nível 0 — O Pântano do POX. HTTP como transporte puro. Um único endpoint POST para todas as operações. O corpo especifica a ação. SOAP, XML-RPC e muitas APIs corporativas legadas estão aqui. Nenhuma feature do HTTP é explorada — verbos, status codes, cache, content negotiation são ignorados.
Nível 1 — Recursos. Múltiplos endpoints, um por recurso: /users, /orders, /products. URLs representam substantivos. Mas os verbos ainda são confusos — POST /users/123 para obter, POST /deleteOrder como endpoint separado. Progresso real, mas verbos HTTP ainda ignorados.
Nível 2 — Verbos HTTP. Recursos + verbos HTTP corretos + status codes significativos. GET /users lista, POST /users cria, GET /users/123 obtém, PUT /users/123 substitui, PATCH /users/123 atualiza parcialmente, DELETE /users/123 remove. Responses retornam 201 Created com Location ao criar, 404 quando não encontrado, 409 em conflito, 422 em validação inválida. A maioria das APIs "REST" em produção está aqui — pragmaticamente suficiente para a maioria dos casos.
Nível 3 — HATEOAS. Respostas incluem controles de hipermídia: links para ações relacionadas e recursos. O cliente descobre o que pode fazer a partir da resposta, não de documentação externa ou URLs hardcoded. O servidor comunica o estado da aplicação através da presença ou ausência de links. Raríssimo em APIs de serviço a serviço; mais comum em APIs públicas de larga escala.
Verbos HTTP com semântica precisa
GET — recuperar uma representação. DEVE ser safe (sem efeitos colaterais) e idempotente (múltiplas requisições idênticas têm o mesmo efeito que uma). Pode ser cacheado. A violação mais comum: usar GET para operações que modificam estado. GET /orders/123/cancel é errado — cancela um pedido mas usa GET, destruindo a semântica de segurança e cacheabilidade.
POST — criar um recurso ou disparar uma ação não idempotente. NÃO é idempotente por definição: dois POSTs idênticos sem controle externo criam dois recursos. Retorna 201 Created com header Location: /orders/456 apontando para o recurso criado. POST também é o verbo para ações de negócio que não se encaixam em CRUD puro: POST /orders/123/cancel, POST /invoices/456/send são válidos e idiomáticos.
PUT — substituir um recurso inteiro (upsert: cria se não existe, substitui se existe). É idempotente. O cliente envia a representação completa — campos não enviados são tratados como ausentes. PUT /users/123 com body {"name": "Ana"} potencialmente apaga todos os outros campos do usuário 123. Use quando o cliente possui a representação completa e quer fazer substituição atômica.
PATCH — atualização parcial. O cliente envia apenas os campos a modificar. Não necessariamente idempotente. Dois formatos: application/merge-patch+json (RFC 7396 — o patch é mesclado ao documento, null remove campos) e application/json-patch+json (RFC 6902 — sequência de operações explícitas: add, remove, replace, move, copy, test). Merge Patch é mais simples e adequado para a maioria dos casos; JSON Patch é mais preciso para documentos complexos com operações atômicas.
DELETE — remover um recurso. Idempotente: deletar 123 duas vezes resulta no mesmo estado final. A primeira chamada retorna 204 No Content; a segunda pode retornar 404 (recurso não encontrado) ou 204 novamente (idempotência estrita). Escolher entre as duas abordagens deve ser consistente em toda a API — documente na spec.
HEAD e OPTIONS. HEAD é idêntico a GET mas sem body — útil para verificar existência ou validade de cache sem transferir o recurso. OPTIONS retorna os métodos permitidos — usado em CORS preflight. Ambos frequentemente negligenciados em implementações, mas importantes para clientes sofisticados.
Status codes com semântica precisa
O status code é o contrato mais importante de uma resposta HTTP. Um código errado quebra a capacidade do cliente de tomar decisões inteligentes: se deve tentar novamente, como apresentar o erro ao usuário, e o que a operação realmente fez.
2xx — Sucesso. 200 OK: sucesso genérico — para GET (recurso retornado), PUT/PATCH (recurso atualizado retornado), e ações com resultado. 201 Created: recurso criado — DEVE incluir Location header. 202 Accepted: requisição aceita para processamento assíncrono, resultado não disponível ainda. 204 No Content: sucesso sem body — para DELETE, ou PUT/PATCH quando o body atualizado não é retornado. 207 Multi-Status: operação em batch com resultados mistos — cada item tem seu próprio status.
3xx — Redirecionamento. 301 Moved Permanently: URL permanentemente mudada — cache o novo destino. 302 Found / 307 Temporary Redirect: redirect temporário — não cache. 304 Not Modified: resposta cacheada ainda é válida — o servidor confirma sem retransmitir o body. Essencial para caching condicional com ETag ou Last-Modified.
4xx — Erro do cliente (não tente novamente sem corrigir). 400 Bad Request: requisição malformada — sintaxe inválida, parâmetros faltando. 401 Unauthorized: autenticação necessária ou falhou — apesar do nome, é sobre autenticação ("quem é você?"). 403 Forbidden: autenticado mas sem permissão ("você pode isso?"). 404 Not Found: recurso não existe; também usado para esconder 403 por segurança. 409 Conflict: conflito de estado — criação duplicada, conflito de concorrência otimista. 410 Gone: recurso existiu mas foi deletado permanentemente. 422 Unprocessable Entity: sintaticamente válido mas semanticamente inválido — erros de validação de negócio. 429 Too Many Requests: rate limit — DEVE incluir Retry-After e RateLimit-* headers.
5xx — Erro do servidor (o cliente pode tentar novamente com backoff). 500 Internal Server Error: erro inesperado — nunca exponha stack traces. 502 Bad Gateway: proxy recebeu resposta inválida do upstream. 503 Service Unavailable: temporariamente indisponível — inclua Retry-After. 504 Gateway Timeout: upstream não respondeu a tempo.
Problem Details — RFC 9457
RFC 9457 (atualiza RFC 7807) define um formato padrão para respostas de erro em HTTP APIs. O content-type é application/problem+json. O objetivo: clientes de APIs distintas podem tratar erros uniformemente, sem parsear mensagens de erro em formato proprietário de cada API.
// RFC 9457 — Problem Details for HTTP APIs
// Content-Type: application/problem+json
// Erro de validação (422)
{
"type": "https://api.example.com/errors/validation-error",
"title": "Dados de entrada inválidos",
"status": 422,
"detail": "O pedido contém campos inválidos.",
"instance": "/orders/req-abc-123",
"errors": [ // extensão customizada
{
"field": "items[0].quantity",
"message": "Deve ser maior que zero"
},
{
"field": "customer_id",
"message": "Cliente não encontrado"
}
]
}
// Conflito de concorrência (409)
{
"type": "https://api.example.com/errors/optimistic-lock",
"title": "Conflito de concorrência",
"status": 409,
"detail": "O recurso foi modificado desde sua última leitura.",
"instance": "/orders/order-456",
"current_version": 7,
"your_version": 5
}
// Rate limit (429)
{
"type": "https://api.example.com/errors/rate-limited",
"title": "Muitas requisições",
"status": 429,
"detail": "Limite de 100 requisições por minuto atingido."
}
// Headers obrigatórios com 429:
// Retry-After: 47
// RateLimit-Limit: 100
// RateLimit-Remaining: 0
// RateLimit-Reset: 1715299200
O campo type é uma URI que identifica o tipo de problema — idealmente aponta para documentação da API explicando causas e remediações. O campo instance identifica a ocorrência específica — útil para correlacionar com logs internos sem expor stack traces. Extensões são permitidas: qualquer campo adicional é válido, desde que o content-type seja application/problem+json.
HTTP Caching em profundidade
Caching é a restrição REST mais subutilizada. Uma API que não implementa caching força cada cliente a buscar dados frescos a cada requisição, mesmo quando os dados raramente mudam. O protocolo HTTP oferece dois modelos complementares: expiration model (o servidor declara por quanto tempo a resposta é válida) e validation model (o cliente verifica com o servidor se a cópia em cache ainda é válida, sem retransmitir o body).
Cache-Control
# Expiration model — o cliente não precisa perguntar até o tempo expirar
# Recurso público, cacheável por 1 hora em CDN e browsers
Cache-Control: public, max-age=3600
# Recurso privado (usuário específico), cacheável apenas no browser
Cache-Control: private, max-age=300
# Não cachear — para dados sensíveis ou mutáveis em tempo real
Cache-Control: no-store
# Revalidar antes de usar a cópia em cache (validation model)
Cache-Control: no-cache
# Recursos estáticos com hash no nome — cache por 1 ano
Cache-Control: public, max-age=31536000, immutable
# Vary — cache separado por header (essencial para content negotiation)
Vary: Accept-Encoding, Accept-Language
ETag e requisições condicionais
O validation model permite que o cliente verifique se sua cópia está atualizada sem retransmitir o body. O servidor gera um ETag (hash do conteúdo ou versão do recurso) e o inclui na resposta. O cliente armazena o ETag e o envia na próxima requisição via If-None-Match. Se o ETag bater (recurso não mudou), o servidor retorna 304 Not Modified sem body — economizando bandwidth e latência de parsing.
# Fluxo completo de caching com ETag
# 1. Primeira requisição — servidor retorna ETag
GET /products/123
→ 200 OK
ETag: "abc123def456"
Cache-Control: private, max-age=60
Content-Type: application/json
Body: { "id": 123, "name": "Produto X", "price": 4999 }
# 2. Após 60 segundos (max-age expirado) — cliente revalida
GET /products/123
If-None-Match: "abc123def456" # ETag da resposta anterior
# 3a. Recurso não mudou — 304 sem body (economia de bandwidth)
→ 304 Not Modified
ETag: "abc123def456"
Cache-Control: private, max-age=60
# 3b. Recurso mudou — 200 com novo conteúdo
→ 200 OK
ETag: "xyz789abc012" # novo ETag
Body: { "id": 123, "name": "Produto X", "price": 5499 }
# Last-Modified — alternativa ao ETag para recursos com timestamps
GET /products/123
If-Modified-Since: Thu, 09 May 2026 10:00:00 GMT
ETag para concorrência otimista (escrita)
ETags também servem para controle de concorrência otimista em operações de escrita via If-Match. O cliente lê o recurso (obtém ETag), faz modificações, e envia o PUT/PATCH com If-Match: "etag-lido". Se outro cliente modificou o recurso enquanto isso (ETag mudou), o servidor retorna 412 Precondition Failed — sem "last write wins" silencioso.
# Concorrência otimista com ETag
# 1. Cliente A lê o pedido
GET /orders/456
→ ETag: "v3-abc"
Body: { status: "pending", items: [...] }
# 2. Cliente B também lê e modifica primeiro
PATCH /orders/456
If-Match: "v3-abc"
→ 200 OK, ETag: "v4-xyz"
# 3. Cliente A tenta modificar com ETag antigo
PATCH /orders/456
If-Match: "v3-abc" # ETag desatualizado
→ 412 Precondition Failed
Body: { "type": ".../optimistic-lock", "current_version": "v4-xyz" }
# Cliente A relê, resolve conflito, tenta novamente com ETag novo
Idempotency-Key para POST
POST não é idempotente por definição, mas operações de negócio críticas (criar pagamento, processar pedido) precisam ser seguras para retry. Se o cliente envia um POST e não recebe resposta (timeout de rede, mas o servidor processou), ele não sabe se deve tentar de novo — arrisca duplicação. A solução: Idempotency-Key header, padrão estabelecido pelo Stripe e adotado amplamente.
# Idempotency-Key — POST seguro para retry
# Primeira tentativa — cliente gera um UUID único por operação
POST /payments
Idempotency-Key: 7f8e9a1b-2c3d-4e5f-a6b7-8c9d0e1f2a3b
Body: { "amount": 4999, "currency": "BRL", "order_id": "ord-123" }
→ 201 Created
Idempotency-Key: 7f8e9a1b-2c3d-4e5f-a6b7-8c9d0e1f2a3b
Body: { "payment_id": "pay-456", "status": "processing" }
# Timeout — cliente não recebeu resposta. Retenta com o MESMO key
POST /payments
Idempotency-Key: 7f8e9a1b-2c3d-4e5f-a6b7-8c9d0e1f2a3b # mesmo key
Body: { "amount": 4999, "currency": "BRL", "order_id": "ord-123" }
# Servidor identifica: já processou este key
# Retorna a MESMA resposta da primeira requisição (sem criar novo pagamento)
→ 201 Created
Idempotency-Key: 7f8e9a1b-2c3d-4e5f-a6b7-8c9d0e1f2a3b
Body: { "payment_id": "pay-456", "status": "processing" }
# Implementação no servidor:
# - Armazena (idempotency_key, response) em cache com TTL (24h típico)
# - Na nova requisição com mesmo key: retorna resposta cacheada sem reprocessar
# - Key diferente com mesmo body = novo processamento (cliente decidiu criar novo)
# - Conflito de body com mesmo key = 422 (cliente usou key errado)
Paginação de coleções
Retornar coleções sem paginação é um anti-padrão que eventualmente resulta em timeout, OOM no servidor, ou transferência de gigabytes para o cliente. Três estratégias principais, cada uma com trade-offs distintos.
Offset/Limit (paginação posicional)
# Offset/Limit — simples, mas com problema em dados mutáveis
GET /orders?limit=20&offset=40
→ {
"data": [...],
"pagination": {
"total": 1247,
"limit": 20,
"offset": 40,
"next": "/orders?limit=20&offset=60",
"prev": "/orders?limit=20&offset=20"
}
}
# Problema: se um item é inserido antes do offset atual enquanto o cliente
# pagina, ele vê um item duplicado na próxima página (o que era offset 40
# agora está em offset 41). Se um item é deletado, ele pula um item.
# Em dados mutáveis e high-concurrency, offset paginação é incorreta.
Cursor/Keyset (paginação por posição estável)
# Cursor-based — correto para dados mutáveis, eficiente em grandes volumes
# O cursor é um ponteiro opaco para o último item visto
GET /orders?limit=20
→ {
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTI3LCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0xMFQxMDowMDowMFoifQ",
"has_next": true
}
}
# Próxima página — usa o cursor, não offset
GET /orders?limit=20&cursor=eyJpZCI6MTI3LCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0xMFQxMDowMDowMFoifQ
# O cursor decodificado: { "id": 127, "created_at": "2026-05-10T10:00:00Z" }
# A query SQL: WHERE (created_at, id) < ('2026-05-10T10:00:00Z', 127) ORDER BY created_at DESC, id DESC LIMIT 20
# Vantagens:
# - Correto mesmo com inserções/deleções concorrentes
# - O índice (created_at, id) torna a query O(log n) independente da profundidade
# - Sem o problema de offset que degrada com tabelas grandes (OFFSET 100000 força scan)
# Desvantagens:
# - Não permite pular para página arbitrária ("ir para página 50")
# - O cursor precisa ser opaco (encode/encrypt) para esconder detalhes internos
Page/Size (paginação por número de página)
# Page-based — conveniente para UIs, mas mesmos problemas do offset
GET /orders?page=3&size=20
→ {
"data": [...],
"pagination": {
"page": 3,
"size": 20,
"total_pages": 63,
"total_items": 1247
}
}
# É essencialmente offset paginação com nomenclatura diferente:
# offset = (page - 1) * size
# Use quando UX precisa de "ir para página N" e dados são relativamente estáticos.
HATEOAS — hipermídia como motor do estado
HATEOAS é a restrição da "uniform interface" de Fielding mais raramente implementada — e a que mais fundamentalmente transforma a relação entre cliente e servidor. A ideia: a resposta não contém apenas dados, mas também links para as próximas ações disponíveis. O cliente descobre o que pode fazer a partir da resposta, não de documentação externa ou URLs hardcoded.
Um pedido em estado "pending" retorna links para "cancel" e "payment". Um pedido em estado "shipped" não retorna o link para "cancel" — a ação não está disponível naquele estado. O cliente não precisa conhecer as regras de negócio sobre quando cancelamento é permitido: o servidor comunica isso através da presença ou ausência de controles. Quando o servidor muda a URL de cancelamento, clientes que seguem links em vez de hardcodar URLs continuam funcionando sem mudança.
// HAL — Hypertext Application Language
// O formato mais comum para HATEOAS em APIs JSON
GET /orders/123
→ {
"order_id": "123",
"status": "pending",
"total": 4999,
"_links": {
"self": { "href": "/orders/123" },
"collection": { "href": "/orders" },
"cancel": { "href": "/orders/123/cancel", "method": "POST" },
"payment": { "href": "/orders/123/payment", "method": "POST" }
}
}
// Mesmo recurso em estado "shipped" — link de cancel ausente
GET /orders/456
→ {
"order_id": "456",
"status": "shipped",
"_links": {
"self": { "href": "/orders/456" },
"track": { "href": "/shipments/shp-789" }
// sem "cancel" — cliente não precisa saber por que; simplesmente não há ação
}
}
Versionamento de API
Qualquer API que não pode mudar é uma API engessada. Versionamento é a estratégia para evoluir APIs sem quebrar clientes existentes.
URL path (/v1/orders, /v2/orders): explícito, visível em logs, fácil de rotear em proxy/gateway, fácil de testar em browser. Tecnicamente viola a semântica REST (a URL representa o recurso, não a versão do contrato). Na prática é o padrão da indústria pelo operacional mais simples. Stripe, Twilio e a maioria das APIs públicas usam URL path.
Header (Accept: application/vnd.myapi.v2+json): semanticamente mais correto — content negotiation é exatamente o que o header Accept é para. Invisível em logs por default, difícil de testar em browser sem ferramenta. GitHub usa este modelo. Requer roteamento mais sofisticado no gateway.
Query string (/orders?version=2): conveniente para testes rápidos, polui cache keys. Raramente em produção.
A questão mais importante não é qual estratégia usar, mas como manter múltiplas versões com custo operacional aceitável. Opções: API gateway roteia versões para instâncias distintas (isolamento total, custo alto), um serviço mantém múltiplos handlers por versão (prático até v3), ou backward compatibility disciplinada — nunca remover campos, nunca mudar tipos, sempre adicionar campos como opcionais — reduz drasticamente a necessidade de versões explícitas.
OpenAPI 3.1 como contrato executável
OpenAPI 3.1 (lançado em 2021, alinhado com JSON Schema draft 2020-12) é o padrão para descrever APIs REST. Um arquivo YAML ou JSON que descreve endpoints, parâmetros, request bodies, responses, schemas e autenticação — de forma que ferramentas possam gerar código, documentação, validação e mocks a partir dele.
Documentação: geração automática de documentação interativa (Swagger UI, Scalar, Redoc). A spec é executável — o usuário pode fazer chamadas reais a partir do browser de documentação. Mas documentação é o valor mais superficial do OpenAPI.
Validação: middleware que valida requisições e respostas contra o schema antes de chegar nos handlers. Erros de validação são retornados automaticamente com 422 e mensagem detalhada por campo. O código da aplicação nunca vê uma requisição inválida. Respostas podem ser validadas em teste (contract testing) para garantir que o código retorna exatamente o que a spec promete.
Geração de código: SDKs de cliente em múltiplas linguagens gerados da spec (openapi-generator, oapi-codegen para Go, kiota da Microsoft). Server stubs gerados para garantir que o servidor implementa todos os endpoints prometidos. Mock servers (Prism, WireMock) gerados para que clientes possam desenvolver antes da API existir.
Schema-first vs code-first. Schema-first: a spec é escrita primeiro, e código/documentação são gerados dela. O spec é reviewed, versionado e aprovado antes de qualquer implementação — permite que cliente e servidor desenvolvam em paralelo usando mocks. Code-first: o código é escrito com anotações, e a spec é gerada das anotações. Mais simples inicialmente, mas a spec tende a refletir a implementação em vez do contrato ideal. Para APIs que são produtos (consumidas externamente), schema-first é fortemente preferível.
APIs REST em C#, Python e Go
// Program.cs — REST com ProblemDetails (RFC 9457), ETag e OpenAPI
using Microsoft.AspNetCore.Mvc;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails(); // RFC 9457 automático para 5xx
builder.Services.AddScoped<IOrdersRepository, OrdersRepository>();
var app = builder.Build();
app.UseExceptionHandler(); // converte exceções em ProblemDetails
app.MapOpenApi();
app.MapScalarApiReference();
// POST /orders — 201 com Location header
app.MapPost("/orders", async (
[FromBody] CreateOrderRequest req,
IOrdersRepository repo) =>
{
if (req.Items.Count == 0)
return Results.ValidationProblem( // 422 com application/problem+json
new Dictionary<string, string[]>
{
["items"] = ["Pedido deve ter ao menos um item."]
});
var order = await repo.CreateAsync(req);
return Results.Created($"/orders/{order.Id}", new OrderResponse(order));
})
.WithName("CreateOrder")
.WithTags("Orders")
.Produces<OrderResponse>(201)
.ProducesValidationProblem()
.ProducesProblem(500);
// GET /orders/{id} — com ETag para caching e concorrência otimista
app.MapGet("/orders/{id:guid}", async (Guid id, IOrdersRepository repo, HttpContext ctx) =>
{
var order = await repo.GetByIdAsync(id);
if (order is null)
return Results.Problem(
title: "Pedido não encontrado",
detail: $"O pedido {id} não existe.",
statusCode: 404,
type: "https://api.example.com/errors/not-found");
var etag = $"\"{order.Version}\"";
var ifNoneMatch = ctx.Request.Headers.IfNoneMatch.ToString();
if (ifNoneMatch == etag)
return Results.StatusCode(304); // 304 Not Modified — sem body
ctx.Response.Headers.ETag = etag;
ctx.Response.Headers.CacheControl = "private, max-age=60";
return Results.Ok(new OrderResponse(order));
})
.WithName("GetOrder")
.WithTags("Orders");
// PATCH /orders/{id} — com If-Match para concorrência otimista
app.MapPatch("/orders/{id:guid}", async (
Guid id,
[FromBody] PatchOrderRequest patch,
IOrdersRepository repo,
HttpContext ctx) =>
{
var order = await repo.GetByIdAsync(id);
if (order is null) return Results.NotFound();
var ifMatch = ctx.Request.Headers.IfMatch.ToString().Trim('"');
if (!string.IsNullOrEmpty(ifMatch) && ifMatch != order.Version.ToString())
return Results.Problem(
title: "Conflito de concorrência",
statusCode: 412,
type: "https://api.example.com/errors/optimistic-lock");
await repo.PatchAsync(id, patch);
return Results.NoContent();
})
.WithName("PatchOrder")
.WithTags("Orders");
app.Run();
ASP.NET Core 8+ gera Problem Details (RFC 9457) automaticamente via AddProblemDetails() e UseExceptionHandler(). Results.Problem() e Results.ValidationProblem() retornam application/problem+json. A integração de ETag via ctx.Response.Headers.ETag é manual mas simples — para APIs com muitos endpoints cacheáveis, considere um middleware centralizado.
# orders/api.py — REST completo com validação, Problem Details e paginação
from uuid import UUID, uuid4
from base64 import b64encode, b64decode
import json
from typing import Annotated
from fastapi import FastAPI, HTTPException, Header, Request, status
from fastapi.responses import JSONResponse
from fastapi.exception_handlers import http_exception_handler
from pydantic import BaseModel, Field, field_validator
app = FastAPI(title="Orders API", version="1.0.0")
# Problem Details (RFC 9457) — override do handler padrão
@app.exception_handler(HTTPException)
async def problem_details_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"type": f"https://api.example.com/errors/{exc.status_code}",
"title": exc.detail if isinstance(exc.detail, str) else "Erro",
"status": exc.status_code,
"instance": str(request.url),
},
headers={"Content-Type": "application/problem+json"},
)
class OrderItem(BaseModel):
product_id: UUID
quantity: int = Field(gt=0)
price_cents: int = Field(gt=0)
class CreateOrderRequest(BaseModel):
customer_id: UUID
items: list[OrderItem] = Field(min_length=1)
@field_validator("items")
@classmethod
def no_duplicate_products(cls, items):
ids = [i.product_id for i in items]
if len(ids) != len(set(ids)):
raise ValueError("Produtos duplicados não permitidos.")
return items
class OrderPage(BaseModel):
data: list[dict]
next_cursor: str | None = None
has_next: bool = False
# Cursor pagination — estável para dados mutáveis
def encode_cursor(order_id: str, created_at: str) -> str:
return b64encode(
json.dumps({"id": order_id, "created_at": created_at}).encode()
).decode()
def decode_cursor(cursor: str) -> dict:
return json.loads(b64decode(cursor.encode()))
@app.get("/orders", response_model=OrderPage, tags=["Orders"])
async def list_orders(
limit: int = Field(default=20, le=100, ge=1),
cursor: str | None = None,
) -> OrderPage:
cursor_data = decode_cursor(cursor) if cursor else None
orders = await fetch_orders_after_cursor(cursor_data, limit + 1)
has_next = len(orders) > limit
page_orders = orders[:limit]
next_cursor = None
if has_next:
last = page_orders[-1]
next_cursor = encode_cursor(str(last["id"]), last["created_at"])
return OrderPage(data=page_orders, next_cursor=next_cursor, has_next=has_next)
@app.get("/orders/{order_id}", tags=["Orders"])
async def get_order(
order_id: UUID,
if_none_match: Annotated[str | None, Header()] = None,
) -> JSONResponse:
order = await fetch_order(order_id)
if order is None:
raise HTTPException(status_code=404, detail=f"Pedido {order_id} não encontrado.")
etag = f'"{order["version"]}"'
if if_none_match == etag:
return JSONResponse(status_code=304, content=None)
return JSONResponse(
content=order,
headers={
"ETag": etag,
"Cache-Control": "private, max-age=60",
},
)
FastAPI valida automaticamente os request body com Pydantic e retorna 422 com detalhe por campo quando inválido. O override do exception handler converte HTTPException em Problem Details. O cursor de paginação é codificado em base64 para ser opaco para o cliente — esconde detalhes internos (campo de ordenação, tipo de ID) e permite mudar a implementação sem breaking change na API.
// handlers/orders.go — REST idiomático em Go com Problem Details e idempotência
package handlers
import (
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// writeProblem — RFC 9457 application/problem+json
func writeProblem(w http.ResponseWriter, status int, problemType, title, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"type": "https://api.example.com/errors/" + problemType,
"title": title,
"status": status,
"detail": detail,
})
}
// Create — POST /orders com Idempotency-Key
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
idempotencyKey := r.Header.Get("Idempotency-Key")
// Verifica se já processamos este key
if idempotencyKey != "" {
if cached, ok := h.idempotencyCache.Get(idempotencyKey); ok {
// Retorna a mesma resposta sem reprocessar
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Idempotency-Key", idempotencyKey)
w.WriteHeader(cached.StatusCode)
w.Write(cached.Body)
return
}
}
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeProblem(w, http.StatusBadRequest,
"invalid-body", "Corpo inválido", err.Error())
return
}
if len(req.Items) == 0 {
writeProblem(w, http.StatusUnprocessableEntity,
"validation-error", "Dados inválidos",
"Pedido deve ter ao menos um item.")
return
}
order, err := h.repo.Create(r.Context(), req)
if err != nil {
writeProblem(w, http.StatusInternalServerError,
"internal-error", "Erro interno", "Erro ao criar pedido.")
return
}
body, _ := json.Marshal(OrderResponse{Order: order})
w.Header().Set("Location", "/orders/"+order.ID.String())
w.Header().Set("Content-Type", "application/json")
if idempotencyKey != "" {
w.Header().Set("Idempotency-Key", idempotencyKey)
// Armazena resposta para retries (TTL: 24h)
h.idempotencyCache.Set(idempotencyKey, CachedResponse{
StatusCode: http.StatusCreated,
Body: body,
}, 24*time.Hour)
}
w.WriteHeader(http.StatusCreated)
w.Write(body)
}
// GetByID — com ETag para caching
func (h *OrderHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid-id", "ID inválido", err.Error())
return
}
order, err := h.repo.GetByID(r.Context(), id)
if err == ErrNotFound {
writeProblem(w, http.StatusNotFound,
"not-found", "Pedido não encontrado",
fmt.Sprintf("O pedido %s não existe.", id))
return
}
body, _ := json.Marshal(OrderResponse{Order: order})
etag := fmt.Sprintf(`"%x"`, md5.Sum(body))
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified) // 304 sem body
return
}
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=60")
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}
Go não tem framework que gere OpenAPI automaticamente como FastAPI — huma ou oapi-codegen preenchem esse gap. A função writeProblem centraliza a serialização de Problem Details com o content-type correto. O cache de Idempotency-Key pode ser implementado com Redis em produção — a interface idempotencyCache abstrai o storage.
Decisões de engenharia
Schema-first ou code-first para OpenAPI?
Schema-first quando a API é um produto consumido externamente ou por múltiplos times: o contrato pode ser negociado antes da implementação, e cliente e servidor desenvolvem em paralelo com mocks. Code-first é pragmático para APIs internas com um único time consumidor — menos overhead, mas a spec tende a refletir a implementação e não o design ideal. Se você acumula mais de 3 versões da spec divergindo do código, é sinal de que code-first não está funcionando — migre para schema-first.
Offset ou cursor pagination?
Cursor (keyset) para qualquer coleção que muda com frequência ou que pode ter volume grande: é correto em dados mutáveis e tem performance O(log n) independente da profundidade. Offset para UIs que precisam de "ir para página N" com dados relativamente estáticos — catálogos, documentos arquivados. Nunca offset para feeds em tempo real, eventos, ou qualquer coleção onde inserções e deleções concorrentes são comuns — o cliente verá duplicatas e itens pulados.
Quando implementar Idempotency-Key em POST?
Obrigatório em operações com efeito financeiro (criar pagamento, processar transferência) ou que criam recursos únicos onde duplicação é inaceitável (envio de email transacional, criação de contrato). Recomendado para qualquer POST que pode ser retentado por infraestrutura (retry automático de API gateway, mobile apps com reconexão). Desnecessário onde duplicação é aceitável (criar um comentário, adicionar um log) ou facilmente detectável e removível. O overhead de implementar é baixo (tabela de keys com TTL); o custo de não implementar em operações críticas é alto.
Como implementar backward compatibility sem criar v2?
Três regras rígidas: nunca remover campos de responses (adicionar apenas), nunca mudar o tipo de um campo existente (string não vira int), nunca tornar um campo de request obrigatório que antes era opcional. Com essas três regras, a maioria das evoluções de API são backward compatible sem versão explícita. O que inevitavelmente requer v2: mudança de semântica (o campo "status" antes tinha 3 valores, agora tem 7 com significados diferentes), remoção de endpoint, ou mudança fundamental no modelo de autenticação. Documente explicitamente qual é o critério de "breaking change" do seu time antes de precisar decidir sob pressão.
Perguntas de entrevista
Qual a diferença entre 401 e 403? Entre 404 e 410? Por que essa distinção importa para clientes?
401 vs 403: 401 Unauthorized (apesar do nome) é sobre autenticação — "não sei quem você é, autentique-se". O cliente deve tentar novamente com credenciais válidas. O header WWW-Authenticate indica o mecanismo de autenticação esperado. 403 Forbidden é sobre autorização — "sei quem você é, mas você não tem permissão". Tentar novamente com as mesmas credenciais não vai ajudar. A distinção é crítica para clientes: ao receber 401, um cliente sofisticado pode renovar o token e retentar automaticamente; ao receber 403, deve informar o usuário que ele não tem acesso a esse recurso.
404 vs 410: 404 Not Found significa "não encontrei esse recurso aqui" — pode ser que nunca existiu, pode ser que foi movido, pode ser erro de digitação na URL. 410 Gone significa "esse recurso existiu aqui e foi removido permanentemente". Para scrapers, motores de busca e caches: 404 indica "tente de novo mais tarde", 410 indica "pode remover do índice, não vai voltar". Para APIs REST, 410 é mais honesto quando você sabe que o recurso foi deletado intencionalmente — o cliente sabe que não deve tentar encontrar o recurso em outro lugar.
O que é idempotência em REST? Quais verbos HTTP são idempotentes e por quê POST não é?
Uma operação é idempotente quando executá-la múltiplas vezes tem o mesmo efeito que executá-la uma vez. No contexto HTTP, significa que múltiplas requisições idênticas resultam no mesmo estado do servidor. GET, HEAD, PUT, DELETE e OPTIONS são idempotentes por definição da especificação HTTP. GET e HEAD nunca modificam estado. PUT substitui o recurso pelo body enviado — enviar o mesmo body N vezes resulta no mesmo estado. DELETE remove o recurso — se ele já não existe na segunda chamada, o estado final é o mesmo.
POST não é idempotente porque cada chamada pode criar um novo recurso ou disparar um novo efeito. POST /orders com o mesmo body duas vezes deve criar dois pedidos. Para tornar operações POST retentáveis sem efeito colateral, usa-se o padrão Idempotency-Key: o cliente gera um UUID por operação lógica e o envia como header. O servidor armazena o par (key, response) e, se receber o mesmo key novamente, retorna a mesma resposta sem reprocessar.
Como implementar caching HTTP corretamente? Qual a diferença entre ETag e Cache-Control?
HTTP tem dois modelos de caching complementares. O expiration model (Cache-Control: max-age=N) declara por quanto tempo a resposta é válida — o cliente não precisa consultar o servidor até o prazo expirar. Eficiente para recursos que mudam raramente. O validation model (ETag + If-None-Match) permite ao cliente verificar se sua cópia está atualizada sem retransmitir o body. O servidor gera um ETag (hash do conteúdo ou versão), o inclui na resposta. Na próxima requisição, o cliente envia If-None-Match: "etag". Se o recurso não mudou, o servidor retorna 304 Not Modified sem body — economizando bandwidth.
Os dois modelos se complementam: Cache-Control: max-age=60, must-revalidate diz "use a cópia por até 60 segundos sem perguntar; depois, revalide com ETag antes de usar". ETags também servem para concorrência otimista em escritas via If-Match: o cliente envia o ETag que leu junto com o PUT/PATCH; se outro cliente modificou o recurso (ETag mudou), o servidor retorna 412 Precondition Failed em vez de sobrescrever silenciosamente.
Qual a diferença entre offset pagination e cursor pagination? Quando cada uma falha?
Offset pagination (?limit=20&offset=40) é simples de implementar mas tem dois problemas sérios. Primeiro: em dados mutáveis, inserções ou deleções entre páginas causam itens duplicados ou pulados. Se um item é inserido antes do offset 40, o que era item 40 agora é 41, e o cliente verá o antigo item 40 de novo na próxima página. Segundo: performance degrada com offset alto — OFFSET 100000 LIMIT 20 faz o banco varrer 100.020 linhas para retornar 20, tornando páginas tardias cada vez mais lentas.
Cursor pagination usa um ponteiro opaco para o último item visto — tipicamente uma combinação de (created_at, id) codificada. A query SQL usa WHERE (created_at, id) < (cursor_value) ORDER BY ... LIMIT N, que aproveita o índice composto e tem performance O(log n) independente da profundidade. É correto em dados mutáveis porque o cursor aponta para uma posição estável, não um offset numérico. A limitação: não permite "ir para página 50" diretamente — só navegação sequencial. Use cursor para qualquer feed ou coleção mutável de alto volume; offset apenas onde a UI precisa de paginação numerada e os dados são relativamente estáticos.
O que é o Problem Details RFC 9457 e por que é preferível a formatos de erro proprietários?
RFC 9457 define um formato padrão para respostas de erro em APIs HTTP via content-type application/problem+json. Os campos canônicos são: type (URI identificando o tipo de problema), title (descrição legível do tipo), status (código HTTP), detail (explicação desta ocorrência específica) e instance (URI identificando a ocorrência). Campos adicionais são permitidos como extensões.
A vantagem sobre formatos proprietários é interoperabilidade: clientes de APIs distintas podem tratar erros uniformemente sem parsear diferentes estruturas JSON proprietárias ({"error": "..."}, {"message": "..."}, {"errors": [...]}). O campo type aponta para documentação do problema, permitindo automação: monitoramento pode agregar por tipo de problema sem interpretação de texto livre. O campo instance correlaciona o erro com logs internos sem expor detalhes de implementação. Frameworks modernos já incluem Problem Details nativamente: ASP.NET Core via AddProblemDetails(), Spring Boot via spring.mvc.problemdetails.enabled=true.
Como praticar
- Audite uma API existente no Richardson Maturity Model. Para cada endpoint, classifique o nível (0–3). Identifique os 3 piores offenders: verbos errados (GET que modifica estado, POST que lista), status codes imprecisos (500 onde deveria ser 422, 200 onde deveria ser 201 com Location), nomes com verbos em vez de substantivos (
/getUserem vez de/users/{id}). Para cada offender, estime o esforço de corrigir sem breaking change e o impacto para os clientes. - Implemente ETag e caching condicional completo. Em um endpoint GET, adicione: (a) geração de ETag baseado no hash do conteúdo ou versão do recurso; (b) suporte a
If-None-Matchretornando 304 quando o ETag bate; (c)Cache-Controlapropriado para o tipo de dado; (d) suporte aIf-Matchno PATCH/PUT para concorrência otimista. Escreva testes que verificam cada cenário: primeira requisição retorna ETag, segunda requisição com ETag correto retorna 304, PATCH com ETag desatualizado retorna 412. - Implemente cursor pagination e compare com offset. Escolha um endpoint de listagem. Implemente as duas estratégias: offset (
?limit=N&offset=M) e cursor (?limit=N&cursor=opaque). Para a versão cursor: use um índice composto (created_at, id) e codifique o cursor em base64 para ser opaco. Simule inserções concorrentes enquanto pagina e observe a diferença de comportamento. Meça o tempo de query para página 1 vs página 100 com offset vs cursor em uma tabela de 1M linhas. - Escreva uma spec OpenAPI 3.1 schema-first antes do código. Escolha um novo recurso. Escreva o YAML completo: paths, schemas com exemplos, error responses usando Problem Details como schema, segurança. Submeta para review antes de implementar. Use Prism para subir um mock server a partir da spec — verifique que o mock é funcional. Depois implemente e valide que a implementação está em conformidade com a spec usando testes de contrato.
- Implemente Idempotency-Key em um endpoint crítico. Escolha um POST de operação crítica (criação de pagamento, envio de email). Implemente o handler com: (a) leitura do header
Idempotency-Key; (b) lookup no cache (Redis ou tabela de banco) antes de processar; (c) armazenamento da resposta após processamento com TTL de 24h; (d) retorno da resposta cacheada em retries. Escreva um teste de integração que simula timeout na primeira tentativa (mata o processo antes da resposta) e verifica que a segunda tentativa retorna a mesma resposta sem duplicação.
Referências para aprofundar
- paper Architectural Styles and the Design of Network-based Software Architectures — Roy T. Fielding (2000).
- artigo Richardson Maturity Model — Martin Fowler (2010).
- docs HTTP Semantics — RFC 9110 — R. Fielding et al. (IETF, 2022).
- docs Problem Details for HTTP APIs — RFC 9457 — IETF (2023).
- docs OpenAPI Specification 3.1.0 — OpenAPI Initiative (2021).
- livro RESTful Web API Patterns and Practices Cookbook — Mike Amundsen (O'Reilly, 2022).
- docs Zalando RESTful API Guidelines — Zalando (2024).
- docs HTTP Caching — RFC 9111 — IETF (2022).
- artigo Stripe API Design Decisions — Stripe Engineering Blog.
- livro API Design Patterns — JJ Geewax (Manning, 2021).
- docs Google Cloud API Design Guide — Google (2024).
- artigo Evolving API Pagination at Slack — Slack Engineering (2018).