Criar sua primeira REST API com Claude Code: CRUD, validação e testes
Crie uma REST API Express com Claude Code: CRUD, validação, erros, testes e código executável.
Ao criar sua primeira REST API, o problema raramente é escrever rotas no Express. A parte difícil é saber o que uma API útil precisa ter: URLs claras, métodos HTTP corretos, validação, respostas de erro previsíveis e alguns testes que provem que o código funciona.
Neste guia, você vai construir com Claude Code uma pequena API de Todos usando Express. Você criará o projeto, adicionará endpoints CRUD, validará JSON, retornará códigos de status úteis e testará tudo com curl e o test runner nativo do Node.js. O código mira Node.js 22/24 LTS ou uma versão mais nova.
Se Claude Code ainda é novidade para você, comece pelo guia inicial de Claude Code. Para se aprofundar em testes, leia Claude Code API testing e validação com Zod.
Termos essenciais
Uma REST API é um estilo para trabalhar com recursos via HTTP. Neste artigo, o recurso é um todo. Em termos simples, outro app chama uma URL, envia ou recebe JSON e usa o código de status para entender o resultado.
| Termo | Significado simples | Exemplo |
|---|---|---|
| Endpoint | URL de entrada da API | GET /todos |
| Método | Verbo HTTP que descreve a ação | GET, POST, PUT, DELETE |
| Código de status | Número que informa o resultado | 200, 201, 400, 404 |
| JSON | Formato de texto leve para dados | { "title": "Learn REST" } |
| Validação | Checar se a entrada é aceitável | Rejeitar title vazio |
| Idempotência | Repetir a mesma requisição mantém o mesmo estado final | Enviar o mesmo PUT duas vezes |
As referências oficiais úteis são métodos HTTP da MDN, códigos de status HTTP da MDN, Express routing, Express error handling e Node.js test runner.
O que vamos construir
A API guarda os Todos em memória. Ao reiniciar o servidor, ela volta ao estado inicial. Isso é proposital: primeiro entendemos o formato REST, depois adicionamos banco de dados.
| Ação | Método e URL | Status de sucesso |
|---|---|---|
| Health check | GET /health | 200 OK |
| Listar Todos | GET /todos | 200 OK |
| Ler um Todo | GET /todos/:id | 200 OK |
| Criar Todo | POST /todos | 201 Created |
| Substituir Todo | PUT /todos/:id | 200 OK |
| Excluir Todo | DELETE /todos/:id | 204 No Content |
Mesmo pequeno, o exemplo encaixa em trabalho real. Primeiro caso: API interna para tarefas, tickets ou revisões. Segundo: backend mock para desenvolver telas em React, Vue ou Astro antes do backend definitivo. Terceiro: API pequena para contato ou newsletter, trocando title por email e message.
Dê critérios claros ao Claude Code
Informe stack, endpoints, erros e verificação. Um prompt vago como “crie uma API” costuma gerar algo que inicia, mas deixa decisões de design sem revisão.
Create a beginner Todo REST API with Express 5 and Node.js ES Modules.
Requirements:
- Provide package.json and server.js
- Add GET /health, GET /todos, GET /todos/:id, POST /todos, PUT /todos/:id, DELETE /todos/:id
- Accept JSON input
- title must be 1 to 120 characters; completed must be boolean
- Return JSON errors for 400, 404, and 500
- Keep PUT idempotent: repeating the same request should not change the final resource state
- Include curl requests and a node:test example
A linha sobre idempotência importa. PUT representa substituição. Se o mesmo corpo for enviado duas vezes, o Todo deve terminar no mesmo estado. Se updatedAt mudar sempre, mesmo sem mudança de dados, o código contradiz a explicação.
Criar o projeto
mkdir claude-rest-api
cd claude-rest-api
npm init -y
npm install express
Substitua package.json:
{
"name": "claude-rest-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch server.js",
"start": "node server.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.0.0"
}
}
server.js
import express from "express";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const PORT = Number(process.env.PORT ?? 3000);
function createHttpError(status, message, details) {
const error = new Error(message);
error.status = status;
error.details = details;
return error;
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function parseId(rawId) {
const id = Number(rawId);
return Number.isInteger(id) && id > 0 ? id : null;
}
function validateTodoInput(body, { partial = false } = {}) {
const errors = [];
if (!partial || hasOwn(body, "title")) {
if (typeof body.title !== "string" || body.title.trim().length === 0) {
errors.push({ field: "title", message: "title is required" });
} else if (body.title.trim().length > 120) {
errors.push({ field: "title", message: "title must be 120 characters or fewer" });
}
}
if (!partial || hasOwn(body, "completed")) {
if (typeof body.completed !== "boolean") {
errors.push({ field: "completed", message: "completed must be a boolean" });
}
}
return errors;
}
export function createApp() {
const app = express();
let nextId = 3;
const todos = [
{
id: 1,
title: "Read MDN HTTP status docs",
completed: false,
updatedAt: "2026-06-03T00:00:00.000Z"
},
{
id: 2,
title: "Ask Claude Code to review API errors",
completed: true,
updatedAt: "2026-06-03T00:00:00.000Z"
}
];
app.use(express.json({ limit: "32kb" }));
app.get("/health", (req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});
app.get("/todos", (req, res) => {
res.json({ data: todos, count: todos.length });
});
app.get("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const todo = todos.find((item) => item.id === id);
if (!todo) return next(createHttpError(404, "todo not found"));
res.json({ data: todo });
});
app.post("/todos", (req, res, next) => {
const errors = validateTodoInput(req.body);
if (errors.length > 0) {
return next(createHttpError(400, "invalid request body", errors));
}
const todo = {
id: nextId,
title: req.body.title.trim(),
completed: req.body.completed,
updatedAt: new Date().toISOString()
};
nextId += 1;
todos.push(todo);
res.status(201).location(`/todos/${todo.id}`).json({ data: todo });
});
app.put("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const errors = validateTodoInput(req.body);
if (errors.length > 0) {
return next(createHttpError(400, "invalid request body", errors));
}
const index = todos.findIndex((item) => item.id === id);
if (index === -1) return next(createHttpError(404, "todo not found"));
const previous = todos[index];
const nextTodo = {
...previous,
title: req.body.title.trim(),
completed: req.body.completed
};
const changed =
previous.title !== nextTodo.title || previous.completed !== nextTodo.completed;
todos[index] = {
...nextTodo,
updatedAt: changed ? new Date().toISOString() : previous.updatedAt
};
res.json({ data: todos[index] });
});
app.delete("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const index = todos.findIndex((item) => item.id === id);
if (index === -1) return next(createHttpError(404, "todo not found"));
todos.splice(index, 1);
res.status(204).send();
});
app.use((req, res, next) => {
next(createHttpError(404, `route not found: ${req.method} ${req.originalUrl}`));
});
app.use((err, req, res, next) => {
const status = Number.isInteger(err.status) && err.status >= 400 ? err.status : 500;
const body = {
error: {
status,
message: status === 500 ? "Internal Server Error" : err.message
}
};
if (err.details) {
body.error.details = err.details;
}
res.status(status).json(body);
});
return app;
}
const currentFile = fileURLToPath(import.meta.url);
const startedFile = process.argv[1] ? resolve(process.argv[1]) : "";
if (startedFile === currentFile) {
createApp().listen(PORT, () => {
console.log(`REST API listening on http://localhost:${PORT}`);
});
}
O essencial é a consistência: sucesso em { data: ... }, erro em { error: ... } e um status code adequado para cada caso.
Testar com curl
npm run dev
curl -i http://localhost:3000/health
curl -i http://localhost:3000/todos
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"Write API tests","completed":false}'
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"","completed":false}'
curl -i -X PUT http://localhost:3000/todos/1 \
-H "Content-Type: application/json" \
-d '{"title":"Read MDN HTTP status docs","completed":true}'
curl -i -X DELETE http://localhost:3000/todos/2
Confira sucesso e falha. Uma API que só funciona no caminho feliz ainda não está pronta para uma interface real.
Adicionar node:test
Crie server.test.js:
import assert from "node:assert/strict";
import test from "node:test";
import { createApp } from "./server.js";
function listen(app) {
return new Promise((resolve) => {
const server = app.listen(0, () => resolve(server));
});
}
async function request(baseUrl, path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
headers: { "Content-Type": "application/json", ...options.headers },
...options
});
const text = await response.text();
return {
response,
body: text ? JSON.parse(text) : null
};
}
test("creates, updates, and deletes a todo", async (t) => {
const server = await listen(createApp());
t.after(() => server.close());
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const created = await request(baseUrl, "/todos", {
method: "POST",
body: JSON.stringify({ title: "Test the API", completed: false })
});
assert.equal(created.response.status, 201);
assert.equal(created.body.data.title, "Test the API");
const id = created.body.data.id;
const updated = await request(baseUrl, `/todos/${id}`, {
method: "PUT",
body: JSON.stringify({ title: "Test the API", completed: true })
});
assert.equal(updated.response.status, 200);
assert.equal(updated.body.data.completed, true);
const deleted = await request(baseUrl, `/todos/${id}`, { method: "DELETE" });
assert.equal(deleted.response.status, 204);
});
test("rejects invalid todo input", async (t) => {
const server = await listen(createApp());
t.after(() => server.close());
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const result = await request(baseUrl, "/todos", {
method: "POST",
body: JSON.stringify({ title: "", completed: false })
});
assert.equal(result.response.status, 400);
assert.equal(result.body.error.details[0].field, "title");
});
npm test
Esses testes são pequenos, mas dão segurança. Quando você pedir ao Claude Code para adicionar banco, OpenAPI ou autenticação, peça que mantenha essa suíte passando.
Armadilhas comuns
Não use endpoints como /getTodos. Deixe o método ser o verbo: GET /todos, POST /todos, PUT /todos/:id.
Não retorne sempre 200. Entrada inválida é 400, recurso ausente é 404, criação correta é 201 e exclusão sem corpo é 204.
Não dependa só da validação do frontend. A API pode ser chamada diretamente, então o servidor deve validar.
Não exponha stack traces aos clientes. Registre detalhes no servidor e retorne JSON controlado.
Não quebre a idempotência. Se um PUT muda timestamps ou valores aleatórios a cada chamada idêntica, repetir a requisição não mantém o mesmo estado final.
Próximos passos e CTA
Depois, adicione uma coisa por vez: banco de dados, validação de esquema, documentação da API e autenticação. Para design de erros, veja padrões de error handling com Claude Code.
Para praticar no seu próprio projeto, use a folha gratuita enquanto escreve prompts para Claude Code. Para prompts reutilizáveis, checklists de revisão e modelos CLAUDE.md, veja a biblioteca de produtos Claude Code. Para onboarding de equipe, regras de revisão de API e desenho de permissões, use treinamento e consultoria Claude Code.
Ao testar o conteúdo deste artigo na prática, o maior avanço para iniciantes não foi o CRUD em si. Foi ver sucesso e erro lado a lado: title vazio retorna 400, ID desconhecido retorna 404 e repetir o mesmo PUT preserva o estado final. Assim, REST deixa de ser uma lista de URLs e passa a ser um contrato entre frontend e backend.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.