Créer sa première REST API avec Claude Code: CRUD, validation et tests
Créez une REST API Express avec Claude Code: CRUD, validation, erreurs, tests et code exécutable.
Quand on crée sa première REST API, le plus difficile n’est pas d’écrire des routes Express. Le vrai problème est de savoir ce qu’une API utilisable doit contenir: des URL claires, les bonnes méthodes HTTP, une validation fiable, des erreurs prévisibles et quelques tests qui prouvent que le code fonctionne.
Dans ce guide, vous allez construire avec Claude Code une petite API Todo en Express. Vous créerez le projet, ajouterez des endpoints CRUD, validerez l’entrée JSON, renverrez des codes de statut utiles et vérifierez le tout avec curl et le test runner intégré de Node.js. Le code vise Node.js 22/24 LTS ou une version plus récente.
Si Claude Code est encore nouveau pour vous, commencez par le guide de démarrage Claude Code. Pour approfondir les tests, lisez ensuite Claude Code API testing et la validation Zod avec Claude Code.
Les notions à connaître
Une REST API est une façon de manipuler des ressources via HTTP. Ici, la ressource est un todo. En termes simples, une autre application appelle une URL, envoie ou reçoit du JSON, puis lit le code de statut pour comprendre le résultat.
| Terme | Sens simple | Exemple |
|---|---|---|
| Endpoint | URL d’entrée de l’API | GET /todos |
| Méthode | Verbe HTTP qui décrit l’action | GET, POST, PUT, DELETE |
| Code de statut | Nombre qui indique le résultat | 200, 201, 400, 404 |
| JSON | Format texte léger pour échanger des données | { "title": "Learn REST" } |
| Validation | Vérification de la validité d’une entrée | Refuser un title vide |
| Idempotence | La même requête répétée garde le même état final | Envoyer le même PUT deux fois |
Les références officielles utiles sont les méthodes HTTP sur MDN, les codes de statut HTTP sur MDN, Express routing, Express error handling et le Node.js test runner.
Ce que nous allons construire
L’API stocke les Todos en mémoire. Après un redémarrage, elle revient à l’état initial. C’est volontaire: on apprend la forme REST avant d’ajouter une base de données.
| Action | Méthode et URL | Statut de succès |
|---|---|---|
| Santé | GET /health | 200 OK |
| Lister les Todos | GET /todos | 200 OK |
| Lire un Todo | GET /todos/:id | 200 OK |
| Créer un Todo | POST /todos | 201 Created |
| Remplacer un Todo | PUT /todos/:id | 200 OK |
| Supprimer un Todo | DELETE /todos/:id | 204 No Content |
Ce petit exemple correspond à des cas réels. Premier cas: une API interne de tâches, tickets ou demandes de revue. Deuxième cas: un backend mock pour développer une interface React, Vue ou Astro avant que le vrai backend soit prêt. Troisième cas: une petite API de contact ou de newsletter, en remplaçant title par email et message.
Donner des critères clairs à Claude Code
Indiquez la stack, les endpoints, le comportement d’erreur et la vérification. Un prompt vague comme “crée une API” produit souvent du code qui démarre, mais dont le design reste difficile à relire.
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 ligne sur l’idempotence compte. PUT représente un remplacement. Si le même corps est envoyé deux fois, le Todo doit finir dans le même état. Si updatedAt change à chaque appel identique, le code contredit l’explication REST.
Créer le projet
mkdir claude-rest-api
cd claude-rest-api
npm init -y
npm install express
Remplacez 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}`);
});
}
Le point essentiel est la forme stable des réponses: { data: ... } pour les succès, { error: ... } pour les échecs, et un code de statut adapté à chaque situation.
Tester avec 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
Regardez les succès et les erreurs. Une API qui ne fonctionne que dans le cas idéal n’est pas prête pour une interface réelle.
Ajouter node:test
Créez 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
Ces tests sont modestes, mais ils donnent une base. Quand Claude Code ajoutera une base de données ou l’authentification, demandez-lui de garder cette suite au vert.
Pièges fréquents
N’utilisez pas des endpoints comme /getTodos. Laissez la méthode porter le verbe: GET /todos, POST /todos, PUT /todos/:id.
Ne renvoyez pas toujours 200. Une mauvaise entrée mérite 400, une ressource absente 404, une création 201, une suppression sans corps 204.
Ne faites pas confiance uniquement à la validation côté frontend. L’API peut être appelée directement, donc le serveur doit valider aussi.
N’exposez pas les stack traces aux clients. Gardez les détails dans les logs serveur et renvoyez un JSON contrôlé.
Ne cassez pas l’idempotence. Si un PUT identique modifie des timestamps ou des valeurs aléatoires à chaque appel, l’état final n’est plus stable.
Suite et CTA
Ajoutez ensuite une seule chose à la fois: base de données, validation de schéma, documentation API, puis authentification. Pour la conception des erreurs, consultez aussi Claude Code error handling patterns.
Pour pratiquer sur votre propre projet, gardez la fiche mémo gratuite sous la main. Pour des prompts réutilisables, des checklists de revue et des modèles CLAUDE.md, utilisez la bibliothèque de produits Claude Code. Pour l’onboarding d’équipe, les règles de revue d’API et le design des permissions, passez par la formation et le conseil Claude Code.
Après avoir testé le contenu de cet article en pratique, le déclic principal pour les débutants n’a pas été le CRUD lui-même. C’était de voir succès et erreurs côte à côte: un title vide renvoie 400, un ID inconnu renvoie 404, et le même PUT répété garde le même état final. REST devient alors un contrat entre frontend et backend, pas seulement une liste d’URL.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Échelle de sécurité des permissions Claude Code
Passer du read-only aux éditions limitées, preuves et checks de déploiement sans perdre le contrôle.
Claude Code Small PR Proof Pack : rendre les petits changements reviewables
Un pack de preuve pour PR Claude Code : diff, vérifications, URL publique, CTA et rollback.
Gate de review avant commit avec Claude Code
Review avant commit avec Claude Code : diff, build, URL publique, liens Gumroad, CTA consultation, tests manquants et fichiers hors scope.