Chapitre 418 minIntermédiaire
Middleware Express : next(), erreurs async et route 404
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer le chemin d'une requête à travers la chaîne des middlewares ;
- écrire un middleware et le brancher au bon niveau ;
- laisser Express 5 attraper les erreurs des fonctions
async; - renvoyer toutes les erreurs dans un format unique, avec une route 404.
4 exercices corrigés1 schéma
Un middleware Express est une fonction qui s'exécute entre l'arrivée de la requête et l'envoi de la réponse : elle reçoit req, res et next, peut lire ou compléter la requête, répondre elle-même, ou passer la main au suivant en appelant next(). Toute une application Express est une chaîne de middlewares : le journal, la lecture du JSON, l'authentification, la validation, puis la route. Ce chapitre explique cette chaîne, montre comment écrire tes propres middlewares et met en place la gestion centralisée des erreurs, avec ce qu'Express 5 a changé pour les fonctions async.
Le principe : une chaîne de fonctions
Quand une requête arrive, Express la fait passer dans les middlewares dans l'ordre où tu les as enregistrés. Chacun a trois possibilités :
- passer la main au suivant avec
next()(après avoir éventuellement ajouté quelque chose àreq) ; - répondre avec
res.json(…),res.sendStatus(…): la chaîne s'arrête là ; - signaler une erreur avec
next(erreur)outhrow: Express saute directement au gestionnaire d'erreurs.
Une route est d'ailleurs un middleware comme un autre : un middleware qui répond toujours.
Écrire un middleware
Un middleware qui écrit dans la console la méthode, l'adresse, le code de statut et la durée de chaque requête :
function journal(req, res, next) {
const debut = performance.now();
res.on('finish', () => {
const duree = (performance.now() - debut).toFixed(1);
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${duree} ms)`);
});
next();
}
app.use(journal);
GET /v1/fiches/12 → 500 (2.6 ms)
POST /v1/fiches → 201 (5.3 ms)
GET /v1/frais → 404 (0.2 ms)
Le middleware ne connaît pas encore le code de statut au moment où il s'exécute : la route n'a pas répondu. Il s'abonne donc à l'événement finish de la réponse, déclenché quand elle est envoyée, et appelle tout de suite next(). Le chapitre 11 remplace ce journal maison par pino.
Un deuxième exemple, qui refuse une requête : l'API n'accepte que du JSON pour les créations et les modifications.
function exigerJson(req, res, next) {
if (['POST', 'PUT', 'PATCH'].includes(req.method) && !req.is('application/json')) {
return res.status(415).json({ erreur: 'Envoie le corps en JSON (Content-Type: application/json).' });
}
next();
}
req.is(type) compare le Content-Type de la requête. 415 Unsupported Media Type est le code prévu pour un format de corps refusé. Une requête refusée ne va jamais plus loin : la route ne s'exécute pas.
L'ordre d'enregistrement compte
Express exécute les middlewares dans l'ordre des app.use et des routes. Les conséquences pratiques :
express.json()doit être enregistré avant les routes qui lisentreq.body;- le journal se place en premier, pour mesurer et tracer toutes les requêtes, même celles qui seront refusées ;
- l'authentification se place avant les routes qu'elle protège ;
- le middleware 404 et le gestionnaire d'erreurs se placent en dernier, après toutes les routes.
// src/app.js : l'ordre de l'API AppliFrais
app.use(journal); // 1. tracer toutes les requêtes
app.use(express.json()); // 2. lire les corps JSON
app.get('/sante', (req, res) => res.json({ statut: 'ok' }));
app.use('/v1/fiches', routeurFiches); // 3. les routes
app.use(routeInconnue); // 4. rien n'a répondu : 404
app.use(gestionnaireErreurs); // 5. une erreur est survenue quelque part
Les trois niveaux : application, routeur, route
Un middleware peut s'appliquer à des portées différentes :
// Niveau application : toutes les requêtes
app.use(express.json());
// Niveau application, limité à un préfixe : tout ce qui commence par /v1
app.use('/v1', exigerJson);
// Niveau routeur : toutes les routes de ce routeur
routeurFiches.use(authentifier);
// Niveau route : une seule route, une liste de middlewares avant le contrôleur
routeurFiches.post('/:id/validations', exigerRole('comptable'), valider({ body: schemaValidation }), fiches.valider);
Le dernier niveau est très lisible : la ligne de route dit qui peut l'appeler et ce qui est vérifié avant d'atteindre le contrôleur. C'est la forme utilisée dans tout le reste du cours.
exigerRole('comptable') et valider({ … }) ne sont pas des middlewares mais des fabriques : des fonctions qui renvoient un middleware configuré. C'est le moyen de paramétrer un middleware :
// Une fabrique : exigerRole('comptable') renvoie un middleware qui connaît le rôle attendu
export function exigerRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.utilisateur?.role)) {
throw new ErreurApi(403, 'acces-refuse', 'Accès refusé', 'Ton rôle ne permet pas cette opération.');
}
next();
};
}
Transmettre une information au suivant
Les middlewares communiquent en ajoutant des propriétés à req. Le middleware d'authentification du chapitre 9 vérifie le jeton et range l'utilisateur dans req.utilisateur ; les middlewares et contrôleurs suivants le lisent. Express propose aussi res.locals, un objet vide prévu pour ça, surtout utilisé par les moteurs de gabarits.
Choisis des noms qui ne risquent pas d'écraser une propriété d'Express : req.utilisateur, req.valide, plutôt que req.query (en lecture seule en Express 5) ou req.user (utilisé par d'autres bibliothèques comme Passport).
Les middlewares prêts à l'emploi
| Middleware | Rôle | Chapitre |
|---|---|---|
express.json() | Lit un corps JSON (100 Ko au plus par défaut) et remplit req.body | 3 |
express.urlencoded() | Lit un formulaire HTML classique | 3 |
express.static(dossier) | Sert des fichiers statiques | 3 |
pino-http | Écrit une ligne de journal structurée par requête | 11 |
helmet | Ajoute les en-têtes de sécurité HTTP | 10 |
cors | Autorise un front hébergé sur une autre origine | 10 |
express-rate-limit | Limite le nombre de requêtes par client | 9 et 10 |
multer | Reçoit des fichiers envoyés par formulaire | 8 |
swagger-ui-express | Publie la documentation interactive | 13 |
Les erreurs des fonctions async
Une route qui interroge la base est async. Que se passe-t-il si la base ne répond pas ?
app.get('/v1/fiches/:id', async (req, res) => {
const fiche = await depot.trouverParId(Number(req.params.id)); // MySQL arrêté : la promesse est rejetée
res.json(fiche);
});
Avec Express 5, une fonction async qui lève une erreur, ou une promesse rejetée renvoyée par un middleware, est transmise automatiquement au gestionnaire d'erreurs, exactement comme si tu avais appelé next(erreur). Le client reçoit une réponse 500 propre.
Avec Express 4, ce même code laissait la requête sans réponse et produisait une promesse rejetée non gérée. Il fallait entourer chaque route d'un try … catch qui appelait next(erreur), ou installer le paquet express-async-errors. Si tu lis ce genre de code dans un tutoriel, sache qu'il n'est plus nécessaire :
// Écriture Express 4 : inutile en Express 5
app.get('/v1/fiches/:id', async (req, res, next) => {
try {
res.json(await depot.trouverParId(Number(req.params.id)));
} catch (erreur) {
next(erreur);
}
});
Une limite demeure : Express ne voit que ce qui se passe dans la fonction ou la promesse qu'elle renvoie. Une erreur levée plus tard dans un rappel (setTimeout, un événement, une ancienne API à rappels) lui échappe. Transforme ces API en promesses (la plupart des modules de Node.js en ont une version, comme node:fs/promises) et utilise await.
Des erreurs métier avec une classe ErreurApi
Dans une API, beaucoup d'« erreurs » sont prévues : la fiche n'existe pas (404), elle est déjà clôturée (409), les données sont invalides (422). Plutôt que d'écrire res.status(…).json(…) partout, on lève une erreur qui connaît son code, et un seul endroit la transforme en réponse.
// src/erreurs.js
export class ErreurApi extends Error {
constructor(status, type, titre, detail, extensions = {}) {
super(detail);
this.status = status; // code HTTP
this.type = type; // identifiant court du type de problème
this.titre = titre; // résumé lisible, le même pour toutes les occurrences
this.extensions = extensions; // informations en plus (champs en erreur…)
}
}
// Dans un contrôleur ou un service
if (fiche.etat !== 'CR') {
throw new ErreurApi(409, 'transition-interdite', "Changement d'état interdit",
`Une fiche à l'état ${fiche.etat} ne peut pas passer à l'état ${nouvelEtat}.`, { etatActuel: fiche.etat });
}
throw interrompt la fonction, qu'elle soit synchrone ou async : Express transmet l'erreur au gestionnaire. Plus de return oublié, plus de double réponse.
La route 404 et le gestionnaire d'erreurs
Deux middlewares ferment la chaîne. Le premier attrape les requêtes qu'aucune route n'a traitées ; le second, reconnaissable à ses quatre paramètres, reçoit toutes les erreurs.
// src/erreurs.js (suite)
export function routeInconnue(req, res, next) {
next(new ErreurApi(404, 'route-inconnue', 'Route inconnue',
`Aucune route ne correspond à ${req.method} ${req.path}.`));
}
export function gestionnaireErreurs(err, req, res, next) {
if (res.headersSent) return next(err); // la réponse est partie : on laisse Express couper la connexion
let erreur = err;
if (err.type === 'entity.parse.failed') {
erreur = new ErreurApi(400, 'json-invalide', 'JSON invalide', "Le corps de la requête n'est pas du JSON valide.");
} else if (err.type === 'entity.too.large') {
erreur = new ErreurApi(413, 'corps-trop-gros', 'Requête trop volumineuse', 'Le corps de la requête dépasse la taille permise.');
} else if (!(err instanceof ErreurApi)) {
console.error(err); // le détail technique pour toi, jamais pour le client
erreur = new ErreurApi(500, 'erreur-interne', 'Erreur interne', 'Une erreur inattendue est survenue.');
}
res.status(erreur.status).type('application/problem+json').json({
type: `https://api.gsb.example/problemes/${erreur.type}`,
title: erreur.titre,
status: erreur.status,
detail: erreur.message,
instance: req.originalUrl,
...erreur.extensions,
});
}
Toutes les erreurs de l'API ont maintenant la même forme, celle de la RFC 9457 (Problem Details for HTTP APIs) présentée dans le cours API REST. Le front n'a qu'une façon de les lire : status pour réagir, detail pour afficher, type pour distinguer les cas.
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/12","etatActuel":"CR"}
Trois cas méritent un traitement à part :
- un JSON mal formé (une virgule en trop) :
express.json()lève une erreur de typeentity.parse.failed. C'est une faute du client :400, pas500; - un corps trop gros :
entity.too.large,413; - toute autre erreur, imprévue (MySQL arrêté, bogue) :
500avec un message générique. La trace de la pile, le texte SQL ou le chemin d'un fichier ne sortent jamais vers le client : ils aident un attaquant à cartographier le serveur. Le détail va dans le journal ; le chapitre 11 ajoute un identifiant d'incident qui relie la réponse à sa ligne de journal.
Les erreurs en dehors des requêtes
Une promesse rejetée que personne n'attend (unhandled rejection) ou une exception levée hors de toute requête arrête le processus Node.js : c'est le comportement par défaut depuis Node.js 15, et c'est le bon. Un programme dans un état inconnu ne doit pas continuer à servir des requêtes. En production, c'est le gestionnaire de processus (Docker, systemd) qui relance l'API (chapitre 14). Ton travail : écrire l'erreur dans le journal avant l'arrêt, et ne pas laisser de promesse sans await.
Exercices
Exercice 1 sur 4 Suivre la chaîne Réussi À revoir
app.use((req, res, next) => { console.log('A'); next(); });
app.get('/sante', (req, res) => { console.log('B'); res.json({ statut: 'ok' }); });
app.use((req, res, next) => { console.log('C'); next(); });
app.use((req, res) => { console.log('D'); res.status(404).json({ erreur: 'Route inconnue' }); });
Qu'affiche la console, et quel code reçoit le client, pour GET /sante, puis pour GET /v1/fiches ?
Voir la réponseMasquer la réponse de l'exercice 1
GET /sante:A,B. Le client reçoit200. La route répond et n'appelle pasnext():CetDne s'exécutent pas.GET /v1/fiches:A,C,D. Aucune route ne correspond ; la requête traverse les middlewares jusqu'au dernier, qui répond404.
Tu avais trouvé ?
Exercice 2 sur 4 La requête qui ne répond jamais Réussi À revoir
Dans Bruno, GET /v1/fiches tourne pendant trente secondes puis échoue sans réponse. Voici le seul middleware ajouté depuis la dernière version qui marchait. Trouve le problème et corrige-le.
app.use((req, res, next) => {
if (req.get('X-Maintenance') === 'oui') {
res.status(503).json({ erreur: 'Maintenance en cours' });
}
});
Voir la réponseMasquer la réponse de l'exercice 2
Quand l'en-tête X-Maintenance est absent, le middleware ne répond pas et n'appelle pas next() : la requête reste en suspens jusqu'au délai du client.
app.use((req, res, next) => {
if (req.get('X-Maintenance') === 'oui') {
return res.status(503).json({ erreur: 'Maintenance en cours' });
}
next();
});
Le return évite d'appeler next() après avoir répondu.
Tu avais trouvé ?
Exercice 3 sur 4 Écrire une fabrique de middleware Réussi À revoir
Écris une fabrique limiterTaille(max) qui refuse avec 413 et une ErreurApi toute requête dont l'en-tête Content-Length dépasse max octets. Montre comment l'appliquer aux seules routes du routeur des frais, avec 10 000 octets.
Voir la réponseMasquer la réponse de l'exercice 3
export function limiterTaille(max) {
return (req, res, next) => {
const taille = Number(req.get('Content-Length') ?? 0);
if (taille > max) {
throw new ErreurApi(413, 'corps-trop-gros', 'Requête trop volumineuse',
`Le corps dépasse ${max} octets.`);
}
next();
};
}
routeurFrais.use(limiterTaille(10_000));
La fabrique renvoie un middleware qui « se souvient » de max (une fermeture). En pratique, express.json({ limit: '10kb' }) fait déjà ce contrôle pour les corps JSON, y compris quand l'en-tête Content-Length est absent.
Tu avais trouvé ?
Exercice 4 sur 4 Quelle réponse ? Réussi À revoir
L'API utilise le gestionnaireErreurs de ce chapitre. Pour chaque situation, donne le code de statut et le champ type de la réponse.
- Le client envoie
{"mois": "2026-11",}avecContent-Type: application/json. - Un service lève
new ErreurApi(404, 'fiche-introuvable', 'Fiche introuvable', 'La fiche 99 n\'existe pas.'). - MySQL est arrêté pendant
GET /v1/fiches/12. - Le client demande
DELETE /v1/frais/3, qui n'existe pas. - Le gestionnaire a été écrit
function gestionnaireErreurs(err, req, res) { … }et le service lève l'erreur du point 2.
Voir la réponseMasquer la réponse de l'exercice 4
400, type…/json-invalide: la virgule finale rend le JSON invalide (entity.parse.failed).404, type…/fiche-introuvable.500, type…/erreur-interne, avec un message générique ; le détail (ECONNREFUSED) part dans la console ou le journal.404, type…/route-inconnue, produit parrouteInconnue.- Avec trois paramètres, la fonction n'est pas un gestionnaire d'erreurs : Express l'ignore pour les erreurs et utilise son gestionnaire par défaut, qui répond
404(il reprend lestatusde l'erreur) avec une page HTML au lieu du JSON prévu. Pire : hors production (NODE_ENVdifférent deproduction), cette page affiche la pile d'appels, avec les chemins des fichiers du serveur. Il faut les quatre paramètres.
Tu avais trouvé ?