Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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érationMéthode et URIRôleSuccèsRefus métier
Lister des fichesGET /v1/fiches?mois=&etat=&page=&limite=visiteur (les siennes), comptable200—
Créer la fiche du moisPOST /v1/fichesvisiteur201 + Location409 si elle existe ; 422
Lire une ficheGET /v1/fiches/{id}propriétaire ou comptable200404
Supprimer une fiche en coursDELETE /v1/fiches/{id}propriétaire204409 si elle n'est plus en cours
Saisir les frais forfaitisésPUT /v1/fiches/{id}/lignes-forfaitpropriétaire200409 ; 422
Ajouter un frais hors forfaitPOST /v1/fiches/{id}/frais-hors-forfaitpropriétaire201409 ; 422
Supprimer un frais hors forfaitDELETE /v1/frais-hors-forfait/{id}propriétaire204409
Clôturer ou mettre en paiementPATCH /v1/fiches/{id} avec {"etat":"CL"} ou {"etat":"MP"}propriétaire (CL), comptable (MP)200409
Valider une fichePOST /v1/fiches/{id}/validationscomptable201409 ; 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.

requête HTTPréponse JSONRoutesroutes/méthodeet adressemiddlewareset contrôleurContrôleurscontrollers/lit reqappelle le servicechoisit le codede statutServicesservices/règles de gestiondroits, états, totalni HTTPni SQLDépôtsdepots/requêtes SQLpréparéeslignes en objetsde l'APIMySQLchanger de base ne touche que les dépôtsmémoire, mysql2 ou Prismachaque couche n'appelle que la suivante
Chaque couche a une responsabilité et n'appelle que la suivante
CoucheDossierElle sait…Elle ignore…
Routessrc/routes/Quelle méthode et quelle adresse appellent quel contrôleur, avec quels middlewaresCe que fait le contrôleur
Contrôleurssrc/controllers/Lire req, choisir le code de statut, envoyer la réponseComment les données sont stockées
Servicessrc/services/Les règles de gestion : qui peut faire quoi, dans quel état, les calculsHTTP et SQL
Dépôtssrc/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.

js
// 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.

js
// 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

js
// 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);
js
// 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) :

bash
curl -s 'http://localhost:3000/v1/fiches?etat=CL'
json
[{"id":13,"visiteurId":8,"mois":"2026-10","etat":"CL","montantValide":null}]
bash
curl -i -X POST http://localhost:3000/v1/fiches -H 'Content-Type: application/json' -d '{"visiteurId": 7, "mois": "2026-11"}'
http
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
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"}
bash
curl -i -X PATCH http://localhost:3000/v1/fiches/14 -H 'Content-Type: application/json' -d '{"etat": "VA"}'
http
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"}
bash
curl -i -X DELETE http://localhost:3000/v1/fiches/14
http
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.

CR → VA directement, retour en arrière… : 409 ConflictCRen coursCLclôturéeVAvalidéeMPen paiementvisiteurPATCH {"etat":"CL"}comptablePOST …/validationscomptablePATCH {"etat":"MP"}frais saisis, ajoutés ou supprimés uniquement à l'état CR ; MP est l'état final
Trois transitions permises ; toute autre demande répond 409

En code, ce diagramme tient dans un objet : pour chaque état, la liste des états suivants autorisés.

js
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

SituationCodeCorps
Lecture réussie, liste vide comprise200 OKLa ressource ou la liste ([] si vide, jamais 404)
Création réussie201 CreatedLa ressource créée, avec l'en-tête Location
Suppression réussie204 No ContentAucun
JSON illisible400 Bad RequestProblème
Données lisibles mais invalides (mois 2026-13, montant négatif)422 Unprocessable ContentProblème avec la liste des champs
Ressource inexistante404 Not FoundProblème
Incompatible avec l'état actuel (doublon, transition interdite)409 ConflictProblème
Erreur imprévue côté serveur500 Internal Server ErrorProblè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 :

js
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.

js
// 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;
}
js
// 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.

  1. GET /v1/fiches?etat=MP alors qu'aucune fiche n'est mise en paiement.
  2. POST /v1/fiches avec {"mois": "2026-11"} : la fiche est créée avec l'identifiant 15.
  3. PATCH /v1/fiches/11 avec {"etat": "CL"} alors que la fiche 11 est validée.
  4. DELETE /v1/fiches/12 réussi.
  5. POST /v1/fiches avec {"mois": "novembre"}.
  6. GET /v1/fiches/abc.
Voir la réponseMasquer la réponse de l'exercice 1
  1. 200 avec une liste vide : la collection existe, elle ne contient rien qui corresponde.
  2. 201, avec la fiche dans le corps et l'en-tête Location: /v1/fiches/15.
  3. 409 avec un corps problème : la transition VA → CL n'existe pas.
  4. 204, sans corps.
  5. 422 avec un corps problème qui désigne le champ mois : le JSON est lisible, la valeur est refusée.
  6. Selon le choix de l'API : 404 dans la version de ce chapitre (aucune fiche ne s'appelle abc), 422 dans 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 ?

  1. SELECT id, mois, etat FROM fiche_frais WHERE id_visiteur = ?
  2. « Une fiche validée ne peut plus recevoir de frais. »
  3. res.status(201).location(…)
  4. routeurFiches.post('/:id/validations', exigerRole('comptable'), …)
  5. Le calcul du total d'une fiche.
  6. La lecture de req.params.id.
Voir la réponseMasquer la réponse de l'exercice 2
  1. Dépôt : c'est le seul endroit qui écrit du SQL.
  2. Service : une règle de gestion.
  3. Contrôleur : le choix de la réponse HTTP.
  4. Routes : l'association méthode, adresse, middlewares, contrôleur.
  5. Service : un calcul métier, testable sans base.
  6. 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
js
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
js
// 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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Node.js et MySQL avec mysql2 : requêtes préparées et transactionsRevoir le sommaire du cours