Chapitre 523 minIntermédiaire
Créer une API REST CRUD avec Node.js et Express
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- traduire le tableau des opérations d'une API en routes Express ;
- écrire les cinq opérations CRUD avec les bons codes de statut ;
- coder une règle métier comme une transition d'état ;
- organiser le code en routes, contrôleurs, services et dépôts.
4 exercices corrigés2 schémas
Créer une API REST avec Node.js et Express, c'est traduire le contrat de l'API (ses ressources, ses opérations, ses codes de statut) en routes qui lisent, créent, modifient et suppriment des données : le CRUD (Create, Read, Update, Delete). Ce chapitre construit le CRUD des fiches de frais d'AppliFrais, d'abord avec des données en mémoire pour se concentrer sur HTTP, en organisant le code en couches (routes, contrôleurs, services, dépôts) pour que le chapitre suivant puisse brancher MySQL sans rien casser.
Du tableau des opérations aux routes
Une API se conçoit avant de se coder. Le cours API REST a produit le tableau des opérations d'AppliFrais ; chaque ligne devient une route Express.
| Opération | Méthode et URI | Rôle | Succès | Refus métier |
|---|---|---|---|---|
| Lister des fiches | GET /v1/fiches?mois=&etat=&page=&limite= | visiteur (les siennes), comptable | 200 | — |
| Créer la fiche du mois | POST /v1/fiches | visiteur | 201 + Location | 409 si elle existe ; 422 |
| Lire une fiche | GET /v1/fiches/{id} | propriétaire ou comptable | 200 | 404 |
| Supprimer une fiche en cours | DELETE /v1/fiches/{id} | propriétaire | 204 | 409 si elle n'est plus en cours |
| Saisir les frais forfaitisés | PUT /v1/fiches/{id}/lignes-forfait | propriétaire | 200 | 409 ; 422 |
| Ajouter un frais hors forfait | POST /v1/fiches/{id}/frais-hors-forfait | propriétaire | 201 | 409 ; 422 |
| Supprimer un frais hors forfait | DELETE /v1/frais-hors-forfait/{id} | propriétaire | 204 | 409 |
| Clôturer ou mettre en paiement | PATCH /v1/fiches/{id} avec {"etat":"CL"} ou {"etat":"MP"} | propriétaire (CL), comptable (MP) | 200 | 409 |
| Valider une fiche | POST /v1/fiches/{id}/validations | comptable | 201 | 409 ; 422 |
Le CRUD se lit dans les méthodes : Create = POST, Read = GET, Update = PUT (remplacer) ou PATCH (modifier une partie), Delete = DELETE. Côté base, ce seront INSERT, SELECT, UPDATE et DELETE (chapitre 6). La colonne « Rôle » sera codée au chapitre 9 ; ce chapitre s'occupe du reste.
Organiser le code en couches
Tout écrire dans les routes fonctionne pour trois routes, pas pour quinze. On sépare le code en couches, chacune avec une seule responsabilité. C'est l'application à une API du principe du modèle MVC, détaillé dans le cours Architecture MVC (MVC avec Node.js et Express) : ici, il n'y a pas de vue, la « vue » est la réponse JSON.
| Couche | Dossier | Elle sait… | Elle ignore… |
|---|---|---|---|
| Routes | src/routes/ | Quelle méthode et quelle adresse appellent quel contrôleur, avec quels middlewares | Ce que fait le contrôleur |
| Contrôleurs | src/controllers/ | Lire req, choisir le code de statut, envoyer la réponse | Comment les données sont stockées |
| Services | src/services/ | Les règles de gestion : qui peut faire quoi, dans quel état, les calculs | HTTP et SQL |
| Dépôts | src/depots/ | Lire et écrire dans la base (le patron DAO, Data Access Object) | Les règles métier et HTTP |
Les bénéfices sont concrets : le chapitre 6 remplace les données en mémoire par MySQL en ne touchant que le dépôt ; le chapitre 7 remplace mysql2 par Prisma au même endroit ; les règles de gestion du service se testent sans serveur ni base (chapitre 12). Et pour une évolution demandée à l'E6, tu sais immédiatement dans quel fichier intervenir.
Le dépôt : des données en mémoire
Pour l'instant, les fiches vivent dans un tableau. Elles disparaissent à chaque redémarrage : c'est voulu, la base arrive au chapitre suivant.
// src/depots/fiches.memoire.js
const fiches = [
{ id: 11, visiteurId: 7, mois: '2026-09', etat: 'VA', montantValide: 389.2 },
{ id: 12, visiteurId: 7, mois: '2026-10', etat: 'CR', montantValide: null },
{ id: 13, visiteurId: 8, mois: '2026-10', etat: 'CL', montantValide: null },
];
let prochainId = 14;
export function lister({ visiteurId, etat }) {
return fiches.filter((f) => (!visiteurId || f.visiteurId === visiteurId) && (!etat || f.etat === etat));
}
export function trouverParId(id) {
return fiches.find((f) => f.id === id) ?? null;
}
export function existe(visiteurId, mois) {
return fiches.some((f) => f.visiteurId === visiteurId && f.mois === mois);
}
export function creer(visiteurId, mois) {
const fiche = { id: prochainId++, visiteurId, mois, etat: 'CR', montantValide: null };
fiches.push(fiche);
return fiche;
}
export function changerEtat(id, etat) {
const fiche = trouverParId(id);
fiche.etat = etat;
return fiche;
}
export function supprimer(id) {
fiches.splice(fiches.findIndex((f) => f.id === id), 1);
}
Le tableau fiches n'est pas exporté : seules les fonctions le sont. Le reste de l'application ne peut pas modifier les données sans passer par le dépôt.
Le contrôleur : lire la requête, choisir la réponse
Dans cette première version, le contrôleur porte encore les vérifications et la règle des états ; la fin du chapitre les déplace dans un service.
// src/controllers/fiches.controller.js (version en mémoire)
import * as depot from '../depots/fiches.memoire.js';
import { ErreurApi } from '../erreurs.js';
const FORMAT_MOIS = /^\d{4}-(0[1-9]|1[0-2])$/;
const ETATS = ['CR', 'CL', 'VA', 'MP'];
const TRANSITIONS = { CR: ['CL'], CL: ['VA'], VA: ['MP'], MP: [] };
function idDepuis(req) {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
throw new ErreurApi(404, 'fiche-introuvable', 'Fiche introuvable', `La fiche ${req.params.id} n'existe pas.`);
}
return id;
}
function ficheOu404(id) {
const fiche = depot.trouverParId(id);
if (!fiche) throw new ErreurApi(404, 'fiche-introuvable', 'Fiche introuvable', `La fiche ${id} n'existe pas.`);
return fiche;
}
// GET /v1/fiches?etat=CL&visiteur=7
export function lister(req, res) {
const { etat, visiteur } = req.query;
if (etat !== undefined && !ETATS.includes(etat)) {
throw new ErreurApi(422, 'validation', 'Données invalides', `État inconnu : ${etat}.`);
}
res.json(depot.lister({ etat, visiteurId: visiteur ? Number(visiteur) : undefined }));
}
// GET /v1/fiches/12
export function lire(req, res) {
res.json(ficheOu404(idDepuis(req)));
}
// POST /v1/fiches {"visiteurId": 7, "mois": "2026-11"}
export function creer(req, res) {
const { visiteurId, mois } = req.body ?? {};
if (!Number.isInteger(visiteurId) || typeof mois !== 'string' || !FORMAT_MOIS.test(mois)) {
throw new ErreurApi(422, 'validation', 'Données invalides', 'visiteurId (entier) et mois (AAAA-MM) sont obligatoires.');
}
if (depot.existe(visiteurId, mois)) {
throw new ErreurApi(409, 'fiche-existante', 'Fiche déjà créée', `Une fiche existe déjà pour le mois ${mois}.`);
}
const fiche = depot.creer(visiteurId, mois);
res.status(201).location(`/v1/fiches/${fiche.id}`).json(fiche);
}
// PATCH /v1/fiches/12 {"etat": "CL"}
export function changerEtat(req, res) {
const fiche = ficheOu404(idDepuis(req));
const etat = req.body?.etat;
if (!TRANSITIONS[fiche.etat].includes(etat)) {
throw new ErreurApi(409, 'transition-interdite', "Changement d'état interdit",
`Une fiche à l'état ${fiche.etat} ne peut pas passer à l'état ${etat}.`);
}
res.json(depot.changerEtat(fiche.id, etat));
}
// DELETE /v1/fiches/12
export function supprimer(req, res) {
const fiche = ficheOu404(idDepuis(req));
if (fiche.etat !== 'CR') {
throw new ErreurApi(409, 'fiche-cloturee', 'Fiche non modifiable', 'Seule une fiche en cours se supprime.');
}
depot.supprimer(fiche.id);
res.sendStatus(204);
}
Le visiteurId envoyé dans le corps est provisoire : dès le chapitre 9, il viendra du jeton de l'utilisateur connecté, jamais du client. Sinon n'importe qui créerait des fiches au nom d'un autre.
Les routes et l'application
// src/routes/fiches.routes.js
import { Router } from 'express';
import * as fiches from '../controllers/fiches.controller.js';
export const routeurFiches = Router();
routeurFiches.get('/', fiches.lister);
routeurFiches.post('/', fiches.creer);
routeurFiches.get('/:id', fiches.lire);
routeurFiches.patch('/:id', fiches.changerEtat);
routeurFiches.delete('/:id', fiches.supprimer);
// src/app.js
import express from 'express';
import { gestionnaireErreurs, routeInconnue } from './erreurs.js';
import { routeurFiches } from './routes/fiches.routes.js';
export const app = express();
app.use(express.json());
app.use('/v1/fiches', routeurFiches);
app.use(routeInconnue);
app.use(gestionnaireErreurs);
Tester le CRUD
Les réponses ci-dessous sont celles de l'API, dans l'ordre (les en-têtes sont abrégés) :
curl -s 'http://localhost:3000/v1/fiches?etat=CL'
[{"id":13,"visiteurId":8,"mois":"2026-10","etat":"CL","montantValide":null}]
curl -i -X POST http://localhost:3000/v1/fiches -H 'Content-Type: application/json' -d '{"visiteurId": 7, "mois": "2026-11"}'
HTTP/1.1 201 Created
Location: /v1/fiches/14
Content-Type: application/json; charset=utf-8
{"id":14,"visiteurId":7,"mois":"2026-11","etat":"CR","montantValide":null}
La même commande une seconde fois :
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
{"type":"https://api.gsb.example/problemes/fiche-existante","title":"Fiche déjà créée","status":409,"detail":"Une fiche existe déjà pour le mois 2026-11.","instance":"/v1/fiches"}
curl -i -X PATCH http://localhost:3000/v1/fiches/14 -H 'Content-Type: application/json' -d '{"etat": "VA"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
{"type":"https://api.gsb.example/problemes/transition-interdite","title":"Changement d'état interdit","status":409,"detail":"Une fiche à l'état CR ne peut pas passer à l'état VA.","instance":"/v1/fiches/14"}
curl -i -X DELETE http://localhost:3000/v1/fiches/14
HTTP/1.1 204 No Content
Teste aussi les cas d'erreur : GET /v1/fiches/99 (404), GET /v1/fiches?etat=XX (422), POST avec "visiteurId": "7" en chaîne (422). Un cas d'erreur non testé est un cas d'erreur qui répondra 500 devant le jury.
Une règle métier : le cycle de vie d'une fiche
Une fiche suit un cycle de vie précis : elle naît en cours (CR), le visiteur la clôture (CL), un comptable la valide (VA), puis la met en paiement (MP). Aucun autre passage n'est permis.
En code, ce diagramme tient dans un objet : pour chaque état, la liste des états suivants autorisés.
const TRANSITIONS = { CR: ['CL'], CL: ['VA'], VA: ['MP'], MP: [] };
Vérifier une transition, c'est chercher le nouvel état dans la liste de l'état actuel. Ajouter un état « remboursée » plus tard, c'est ajouter une ligne, pas réécrire des if imbriqués. Le refus répond 409 Conflict : la requête est bien formée, mais elle est incompatible avec l'état actuel de la ressource. Ce n'est ni 400 (la requête n'est pas malformée) ni 403 (ce n'est pas une question de droits).
Les codes de statut de l'API
| Situation | Code | Corps |
|---|---|---|
| Lecture réussie, liste vide comprise | 200 OK | La ressource ou la liste ([] si vide, jamais 404) |
| Création réussie | 201 Created | La ressource créée, avec l'en-tête Location |
| Suppression réussie | 204 No Content | Aucun |
| JSON illisible | 400 Bad Request | Problème |
Données lisibles mais invalides (mois 2026-13, montant négatif) | 422 Unprocessable Content | Problème avec la liste des champs |
| Ressource inexistante | 404 Not Found | Problème |
| Incompatible avec l'état actuel (doublon, transition interdite) | 409 Conflict | Problème |
| Erreur imprévue côté serveur | 500 Internal Server Error | Problème générique |
Node.js écrit 422 Unprocessable Entity dans la ligne de statut, l'ancien libellé ; la RFC 9110 l'a renommé « Unprocessable Content ». Seul le nombre compte pour le client. Le choix entre 400 et 422 est discuté dans le cours API REST : retiens que 422 signale des données comprises mais refusées par les règles.
Paginer une liste
Une liste ne se renvoie jamais en entier : avec des milliers de fiches, la réponse serait lourde et lente. L'API d'AppliFrais pagine par décalage, avec page et limite, et renvoie la page demandée avec des informations de pagination :
function paginer(elements, page, limite) {
const total = elements.length;
const debut = (page - 1) * limite;
return {
donnees: elements.slice(debut, debut + limite),
pagination: { page, limite, total, pages: Math.ceil(total / limite) },
};
}
Avec 47 fiches et 20 par page, la page 3 contient les fiches 41 à 47 et pages vaut 3. Une page au-delà de la dernière renvoie 200 avec une liste vide. Le serveur plafonne limite (100 au plus, 20 par défaut) : sans plafond, ?limite=1000000 viderait la table en une requête. En base, ce découpage devient LIMIT … OFFSET … (chapitre 6). Les autres techniques, comme la pagination par curseur, sont comparées dans le cours API REST.
Extraire les règles dans un service
Dernière étape : sortir les règles de gestion du contrôleur. Le service contient les règles, sans rien savoir de req, de res ni de SQL ; le contrôleur ne fait plus que traduire entre HTTP et le service.
// src/services/fiches.service.js (extrait de la version finale)
import * as depot from '../depots/fiches.depot.js';
import { ErreurApi } from '../erreurs.js';
const TRANSITIONS = { CR: ['CL'], CL: ['VA'], VA: ['MP'], MP: [] };
export function verifierTransition(etatActuel, nouvelEtat) {
if (!TRANSITIONS[etatActuel]?.includes(nouvelEtat)) {
throw new ErreurApi(409, 'transition-interdite', "Changement d'état interdit",
`Une fiche à l'état ${etatActuel} ne peut pas passer à l'état ${nouvelEtat}.`, { etatActuel });
}
}
// Calcul en centimes : en virgule flottante, 57 × 0.62 donne 35.339999999999996.
export function calculerTotal(lignesForfait, fraisHorsForfait) {
const centimes = (montant) => Math.round(montant * 100);
const forfait = lignesForfait.reduce((somme, l) => somme + l.quantite * centimes(l.montantUnitaire), 0);
const horsForfait = fraisHorsForfait.reduce((somme, f) => somme + centimes(f.montant), 0);
return (forfait + horsForfait) / 100;
}
// src/controllers/fiches.controller.js (version finale)
import * as service from '../services/fiches.service.js';
export async function lire(req, res) {
res.json(await service.lireFiche(req.utilisateur, req.valide.params.id));
}
export async function creer(req, res) {
const fiche = await service.creerFiche(req.utilisateur, req.valide.body.mois);
res.status(201).location(`/v1/fiches/${fiche.id}`).json(fiche);
}
export async function changerEtat(req, res) {
res.json(await service.changerEtat(req.utilisateur, req.valide.params.id, req.valide.body.etat));
}
Les fonctions deviennent async parce que le dépôt MySQL le sera. req.utilisateur (l'utilisateur connecté, chapitre 9) et req.valide (les données vérifiées, chapitre 8) arrivent dans les chapitres suivants. calculerTotal et verifierTransition sont des fonctions pures : mêmes entrées, même résultat, aucun accès extérieur. Ce sont les premières que tu testeras.
Exercices
Exercice 1 sur 4 Choisir le code de statut Réussi À revoir
Pour chaque situation, donne le code de statut et dis si la réponse a un corps.
GET /v1/fiches?etat=MPalors qu'aucune fiche n'est mise en paiement.POST /v1/fichesavec{"mois": "2026-11"}: la fiche est créée avec l'identifiant 15.PATCH /v1/fiches/11avec{"etat": "CL"}alors que la fiche 11 est validée.DELETE /v1/fiches/12réussi.POST /v1/fichesavec{"mois": "novembre"}.GET /v1/fiches/abc.
Voir la réponseMasquer la réponse de l'exercice 1
200avec une liste vide : la collection existe, elle ne contient rien qui corresponde.201, avec la fiche dans le corps et l'en-têteLocation: /v1/fiches/15.409avec un corps problème : la transitionVA→CLn'existe pas.204, sans corps.422avec un corps problème qui désigne le champmois: le JSON est lisible, la valeur est refusée.- Selon le choix de l'API :
404dans la version de ce chapitre (aucune fiche ne s'appelleabc),422dans la version finale qui valide les paramètres avec Zod. Les deux se défendent ; l'important est d'être cohérent sur toute l'API et de l'écrire dans la documentation.
Tu avais trouvé ?
Exercice 2 sur 4 Quelle couche ? Réussi À revoir
Dans quel fichier (route, contrôleur, service, dépôt) places-tu chacun de ces éléments ?
SELECT id, mois, etat FROM fiche_frais WHERE id_visiteur = ?- « Une fiche validée ne peut plus recevoir de frais. »
res.status(201).location(…)routeurFiches.post('/:id/validations', exigerRole('comptable'), …)- Le calcul du total d'une fiche.
- La lecture de
req.params.id.
Voir la réponseMasquer la réponse de l'exercice 2
- Dépôt : c'est le seul endroit qui écrit du SQL.
- Service : une règle de gestion.
- Contrôleur : le choix de la réponse HTTP.
- Routes : l'association méthode, adresse, middlewares, contrôleur.
- Service : un calcul métier, testable sans base.
- Contrôleur (ou le middleware de validation qui le précède) : c'est de la lecture de requête HTTP.
Tu avais trouvé ?
Exercice 3 sur 4 Les transitions Réussi À revoir
On ajoute un état RB (« remboursée ») après MP, et le comptable doit pouvoir refuser une fiche clôturée pour la renvoyer au visiteur, qui la retrouve en cours. Modifie l'objet TRANSITIONS. Que répond ensuite verifierTransition('CL', 'CR'), puis verifierTransition('MP', 'CL') ?
Voir la réponseMasquer la réponse de l'exercice 3
const TRANSITIONS = { CR: ['CL'], CL: ['VA', 'CR'], VA: ['MP'], MP: ['RB'], RB: [] };
verifierTransition('CL', 'CR') ne lève rien : le renvoi au visiteur est permis. verifierTransition('MP', 'CL') lève l'ErreurApi 409 « Une fiche à l'état MP ne peut pas passer à l'état CL. » Il faudra aussi ajouter RB à l'énumération de la colonne etat en base, et décider qui peut déclencher chaque transition (le renvoi CL → CR est réservé au comptable).
Tu avais trouvé ?
Exercice 4 sur 4 Une route de plus Réussi À revoir
Ajoute à la version en mémoire la route GET /v1/fiches/:id/historique, qui doit répondre 501 Not Implemented avec un corps problème tant qu'elle n'est pas codée. Écris la ligne de route et la fonction du contrôleur. Où doit-elle être déclarée par rapport à GET /:id ?
Voir la réponseMasquer la réponse de l'exercice 4
// src/controllers/fiches.controller.js
export function historique(req, res) {
throw new ErreurApi(501, 'non-implemente', 'Non implémenté', "L'historique des fiches n'est pas encore disponible.");
}
// src/routes/fiches.routes.js
routeurFiches.get('/:id/historique', fiches.historique);
L'ordre n'a pas d'importance ici : /:id ne correspond qu'à un segment après /v1/fiches, alors que /12/historique en a deux. Il n'y a conflit qu'entre chemins de même forme, comme /stats et /:id.
Tu avais trouvé ?