Design de APIs REST
Recursos, nomes, códigos de status e formato de erros em uma API previsível.
Os exemplos implementam a mesma rota nas cinco linguagens: POST /pedidos, que valida o corpo, cria o pedido e responde 201 Created com o cabeçalho Location, ou devolve o erro no formato padrão de Problem Details (RFC 9457).
Criar um recurso, com erros no formato padrão
import http from "node:http";
import { randomUUID } from "node:crypto";
type Item = { produtoId: string; quantidade: number };
type Pedido = { id: string; clienteId: string; itens: Item[]; status: string; criadoEm: string };
const pedidos = new Map<string, Pedido>();
const ERROS = "https://api.loja.com.br/erros";
function problema(res: http.ServerResponse, status: number, tipo: string, titulo: string, extra = {}) {
res.writeHead(status, { "Content-Type": "application/problem+json" });
res.end(JSON.stringify({ type: `${ERROS}/${tipo}`, title: titulo, status, ...extra }));
}
function validar(corpo: any): Record<string, string> {
const erros: Record<string, string> = {};
if (typeof corpo?.clienteId !== "string" || !corpo.clienteId) erros.clienteId = "obrigatório";
if (!Array.isArray(corpo?.itens) || corpo.itens.length === 0) erros.itens = "precisa de pelo menos um item";
corpo?.itens?.forEach?.((item: any, i: number) => {
if (!Number.isInteger(item?.quantidade) || item.quantidade < 1) erros[`itens[${i}].quantidade`] = "inteiro maior que zero";
});
return erros;
}
http.createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/pedidos") {
return problema(res, 404, "nao-encontrado", "Recurso não encontrado");
}
let corpo: any;
try {
let texto = "";
for await (const parte of req) texto += parte;
corpo = JSON.parse(texto);
} catch {
return problema(res, 400, "json-invalido", "O corpo não é um JSON válido");
}
const erros = validar(corpo);
if (Object.keys(erros).length > 0) {
return problema(res, 422, "dados-invalidos", "Os dados enviados são inválidos", { errors: erros });
}
const pedido: Pedido = {
id: randomUUID(),
clienteId: corpo.clienteId,
itens: corpo.itens,
status: "aberto",
criadoEm: new Date().toISOString(),
};
pedidos.set(pedido.id, pedido);
res.writeHead(201, { "Content-Type": "application/json", Location: `/pedidos/${pedido.id}` });
res.end(JSON.stringify(pedido));
}).listen(3000);from datetime import datetime, timezone
from uuid import uuid4
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
ERROS = "https://api.loja.com.br/erros"
app = FastAPI()
pedidos: dict[str, dict] = {}
class Modelo(BaseModel):
# Campos em snake_case no Python, camelCase no JSON
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class Item(Modelo):
produto_id: str
quantidade: int = Field(gt=0)
class NovoPedido(Modelo):
cliente_id: str = Field(min_length=1)
itens: list[Item] = Field(min_length=1)
def problema(status: int, tipo: str, titulo: str, **extra) -> JSONResponse:
return JSONResponse(
status_code=status,
media_type="application/problem+json",
content={"type": f"{ERROS}/{tipo}", "title": titulo, "status": status, **extra},
)
# Troca o formato de erro padrão do FastAPI pelo Problem Details
@app.exception_handler(RequestValidationError)
async def dados_invalidos(request: Request, exc: RequestValidationError):
if any(erro["type"] == "json_invalid" for erro in exc.errors()):
return problema(400, "json-invalido", "O corpo não é um JSON válido")
erros = {".".join(str(p) for p in erro["loc"][1:]): erro["msg"] for erro in exc.errors()}
return problema(422, "dados-invalidos", "Os dados enviados são inválidos", errors=erros)
@app.post("/pedidos", status_code=201)
def criar_pedido(novo: NovoPedido):
pedido = {
"id": str(uuid4()),
"clienteId": novo.cliente_id,
"itens": [item.model_dump(by_alias=True) for item in novo.itens],
"status": "aberto",
"criadoEm": datetime.now(timezone.utc).isoformat(),
}
pedidos[pedido["id"]] = pedido
return JSONResponse(status_code=201, content=pedido, headers={"Location": f"/pedidos/{pedido['id']}"})package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
const erros = "https://api.loja.com.br/erros"
type Item struct {
ProdutoID string `json:"produtoId"`
Quantidade int `json:"quantidade"`
}
type Pedido struct {
ID string `json:"id"`
ClienteID string `json:"clienteId"`
Itens []Item `json:"itens"`
Status string `json:"status"`
CriadoEm string `json:"criadoEm"`
}
var (
mu sync.Mutex
pedidos = map[string]Pedido{}
)
func problema(w http.ResponseWriter, status int, tipo, titulo string, extra map[string]any) {
corpo := map[string]any{"type": erros + "/" + tipo, "title": titulo, "status": status}
for k, v := range extra {
corpo[k] = v
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(corpo)
}
func criarPedido(w http.ResponseWriter, r *http.Request) {
var novo Pedido
if err := json.NewDecoder(r.Body).Decode(&novo); err != nil {
problema(w, 400, "json-invalido", "O corpo não é um JSON válido", nil)
return
}
invalidos := map[string]string{}
if novo.ClienteID == "" {
invalidos["clienteId"] = "obrigatório"
}
if len(novo.Itens) == 0 {
invalidos["itens"] = "precisa de pelo menos um item"
}
for i, item := range novo.Itens {
if item.Quantidade < 1 {
invalidos[fmt.Sprintf("itens[%d].quantidade", i)] = "inteiro maior que zero"
}
}
if len(invalidos) > 0 {
problema(w, 422, "dados-invalidos", "Os dados enviados são inválidos", map[string]any{"errors": invalidos})
return
}
pedido := Pedido{ID: rand.Text(), ClienteID: novo.ClienteID, Itens: novo.Itens,
Status: "aberto", CriadoEm: time.Now().UTC().Format(time.RFC3339)}
mu.Lock()
pedidos[pedido.ID] = pedido
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", "/pedidos/"+pedido.ID)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(pedido)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /pedidos", criarPedido)
http.ListenAndServe(":3000", mux)
}import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.net.URI;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
// Com spring.mvc.problemdetails.enabled=true, o Spring Boot 3 devolve
// erros de validação e de JSON malformado como application/problem+json
@RestController
public class PedidosController {
record Item(@NotBlank String produtoId, @Positive int quantidade) {}
record NovoPedido(@NotBlank String clienteId, @NotEmpty List<@Valid Item> itens) {}
record Pedido(String id, String clienteId, List<Item> itens, String status, Instant criadoEm) {}
private final Map<String, Pedido> pedidos = new ConcurrentHashMap<>();
@PostMapping("/pedidos")
public ResponseEntity<Pedido> criar(@Valid @RequestBody NovoPedido novo) {
var pedido = new Pedido(UUID.randomUUID().toString(), novo.clienteId(),
novo.itens(), "aberto", Instant.now());
pedidos.put(pedido.id(), pedido);
return ResponseEntity.created(URI.create("/pedidos/" + pedido.id())).body(pedido);
}
}using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(); // erros como application/problem+json
var app = builder.Build();
app.UseStatusCodePages();
var pedidos = new ConcurrentDictionary<string, Pedido>();
app.MapPost("/pedidos", (NovoPedido novo) =>
{
var erros = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(novo.ClienteId))
erros["clienteId"] = ["obrigatório"];
if (novo.Itens is not { Count: > 0 })
erros["itens"] = ["precisa de pelo menos um item"];
for (var i = 0; i < (novo.Itens?.Count ?? 0); i++)
if (novo.Itens![i].Quantidade < 1)
erros[$"itens[{i}].quantidade"] = ["inteiro maior que zero"];
if (erros.Count > 0)
return Results.ValidationProblem(erros, statusCode: 422, title: "Os dados enviados são inválidos");
var pedido = new Pedido(Guid.NewGuid().ToString(), novo.ClienteId!, novo.Itens!,
"aberto", DateTime.UtcNow);
pedidos[pedido.Id] = pedido;
return Results.Created($"/pedidos/{pedido.Id}", pedido);
});
app.Run();
record Item(string ProdutoId, int Quantidade);
record NovoPedido(string? ClienteId, List<Item>? Itens);
record Pedido(string Id, string ClienteId, List<Item> Itens, string Status, DateTime CriadoEm);O resultado, nas quatro versões executadas
Rodamos as versões em TypeScript, Python, Go e C# e mandamos três pedidos a cada uma: um válido, um com dados inválidos (cliente vazio e quantidade zero) e um com JSON malformado.
| Pedido | TypeScript | Python (FastAPI) | Go | C# (ASP.NET) |
|---|---|---|---|---|
| Válido | 201, Location | 201, Location | 201, Location | 201, Location |
| Dados inválidos | 422, problem+json | 422, problem+json | 422, problem+json | 422, problem+json |
| JSON malformado | 400, problem+json | 400, problem+json | 400, problem+json | 400, problem+json |
O formato padrão é o mesmo nas quatro; os detalhes, não:
| Mensagens de validação | errors | type | Extras | |
|---|---|---|---|---|
| TypeScript | As do seu código | Texto por campo | O seu catálogo de erros | Nenhum |
| Python | As do Pydantic, em inglês (String should have at least 1 character) | Texto por campo, com o caminho itens.0.quantidade | O seu catálogo | Nenhum |
| Go | As do seu código | Texto por campo | O seu catálogo | Campos em ordem alfabética |
| Java | As do Bean Validation, no idioma da configuração | Não incluídos por padrão: acrescente num @RestControllerAdvice | Padrão do Spring, personalizável | instance com o caminho |
| C# | As do seu código | Lista de textos por campo | Link para a RFC (rfc4918#section-11.2), se não for definido | traceId |
Num mesmo sistema, escolha uma forma e mantenha nos serviços todos: um cliente que integra com três serviços não deveria tratar três formatos de erro. Se as mensagens vão aparecer para o usuário final, traduza as do framework ou use códigos de erro que o cliente traduz.
A versão em Java não foi executada: ela depende de um projeto Spring Boot completo. Com spring.mvc.problemdetails.enabled=true, o Spring Boot 3 responde aos erros de validação com application/problem+json, mas com status 400, e não 422. Para usar 422, trate a MethodArgumentNotValidException num @RestControllerAdvice.
Ler um recurso, com o código certo para cada caso
GET /pedidos/981 HTTP/1.1| Situação | Resposta |
|---|---|
| O pedido existe e pertence ao usuário | 200 com o pedido |
| O pedido não existe | 404 com problem+json |
| O pedido existe e é de outro usuário | 404, e não 403: não confirme a existência de recursos alheios |
| Sem credencial | 401 |
| O identificador tem formato inválido | 400 ou 404; escolha um e mantenha |
A terceira linha é uma decisão de segurança. Responder 403 para pedidos de outros usuários e 404 para os que não existem permite descobrir quais identificadores são válidos.
Testar a API pela linha de comando
# Criar: espera 201 e o cabeçalho Location
curl -i -X POST http://localhost:3000/pedidos \
-H 'Content-Type: application/json' \
-d '{"clienteId":"42","itens":[{"produtoId":"7","quantidade":2}]}'
# Dados inválidos: espera 422 e application/problem+json
curl -i -X POST http://localhost:3000/pedidos \
-H 'Content-Type: application/json' \
-d '{"clienteId":"","itens":[{"produtoId":"7","quantidade":0}]}'
# JSON malformado: espera 400
curl -i -X POST http://localhost:3000/pedidos \
-H 'Content-Type: application/json' -d '{"clienteId":'Esses três pedidos são a base de um teste automatizado de contrato: o status, o Content-Type e os campos do erro fazem parte da API tanto quanto os campos de sucesso.
Lista de revisão
Recursos e URLs
- Os caminhos usam substantivos no plural (
/pedidos,/pedidos/981/itens), sem verbos? - Ações que não são criar, ler, alterar ou remover viram sub-recursos (
POST /pedidos/981/cancelamento)? - Os nomes de campos seguem um único padrão em toda a API?
Respostas
- Criar devolve
201comLocation; remover devolve204? - Erros do cliente são
4xx, falhas do servidor5xx, e nenhum erro sai com200? - Todos os erros seguem o mesmo formato, de preferência
application/problem+json? - Datas em ISO 8601 com fuso, valores monetários sem ponto flutuante?
Segurança e contrato
- Recursos de outros usuários respondem
404, e não403? - A resposta não expõe campos internos (senhas, chaves, colunas que só o banco usa)?
- Existe teste automatizado para os casos de erro, e não só para o de sucesso?
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.
Implementar uma rota de criação do jeito certo
Quero implementar uma rota de criação de recurso seguindo as boas práticas de API REST. Pergunte qual linguagem e framework eu uso. Vamos em etapas. Em cada uma, deixe que eu tente e depois mostre a sua versão: 1. A rota POST que cria um pedido e responde 201 com o cabeçalho Location. 2. A validação dos dados, com erro 422 listando cada campo inválido. 3. O tratamento de JSON malformado com 400. 4. Todos os erros no formato application/problem+json, inclusive os gerados pelo próprio framework. 5. A rota GET que devolve 404 para pedidos inexistentes e para pedidos de outros usuários. 6. Testes automatizados para os casos de sucesso e de erro, conferindo status, Content-Type e corpo. No final, me mostre como testar tudo com curl.
Padronizar os erros do meu framework
Quero que todos os erros da minha API sigam o formato Problem Details, inclusive os gerados pelo framework. Pergunte qual linguagem e framework eu uso. Explique, com código: 1. Como o meu framework formata os erros por padrão: validação, rota inexistente, método não permitido, exceção não tratada. 2. Como trocar cada um pelo formato application/problem+json. 3. Como definir um catálogo de tipos de erro com endereços estáveis. 4. Como traduzir as mensagens de validação do framework. 5. Como garantir que uma exceção não tratada não vaze detalhes internos na resposta. Termine com um teste que confere o formato de cada tipo de erro.
Revisar o código de uma rota
Vou colar abaixo o código de uma ou mais rotas da minha API. Revise com foco no desenho e no contrato. Verifique, e para cada item diga se está correto, incorreto ou ausente, citando a linha: 1. O método e o endereço combinam com a ação? 2. O status de sucesso está correto, com Location ao criar? 3. A validação devolve 422 com os campos inválidos, e JSON malformado devolve 400? 4. Os erros seguem o formato padrão da API? 5. A resposta usa um formato próprio, e não a entidade do banco? 6. Recursos de outros usuários respondem 404? 7. Há testes para os casos de erro? Para cada problema, explique a consequência para quem consome a API e mostre a correção. Código: [cole aqui]