
outubro 17, 2025
0
Construindo APIs REST com Express: contratos, status codes, validação e testes (na prática)
Resumo rápido: neste guia você cria uma API REST consistente com Express: define recursos e contratos, retorna status codes corretos, valida entrada (params/query/body), padroniza erros, adiciona paginação/ordenação, e testa com supertest — tudo com exemplos prontos.
Setup mínimo
mkdir express-api && cd express-api
npm init -y
npm i express zod
npm i -D nodemon supertest vitest
package.json (trecho):
{
"type": "module",
"scripts": {
"dev": "nodemon src/server.js",
"test": "vitest run"
},
"dependencies": { "express": "^4.19.0", "zod": "^3.23.8" },
"devDependencies": { "nodemon": "^3.1.0", "supertest": "^7.0.0", "vitest": "^2.0.0" }
}
Estrutura:
src/
server.js
app.js
routes/
tasks/
index.js
tasks.controller.js
tasks.schemas.js
lib/
async-wrap.js
Princípios de uma API REST “limpa”
- Recurso como substantivo:
/tasks,/tasks/:id - Método HTTP indica intenção:
GET(ler),POST(criar),PATCH(atualizar parcial),DELETE(remover) - Status codes claros:
201(criado),400/422(entrada ruim),404(não encontrado),409(conflito),500(erro interno) - Resposta JSON padronizada: sempre objeto (nunca string solta)
- Paginação/ordenar/filtrar por querystring
- Erros com formato único:
{ error: { code, message, details? } }
App base e montagem de rotas
src/app.js
import express from "express";
import tasksRouter from "./routes/tasks/index.js";
const app = express();
app.use(express.json());
// Domínios da API
app.use("/tasks", tasksRouter);
// 404
app.use((req, res) => {
res.status(404).json({ error: { code: "ROUTE_NOT_FOUND", message: "Rota não encontrada" } });
});
// Handler central de erros
app.use((err, req, res, next) => {
console.error("[ERRO]", err);
const status = err.status || 500;
const payload = {
error: {
code: err.code || (status === 500 ? "INTERNAL_ERROR" : "ERROR"),
message: err.message || "Erro interno do servidor",
...(err.details ? { details: err.details } : {})
}
};
res.status(status).json(payload);
});
export default app;
src/server.js
import app from "./app.js";
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API em http://localhost:${PORT}`));
Contratos com validação (Zod) e handlers assíncronos
src/lib/async-wrap.js
export const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
src/routes/tasks/tasks.schemas.js
import { z } from "zod";
export const TaskCreate = z.object({
title: z.string().min(1, "title é obrigatório"),
done: z.boolean().optional().default(false)
});
export const TaskUpdate = z.object({
title: z.string().min(1).optional(),
done: z.boolean().optional()
});
export const ListQuery = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10),
sort: z.enum(["title","createdAt","done"]).optional().default("createdAt"),
order: z.enum(["asc","desc"]).optional().default("desc")
});
src/routes/tasks/tasks.controller.js
import { TaskCreate, TaskUpdate, ListQuery } from "./tasks.schemas.js";
// DB em memória (exemplo)
const DB = [];
const byId = (id) => DB.find((t) => t.id === id);
export async function list(req, res, next) {
const q = ListQuery.safeParse(req.query);
if (!q.success) return next({ status: 400, code: "BAD_QUERY", message: "Query inválida", details: q.error.format() });
const { page, limit, sort, order } = q.data;
const sorted = [...DB].sort((a, b) => {
const A = a[sort], B = b[sort];
return order === "asc" ? (A > B ? 1 : -1) : (A < B ? 1 : -1);
});
const start = (page - 1) * limit;
const data = sorted.slice(start, start + limit);
res.json({ page, limit, total: DB.length, data });
}
export async function getById(req, res, next) {
const task = byId(req.params.id);
if (!task) return next({ status: 404, code: "TASK_NOT_FOUND", message: "Tarefa não encontrada" });
res.json(task);
}
export async function create(req, res, next) {
const parsed = TaskCreate.safeParse(req.body);
if (!parsed.success) return next({ status: 422, code: "INVALID_BODY", message: "Corpo inválido", details: parsed.error.format() });
const now = new Date().toISOString();
const task = { id: String(Date.now()), createdAt: now, ...parsed.data };
DB.push(task);
res.status(201).json(task);
}
export async function update(req, res, next) {
const task = byId(req.params.id);
if (!task) return next({ status: 404, code: "TASK_NOT_FOUND", message: "Tarefa não encontrada" });
const parsed = TaskUpdate.safeParse(req.body);
if (!parsed.success) return next({ status: 422, code: "INVALID_BODY", message: "Corpo inválido", details: parsed.error.format() });
Object.assign(task, parsed.data);
res.json(task);
}
export async function remove(req, res, next) {
const idx = DB.findIndex((t) => t.id === req.params.id);
if (idx < 0) return next({ status: 404, code: "TASK_NOT_FOUND", message: "Tarefa não encontrada" });
const [deleted] = DB.splice(idx, 1);
res.status(200).json({ deleted });
}
src/routes/tasks/index.js
import { Router } from "express";
import * as ctrl from "./tasks.controller.js";
import { wrap } from "../../lib/async-wrap.js";
const router = Router();
router.get("/", wrap(ctrl.list));
router.get("/:id", wrap(ctrl.getById));
router.post("/", wrap(ctrl.create));
router.patch("/:id", wrap(ctrl.update));
router.delete("/:id", wrap(ctrl.remove));
export default router;
Padrões essenciais para APIs robustas
1) Status codes consistentes
- 201 Created com o recurso no corpo ao criar
- 200 OK para leituras/atualizações bem-sucedidas
- 204 No Content opcional para deleções sem payload
- 400/422 para validação/entrada inválida
- 404 quando recurso não existe
- 409 para conflitos (e.g., duplicidade)
2) Formato de erro único
Mantenha um envelope claro:
{
"error": { "code": "INVALID_BODY", "message": "Corpo inválido", "details": { /* opcional */ } }
}
3) Paginação/previsibilidade
Retorne metadados:
{ "page": 1, "limit": 10, "total": 42, "data": [ /* itens */ ] }
4) Idempotência e segurança
GETnão altera estadoPOSTcria,PATCHatualiza parcialmente- Valide e sanitize entradas — nunca confie no cliente
Testes de API com supertest + vitest
tests/tasks.test.js
import request from "supertest";
import app from "../src/app.js";
import { describe, it, expect } from "vitest";
describe("Tasks API", () => {
it("cria e lista tarefas", async () => {
const create = await request(app).post("/tasks").send({ title: "Ler docs" });
expect(create.status).toBe(201);
expect(create.body).toHaveProperty("id");
const list = await request(app).get("/tasks?limit=1&page=1");
expect(list.status).toBe(200);
expect(list.body).toHaveProperty("data");
expect(list.body.data.length).toBeGreaterThan(0);
});
it("valida corpo inválido", async () => {
const resp = await request(app).post("/tasks").send({ title: "" });
expect(resp.status).toBe(422);
expect(resp.body).toHaveProperty("error");
});
});
Execute:
npm test