0
Promoção de Volta das Aulas ! Cursos com 25% OFF no menu "Cursos"
outubro 17, 2025
0
César Fontanella

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

  • GET não altera estado
  • POST cria, PATCH atualiza 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