Ton serveur sait lire des tâches. Dans ce tuto, il apprend à créer, modifier et supprimer, à refuser les données bancales et à répondre proprement quand ça casse. À la fin, t'as une vraie API, celle que ta to-do list appellera à l'étape 5.
Durée 1 à 2 semainesNiveau débutantProjet l'API complète de ta to-doMis à jour
À la fin, tu sauras
Comprendre REST : des ressources, des verbes, des codes de statut
Tester n'importe quelle requête avec REST Client ou curl
Créer, modifier et supprimer des données avec POST, PATCH et DELETE
Valider ce que le client envoie et répondre 400 quand c'est faux
Ranger tes routes dans un Router et gérer les erreurs en un seul endroit
Les missions
Tu repars du projet api-taches de l'étape 2. Garde un terminal avec npm run dev ouvert tout le long, et teste chaque route dès qu'elle est écrite.
Mission 0
À l'étape 2, ton API savait lire. Pour une vraie to-do, il faut aussi créer, modifier et supprimer. Il existe une façon de ranger tout ça que presque toutes les API du web suivent : REST.
Une ressource, c'est une chose que ton API gère. Ici : les tâches. Elle a une adresse : /api/tasks pour la liste, /api/tasks/1759755600000 pour une tâche précise.
Un verbe, c'est la méthode HTTP. Elle dit ce que tu veux faire sur la ressource : GET lire, POST créer, PATCH modifier une partie, DELETE supprimer.
L'adresse dit quoi, le verbe dit quoi faire. Pas besoin d'adresses du genre /api/supprimer-tache?id=3. Voici le contrat de ton API à la fin de ce tuto :
Le contrat de l'API
Verbe URL Action Réponse si tout va bien
GET /api/tasks lister les tâches 200 + le tableau
GET /api/tasks/:id lire une tâche 200 + la tâche
POST /api/tasks créer une tâche 201 + la tâche créée
PATCH /api/tasks/:id modifier une tâche 200 + la tâche modifiée
DELETE /api/tasks/:id supprimer une tâche 204, sans corps
Deux adresses seulement, cinq actions. Le verbe fait la différence.
201 Created : « c'est créé ». 204 No Content : « c'est fait, rien à te renvoyer ».
Les noms sont ceux de ta to-do du frontend : id, text, done. À l'étape 5, le front et le back se parleront sans traduction.
Lance ton serveur (npm run dev), puis dans un deuxième terminal, essaie de supprimer une tâche qui existe dans ton tasks.json :
Confondre PATCH et PUT. PUT remplace la ressource entière : oublie text et la tâche le perd. PATCH ne change que ce que tu envoies. Pour cocher une case, c'est PATCH.
Demande à l'IA
Explique-moi ce qu'est une API REST avec une analogie de la vie de tous les jours. Pourquoi on met l'action dans la méthode HTTP (GET, POST, PATCH, DELETE) plutôt que dans l'URL ? N'écris pas de code.
Résultat attendu
Terminal
$ curl -i -X DELETE http://localhost:3000/api/tasks/1759755600000
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 29
{"error":"Route introuvable"}
La tâche existe, mais la route DELETE n'existe pas encore : ta 404 finale de l'étape 2 répond. Le verbe fait partie de l'adresse. À la fin de ce tuto, la même commande renverra 204.
Mission 1
Le navigateur ne sait envoyer que des GET quand tu tapes une adresse. Pour tester un POST ou un DELETE, il faut un outil. Le plus simple : une extension VS Code.
Dans VS Code, onglet Extensions, cherche REST Client (l'auteur est Huachao Mao) et installe-la.
À la racine de api-taches, crée un fichier requests.http.
Au-dessus de chaque requête apparaît un petit lien Send Request. Clique dessus, serveur lancé.
requests.http
@base = http://localhost:3000
### Toutes les tâches
GET {{base}}/api/tasks
### Seulement les tâches faites
GET {{base}}/api/tasks?done=true
### Une seule tâche (mets un id qui existe dans ton tasks.json)
GET {{base}}/api/tasks/1759755600000
@base = ... : une variable. {{base}} est remplacé par l'adresse partout. Le jour où ton API est en ligne, tu changes une seule ligne.
### sépare les requêtes. Le texte après sert de titre.
Une requête = le verbe, l'URL, puis (on le verra) les en-têtes et le corps.
Ce fichier, tu le gardes et tu le commites : c'est la liste de tout ce que ton API sait faire, prête à être rejouée.
Tu préfères le terminal ? curl marche aussi. -i affiche le statut et les en-têtes (sur Windows, tape curl.exe) :
« Connection refused » ou « ECONNREFUSED » : ton serveur ne tourne pas, ou sur un autre port. Garde un terminal avec npm run dev ouvert pendant tous tes tests, et vérifie le port affiché.
À toi
Ajoute deux requêtes : les tâches pas faites, et une tâche avec un id qui n'existe pas. Indice : la seconde doit répondre 404 avec le message de l'étape 2.
La réponse s'ouvre dans un onglet à droite : le statut, les en-têtes, puis le JSON bien rangé. Tes tâches et tes id sont ceux de ton tasks.json.
Mission 2
Pour créer, le client envoie la tâche dans le corps de la requête, en JSON. Par défaut, Express ne lit pas ce corps : il faut lui ajouter le middleware express.json(). Voici ton server.js de l'étape 2, complet, avec les deux ajouts (le middleware et la route POST) :
server.js
import express from "express";
import { loadTasks, saveTasks } from "./storage.js";
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware 1 : affiche chaque requête dans le terminal
app.use((req, res, next) => {
const heure = new Date().toLocaleTimeString("fr-FR");
console.log(`[${heure}] ${req.method} ${req.url}`);
next();
});
// Middleware 2 : lit le corps JSON des requêtes et le range dans req.body
app.use(express.json());
// Middleware 3 : sert les fichiers du dossier public
app.use(express.static("public"));
// Toutes les tâches, ou seulement les faites / à faire avec ?done=
app.get("/api/tasks", (req, res) => {
let tasks = loadTasks();
if (req.query.done === "true") {
tasks = tasks.filter((task) => task.done);
} else if (req.query.done === "false") {
tasks = tasks.filter((task) => !task.done);
}
res.json(tasks);
});
// Une seule tâche, par son id
app.get("/api/tasks/:id", (req, res) => {
const id = Number(req.params.id);
const task = loadTasks().find((t) => t.id === id);
if (!task) {
return res.status(404).json({ error: "Tâche introuvable" });
}
res.json(task);
});
// Renvoie un message d'erreur si le texte n'est pas bon, sinon null
function checkText(text) {
if (typeof text !== "string" || text.trim() === "") {
return "Le texte est obligatoire";
}
if (text.trim().length > 200) {
return "200 caractères maximum";
}
return null;
}
// Créer une tâche
app.post("/api/tasks", (req, res) => {
const text = req.body?.text;
const error = checkText(text);
if (error) {
return res.status(400).json({ error });
}
const tasks = loadTasks();
const task = { id: Date.now(), text: text.trim(), done: false };
tasks.push(task);
saveTasks(tasks);
res.status(201).json(task);
});
// Aucune route n'a répondu : 404 en JSON (toujours en dernier)
app.use((req, res) => {
res.status(404).json({ error: "Route introuvable" });
});
app.listen(PORT, (error) => {
if (error) throw error;
console.log(`Serveur lancé sur http://localhost:${PORT}`);
});
app.use(express.json()) : lit le corps JSON et le range dans req.body. Il doit être avant les routes.
req.body?.text : le ?. évite de planter si req.body est vide (undefined) : tu obtiens undefined au lieu d'une erreur.
checkText : on ne fait jamais confiance au client. Le texte doit être une chaîne (typeof), pas vide une fois les espaces enlevés (trim), et 200 caractères au plus.
Si c'est mauvais : 400 avec { error }, qui veut dire { error: error }. Le return arrête la fonction : rien n'est sauvegardé.
Si c'est bon : c'est le serveur qui choisit l'id et done: false, pas le client. Puis saveTasks, et 201 avec la tâche créée, pour que le client connaisse son id.
Ajoute ces requêtes à la fin de requests.http. Attention à la ligne vide entre les en-têtes et le corps, elle est obligatoire :
requests.http
### Créer une tâche
POST {{base}}/api/tasks
Content-Type: application/json
{
"text": "Tester mon API"
}
### Erreur : texte vide
POST {{base}}/api/tasks
Content-Type: application/json
{
"text": " "
}
### Erreur : pas de texte du tout
POST {{base}}/api/tasks
Content-Type: application/json
{
"done": true
}
Erreur fréquente
Toujours « Le texte est obligatoire », même avec un texte ? Il manque l'en-tête Content-Type: application/json dans ta requête (ou la ligne vide avant le corps) : express.json() ne lit que le JSON annoncé comme tel, et req.body reste vide. Autre coupable : app.use(express.json()) placé après tes routes.
Demande à l'IA
Pourquoi faut-il valider les données côté serveur dans une API, même si mon formulaire HTML vérifie déjà que le champ n'est pas vide ? Donne-moi un exemple concret de ce qui peut arriver sinon. N'écris pas de code.
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
...
{
"error": "Le texte est obligatoire"
}
Ouvre tasks.json : la nouvelle tâche est à la fin. Les deux requêtes en erreur n'ont rien ajouté. Dans le terminal du serveur, une ligne POST /api/tasks par envoi.
Mission 3
Cocher une tâche, corriger son texte : c'est une modification partielle, donc PATCH. Le client envoie seulement ce qui change : text, done, ou les deux. Ajoute cette route sous le POST, avant la 404 finale :
On cherche d'abord la tâche : si elle n'existe pas, 404. Inutile de valider le reste.
const { text, done } = req.body ?? {} : sort les deux champs du corps. ?? {} : si le corps est vide, on prend un objet vide à la place.
undefined veut dire « pas envoyé ». On ne valide et on ne modifie que ce qui a été envoyé. Rien du tout ? 400.
checkText resert : même règle qu'à la création. done doit être un vrai booléen (typeof done !== "boolean").
task pointe vers l'objet dans le tableau tasks : le modifier modifie le tableau, qu'on sauvegarde.
requests.http
### Cocher une tâche
PATCH {{base}}/api/tasks/1759755660000
Content-Type: application/json
{
"done": true
}
### Renommer une tâche
PATCH {{base}}/api/tasks/1759755660000
Content-Type: application/json
{
"text": "Écrire mon serveur Express"
}
### Erreur : done n'est pas un booléen
PATCH {{base}}/api/tasks/1759755660000
Content-Type: application/json
{
"done": "oui"
}
Erreur fréquente
Envoyer "done": "true" (avec guillemets) au lieu de "done": true. Le premier est du texte, et le texte "false" est considéré comme vrai par JavaScript. C'est exactement pour ça qu'on vérifie le type : ton API répond 400 au lieu d'enregistrer n'importe quoi.
Résultat attendu
Réponse : cocher
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
{
"id": 1759755660000,
"text": "Écrire mon premier serveur",
"done": true
}
Réponse : done à "oui"
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
...
{
"error": "done doit valoir true ou false"
}
Après « Renommer », la tâche garde done: true : PATCH ne touche qu'à ce que tu envoies. Un id inconnu renvoie 404 avec Tâche introuvable.
Mission 4
La plus courte. Ajoute-la sous le PATCH, toujours avant la 404 :
findIndex : la position de la tâche dans le tableau, ou -1 si elle n'y est pas.
splice(index, 1) : retire 1 élément à cette position.
res.status(204).end() : « c'est fait », sans corps. end() termine la réponse sans rien envoyer.
requests.http
### Supprimer une tâche
DELETE {{base}}/api/tasks/1759755600000
Envoie-la deux fois de suite et compare les réponses.
Astuce
Recopier les id à la main, c'est pénible. REST Client sait réutiliser la réponse d'une requête nommée avec # @name. Lance le POST, puis le DELETE :
requests.http
# @name nouvelle
POST {{base}}/api/tasks
Content-Type: application/json
{
"text": "Tâche à supprimer"
}
### Supprime la tâche créée juste au-dessus
DELETE {{base}}/api/tasks/{{nouvelle.response.body.$.id}}
Erreur fréquente
Faire res.status(204).json(...) en pensant renvoyer un message : avec 204, le corps est toujours jeté, le client ne reçoit rien. Si tu veux renvoyer quelque chose, c'est 200. Et pas de .end() du tout ? La requête tourne dans le vide jusqu'à expirer.
Résultat attendu
Réponse : premier envoi
HTTP/1.1 204 No Content
X-Powered-By: Express
...
Réponse : deuxième envoi
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
...
{
"error": "Tâche introuvable"
}
Le premier envoi supprime, sans corps de réponse. Le deuxième ne trouve plus rien. Relance la commande curl de la mission 0 avec un id qui existe : elle répond 204 maintenant.
Mission 5
Ton server.js grossit. À l'étape 6, tu ajouteras les routes des comptes : tout dans un fichier, ça devient illisible. Express a un outil pour ranger : express.Router(), un mini-serveur qui ne contient que des routes. Crée un dossier routes et dedans tasks.js :
routes/tasks.js
import express from "express";
import { loadTasks, saveTasks } from "../storage.js";
const router = express.Router();
// Renvoie un message d'erreur si le texte n'est pas bon, sinon null
function checkText(text) {
if (typeof text !== "string" || text.trim() === "") {
return "Le texte est obligatoire";
}
if (text.trim().length > 200) {
return "200 caractères maximum";
}
return null;
}
// GET /api/tasks (et /api/tasks?done=true)
router.get("/", (req, res) => {
let tasks = loadTasks();
if (req.query.done === "true") {
tasks = tasks.filter((task) => task.done);
} else if (req.query.done === "false") {
tasks = tasks.filter((task) => !task.done);
}
res.json(tasks);
});
// GET /api/tasks/:id
router.get("/:id", (req, res) => {
const id = Number(req.params.id);
const task = loadTasks().find((t) => t.id === id);
if (!task) {
return res.status(404).json({ error: "Tâche introuvable" });
}
res.json(task);
});
// POST /api/tasks
router.post("/", (req, res) => {
const text = req.body?.text;
const error = checkText(text);
if (error) {
return res.status(400).json({ error });
}
const tasks = loadTasks();
const task = { id: Date.now(), text: text.trim(), done: false };
tasks.push(task);
saveTasks(tasks);
res.status(201).json(task);
});
// PATCH /api/tasks/:id
router.patch("/:id", (req, res) => {
const tasks = loadTasks();
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return res.status(404).json({ error: "Tâche introuvable" });
}
const { text, done } = req.body ?? {};
if (text === undefined && done === undefined) {
return res.status(400).json({ error: "Rien à modifier : envoie text et/ou done" });
}
if (text !== undefined) {
const error = checkText(text);
if (error) {
return res.status(400).json({ error });
}
}
if (done !== undefined && typeof done !== "boolean") {
return res.status(400).json({ error: "done doit valoir true ou false" });
}
if (text !== undefined) task.text = text.trim();
if (done !== undefined) task.done = done;
saveTasks(tasks);
res.json(task);
});
// DELETE /api/tasks/:id
router.delete("/:id", (req, res) => {
const tasks = loadTasks();
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return res.status(404).json({ error: "Tâche introuvable" });
}
tasks.splice(index, 1);
saveTasks(tasks);
res.status(204).end();
});
export default router;
Puis allège server.js : il ne garde que les middlewares, le montage et la 404.
server.js
import express from "express";
import tasksRouter from "./routes/tasks.js";
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware 1 : affiche chaque requête dans le terminal
app.use((req, res, next) => {
const heure = new Date().toLocaleTimeString("fr-FR");
console.log(`[${heure}] ${req.method} ${req.url}`);
next();
});
// Middleware 2 : lit le corps JSON des requêtes et le range dans req.body
app.use(express.json());
// Middleware 3 : sert les fichiers du dossier public
app.use(express.static("public"));
// Toutes les routes des tâches, rangées dans routes/tasks.js
app.use("/api/tasks", tasksRouter);
// Aucune route n'a répondu : 404 en JSON (toujours en dernier)
app.use((req, res) => {
res.status(404).json({ error: "Route introuvable" });
});
app.listen(PORT, (error) => {
if (error) throw error;
console.log(`Serveur lancé sur http://localhost:${PORT}`);
});
app.use("/api/tasks", tasksRouter) : monte le router sur ce préfixe. Dans le router, "/" veut dire /api/tasks et "/:id" veut dire /api/tasks/:id.
"../storage.js" : deux points, car routes/tasks.js est un dossier plus bas que storage.js.
export default router : le fichier exporte une seule chose, importée sans accolades dans server.js.
Les routes sont les mêmes qu'avant, on a juste remplacé app. par router. et retiré le préfixe.
Ton projet
api-taches/
├── package.json
├── server.js # le serveur : middlewares, montage des routes, 404
├── routes/
│ └── tasks.js # tout ce qui commence par /api/tasks
├── storage.js # lire et écrire tasks.json (inchangé)
├── cli.js # l'outil de l'étape 1, toujours là
├── tasks.json
├── requests.http # tes requêtes de test
├── public/
│ └── index.html
└── .gitignore
Erreur fréquente
Garder le préfixe dans le router : router.get("/api/tasks", ...) répond en fait à /api/tasks/api/tasks, et toutes tes requêtes tombent sur « Route introuvable ». Autre classique : Cannot find module .../storage.js, c'est le ../ oublié.
À toi
Ajoute la recherche : GET /api/tasks?q=node ne renvoie que les tâches dont le texte contient « node », sans tenir compte des majuscules. Elle doit marcher avec ?done= en même temps. Indice : dans router.get("/"), un deuxième filter, toLowerCase() et includes().
Demande à l'IA
Explique-moi à quoi sert express.Router() dans Express et ce que fait app.use('/api/tasks', router). Comment Express décide quelle route répond à une requête ? N'écris pas mon code à ma place.
Résultat attendu
Terminal
$ npm run dev
Serveur lancé sur http://localhost:3000
[14:32:05] GET /api/tasks
[14:32:09] POST /api/tasks
[14:32:12] PATCH /api/tasks/1759755660000
[14:32:16] DELETE /api/tasks/1791441312047
[14:32:20] GET /api/n-importe-quoi
Relance toutes les requêtes de requests.http : mêmes réponses qu'avant le rangement. C'est le but : l'API n'a pas bougé, seul le code est mieux rangé. La dernière renvoie toujours 404Route introuvable.
Mission 6
Ton API répond en JSON… sauf quand ça casse. Ajoute cette requête avec une virgule en trop, et envoie-la :
requests.http
### Erreur : JSON invalide (la virgule en trop)
POST {{base}}/api/tasks
Content-Type: application/json
{
"text": "Oups",
}
Réponse avant la correction
HTTP/1.1 400 Bad Request
Content-Type: text/html; charset=utf-8
...
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>SyntaxError: Expected double-quoted property name in JSON at position 20 (line 3 column 1)<br> at JSON.parse (<anonymous>)<br> at parse (/Users/alex/api-taches/node_modules/body-parser/...
Une page HTML en anglais, avec le chemin de tes fichiers : illisible pour le front, et ça montre l'intérieur de ton serveur. On ajoute un middleware d'erreur. Voici le server.js final de l'étape :
server.js
import express from "express";
import tasksRouter from "./routes/tasks.js";
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware 1 : affiche chaque requête dans le terminal
app.use((req, res, next) => {
const heure = new Date().toLocaleTimeString("fr-FR");
console.log(`[${heure}] ${req.method} ${req.url}`);
next();
});
// Middleware 2 : lit le corps JSON des requêtes et le range dans req.body
app.use(express.json());
// Middleware 3 : sert les fichiers du dossier public
app.use(express.static("public"));
// Toutes les routes des tâches, rangées dans routes/tasks.js
app.use("/api/tasks", tasksRouter);
// Aucune route n'a répondu : 404 en JSON
app.use((req, res) => {
res.status(404).json({ error: "Route introuvable" });
});
// Middleware d'erreur : 4 paramètres, toujours en dernier
app.use((err, req, res, next) => {
if (err.type === "entity.parse.failed") {
return res.status(400).json({ error: "JSON invalide" });
}
console.error(err);
res.status(500).json({ error: "Erreur serveur" });
});
app.listen(PORT, (error) => {
if (error) throw error;
console.log(`Serveur lancé sur http://localhost:${PORT}`);
});
Un middleware avec 4 paramètres(err, req, res, next), Express le reconnaît comme gestionnaire d'erreurs. Il faut les 4, même si next ne sert pas.
Express l'appelle dès qu'une erreur est lancée dans une route ou un middleware. Il se place en dernier, après la 404.
err.type === "entity.parse.failed" : c'est express.json() qui n'a pas pu lire le JSON. C'est la faute du client : 400.
Tout le reste, c'est un bug chez toi : console.error(err) pour toi dans le terminal, 500 et un message neutre pour le client.
Pour tester la 500, casse exprès tasks.json (supprime la fin du fichier pour qu'il ressemble à ça), puis envoie GET {{base}}/api/tasks :
Remets ensuite le fichier en état (Ctrl + Z dans VS Code).
Erreur fréquente
Ton middleware d'erreur n'est jamais appelé : il lui manque un paramètre (avec 3, Express le prend pour un middleware normal), ou il est placé avantapp.use("/api/tasks", ...). L'ordre des app.use compte, comme l'ordre des lignes d'une recette.
À toi
Ajoute une route GET /api/stats qui renvoie { "total": 3, "done": 1, "todo": 2 } (le nombre de tâches, de faites, de pas faites). Indice : dans server.js, avant la 404, importe loadTasks, puis length et filter. Ajoute la requête à requests.http.
Demande à l'IA
Dans une API Express, quelle est la différence entre une erreur 400 et une erreur 500 ? Pourquoi on ne renvoie pas le détail de l'erreur (la stack trace) au client ? Explique-moi sans écrire mon code.
[14:40:51] GET /api/tasks
SyntaxError: Expected double-quoted property name in JSON at position 56 (line 3 column 1)
at JSON.parse (<anonymous>)
at loadTasks (file:///Users/alex/api-taches/storage.js:7:15)
at file:///Users/alex/api-taches/routes/tasks.js:19:15
...
Le visiteur reçoit un message propre, toi tu as le détail dans le terminal, avec le fichier et la ligne (storage.js:7). Le serveur ne s'arrête pas : répare tasks.json et la requête suivante repasse en 200.
Le projet
L'API complète de ta to-do
Termine l'API des tâches : les cinq actions du contrat, des réponses claires quand le client se trompe, et un fichier requests.http qui prouve que tout marche. Ensuite, fais un commit et pousse sur GitHub. Pour t'entraîner, réécris la route DELETE de mémoire, sans regarder la mission.
Ton projet est validé quand :
Bonus
Les id qui ne sont pas des nombres (/api/tasks/abc) tombent aujourd'hui en 404. Fais-leur répondre 400 « id invalide », à un seul endroit pour toutes les routes. Indice : cherche router.param dans la doc d'Express et Number.isInteger.
Pour aller plus loin
Les méthodes HTTP sur MDN, en français : GET, POST, PATCH, DELETE et les autres, expliquées une par une.
Si ton fichier requests.http passe en entier sans surprise, t'as écrit ta première vraie API. Il lui manque une chose : un fichier JSON, ça casse vite dès que deux personnes écrivent en même temps. Prochaine étape : une vraie base de données, avec SQL.