Crear tu primera REST API con Claude Code: CRUD, validación y pruebas
Crea una REST API Express con Claude Code: CRUD, validación, diseño de errores, pruebas y código ejecutable.
Cuando creas tu primera REST API, lo difícil no es escribir rutas de Express. Lo difícil es saber qué debe tener una API útil: URLs claras, métodos HTTP correctos, validación, errores predecibles y alguna prueba que confirme que todo funciona.
En esta guía vas a construir con Claude Code una pequeña API de Todos usando Express. Crearás el proyecto, añadirás endpoints CRUD, validarás JSON, devolverás códigos de estado útiles y probarás la API con curl y el test runner integrado de Node.js. El código está pensado para Node.js 22/24 LTS o una versión más reciente.
Si todavía estás aprendiendo Claude Code, empieza por la guía de inicio de Claude Code. Para profundizar en pruebas, sigue con Claude Code API testing y validación con Zod.
Conceptos básicos
Una REST API es una forma de trabajar con recursos mediante HTTP. En este artículo el recurso es un todo. En palabras simples: otra aplicación llama a una URL, envía o recibe JSON y usa el código de estado para saber qué ocurrió.
| Término | Significado simple | Ejemplo |
|---|---|---|
| Endpoint | URL de entrada de la API | GET /todos |
| Método | Verbo HTTP que indica la acción | GET, POST, PUT, DELETE |
| Código de estado | Número que resume el resultado | 200, 201, 400, 404 |
| JSON | Formato de texto ligero para datos | { "title": "Learn REST" } |
| Validación | Comprobar que la entrada es válida | Rechazar title vacío |
| Idempotencia | Repetir la misma petición deja el mismo estado final | Enviar el mismo PUT dos veces |
Guarda estas referencias oficiales: métodos HTTP de MDN, códigos de estado HTTP de MDN, routing de Express, manejo de errores en Express y test runner de Node.js.
Qué vamos a construir
La API guarda los Todos en memoria. Al reiniciar el servidor, vuelve al estado inicial. Es una decisión intencional para aprender la forma de una REST API antes de añadir una base de datos.
| Acción | Método y URL | Estado de éxito |
|---|---|---|
| Comprobar salud | GET /health | 200 OK |
| Listar Todos | GET /todos | 200 OK |
| Leer un Todo | GET /todos/:id | 200 OK |
| Crear Todo | POST /todos | 201 Created |
| Reemplazar Todo | PUT /todos/:id | 200 OK |
| Eliminar Todo | DELETE /todos/:id | 204 No Content |
Aunque el ejemplo es pequeño, sirve para casos reales. El primer uso es una API interna de tareas, revisiones o tickets. El segundo es un backend falso para desarrollar una interfaz en React, Vue o Astro. El tercero es una API pequeña para contactos o newsletter, cambiando title por email y message.
Pide a Claude Code criterios de finalización
Incluye stack, endpoints, errores y verificación. Un prompt como “crea una API” suele producir código que arranca, pero no deja claro cómo revisar el diseñ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
La línea de idempotencia es importante. PUT representa un reemplazo. Si envías el mismo cuerpo dos veces, el Todo debe quedar en el mismo estado final. Si updatedAt cambia siempre, aunque los datos no cambien, el código contradice la explicación.
Crear el proyecto
mkdir claude-rest-api
cd claude-rest-api
npm init -y
npm install express
Reemplaza 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}`);
});
}
El punto importante es la consistencia. Las respuestas correctas usan { data: ... }, los errores usan { error: ... } y cada caso devuelve un status code adecuado.
Probar con 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
Mira tanto los casos correctos como los errores. Una API que solo funciona en el camino feliz todavía no está lista para una interfaz real.
Añadir node:test
Crea 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
Con esto, Claude Code tiene una red de seguridad. Cuando le pidas añadir SQLite, PostgreSQL, OpenAPI o autenticación, exige que las pruebas sigan pasando.
Errores comunes
No nombres endpoints como /getTodos. Deja que el método sea el verbo: GET /todos, POST /todos, PUT /todos/:id.
No devuelvas siempre 200. Entrada inválida es 400, recurso inexistente es 404, creación correcta es 201 y eliminación sin cuerpo es 204.
No confíes solo en la validación del frontend. La API puede llamarse directamente, así que el servidor también debe validar.
No expongas stack traces a clientes. Registra el detalle en el servidor y devuelve una respuesta JSON controlada.
No rompas la idempotencia. Si un PUT cambia campos aleatorios o timestamps en cada llamada idéntica, repetir la petición ya no deja el mismo estado final.
Siguientes pasos y CTA
Después, avanza de una mejora en una mejora: base de datos, validación de esquema, documentación de API y autenticación. Para diseño de errores, revisa patrones de error handling con Claude Code.
Para practicar con tu propio proyecto, ten a mano la chuleta gratuita. Para prompts reutilizables, checklists de revisión y plantillas CLAUDE.md, usa la biblioteca de productos Claude Code. Para onboarding de equipos, reglas de revisión de API y diseño de permisos, entra en formación y consultoría de Claude Code.
Al probar el contenido de este artículo en la práctica, el mayor avance para principiantes no fue el CRUD en sí, sino ver éxito y error lado a lado: title vacío devuelve 400, un ID desconocido devuelve 404 y repetir el mismo PUT conserva el estado final. Así REST deja de parecer una lista de URLs y se entiende como un contrato entre frontend y backend.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.