Chapitre 1321 minIntermédiaire
Documenter une API Express avec Swagger (OpenAPI)
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- décrire les routes de l'API dans un fichier OpenAPI ;
- publier la documentation interactive avec Swagger UI ;
- garder la documentation fidèle au code.
3 exercices corrigés
OpenAPI est le format standard pour décrire une API REST dans un fichier YAML ou JSON : ses routes, leurs paramètres, les corps attendus, les réponses possibles et l'authentification. Swagger UI lit ce fichier et en fait une page web interactive, où l'on peut lire la documentation et essayer chaque route. Ce chapitre écrit la description OpenAPI de l'API AppliFrais, la publie sur /docs avec swagger-ui-express, et montre comment la garder fidèle au code.
OpenAPI et Swagger : rappel et choix de méthode
OpenAPI est la spécification (version 3.2 depuis septembre 2025 ; ce cours écrit en 3.1, encore la mieux prise en charge par les outils) ; Swagger est le nom historique du format et celui d'une famille d'outils, dont Swagger UI. La structure d'un document OpenAPI, ses schémas et ses mécanismes de sécurité sont expliqués dans le cours API REST : ce chapitre l'applique à Express.
Deux démarches existent :
| Démarche | Principe | Outils avec Express |
|---|---|---|
| Description d'abord (design-first) | On écrit openapi.yaml, puis le code qui le respecte | Un fichier YAML, swagger-ui-express |
| Code d'abord (code-first) | On génère la description à partir du code | Commentaires lus par swagger-jsdoc, schémas Zod convertis par @asteasolutions/zod-to-openapi |
AppliFrais suit la description d'abord : le contrat a été conçu avant le code (le tableau des opérations du chapitre 5), et un fichier YAML se relit, se commente et se valide en revue de code comme le reste du projet. On verra ensuite comment réutiliser les schémas Zod pour éviter les écarts.
Le fichier docs/openapi.yaml
openapi: 3.1.1
info:
title: API AppliFrais
version: 1.0.0
description: Gestion des fiches de frais des visiteurs médicaux de GSB.
servers:
- url: http://localhost:3000
description: Poste de développement
security:
- jeton: []
paths:
/v1/auth/connexion:
post:
summary: Se connecter et obtenir un jeton
tags: [Authentification]
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Connexion'
responses:
'200':
description: Jeton d'accès valable 15 minutes
content:
application/json:
schema:
$ref: '#/components/schemas/Jeton'
'401':
$ref: '#/components/responses/NonAuthentifie'
'422':
$ref: '#/components/responses/Invalide'
'429':
description: Trop de tentatives
/v1/fiches/{id}/frais-hors-forfait:
parameters:
- { name: id, in: path, required: true, schema: { type: integer, minimum: 1 } }
post:
summary: Ajouter un frais hors forfait à une fiche en cours
tags: [Fiches]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NouveauFraisHorsForfait' }
responses:
'201':
description: Frais ajouté
content:
application/json:
schema: { $ref: '#/components/schemas/FraisHorsForfait' }
'404':
$ref: '#/components/responses/Introuvable'
'409':
$ref: '#/components/responses/Conflit'
'422':
$ref: '#/components/responses/Invalide'
components:
securitySchemes:
jeton:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
NouveauFraisHorsForfait:
type: object
required: [date, libelle, montant]
additionalProperties: false
properties:
date: { type: string, format: date }
libelle: { type: string, minLength: 3, maxLength: 100 }
montant: { type: number, exclusiveMinimum: 0, maximum: 5000 }
Probleme:
type: object
description: Erreur au format de la RFC 9457
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }
responses:
Conflit:
description: État de la fiche incompatible avec l'opération
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Probleme' }
(Extrait : le fichier complet décrit aussi les routes des fiches et tous les schémas référencés.) Les points clés :
security: [ { jeton: [] } ]à la racine protège toutes les routes par le schémajeton(un Bearer JWT) ; la route de connexion l'annule avecsecurity: [];- les composants (
components) évitent de répéter : un schéma ou une réponse d'erreur se définit une fois et se référence partout avec$ref; additionalProperties: falseest la traduction dez.strictObject: la documentation annonce la même liste blanche que le code ;- chaque code de statut que la route peut renvoyer est documenté, refus compris : c'est ce dont le développeur du front a besoin.
Publier Swagger UI sur /docs
npm install swagger-ui-express yaml
// src/app.js
import { readFile } from 'node:fs/promises';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yaml';
const documentation = YAML.parse(await readFile(new URL('../docs/openapi.yaml', import.meta.url), 'utf8'));
app.use('/docs', swaggerUi.serve, swaggerUi.setup(documentation));
app.get('/openapi.json', (req, res) => res.json(documentation)); // la description brute, pour les outils
Le fichier est lu une fois, au démarrage (await au niveau du module). new URL('../docs/openapi.yaml', import.meta.url) construit le chemin à partir de l'emplacement de app.js, quel que soit le dossier d'où l'on lance l'API.
Ouvre http://localhost:3000/docs :
La page affiche le titre et la version de l'API, une liste des serveurs, un bouton Authorize, puis les routes regroupées par étiquette (tags) : Authentification, Fiches. Un cadenas signale chaque route qui exige un jeton. En bas, la section Schemas liste les composants.
Pour essayer une route protégée : exécute POST /v1/auth/connexion avec « Try it out », copie accessToken, clique sur Authorize et colle le jeton. Swagger UI ajoute ensuite l'en-tête Authorization: Bearer … à chaque essai. Les en-têtes de helmet sont compatibles avec Swagger UI : ses scripts sont servis par l'API elle-même.
Garder la documentation fidèle au code
Le risque de la description d'abord : le code évolue, le YAML non. Trois parades, de la plus simple à la plus complète.
1. Relire la documentation dans la revue de code. Une pull request qui modifie une route sans toucher à openapi.yaml doit éveiller l'attention du relecteur.
2. Générer les schémas depuis Zod. Zod 4 convertit un schéma en JSON Schema, le langage qu'utilise OpenAPI 3.1. On remplace au démarrage le schéma écrit à la main par celui du code :
import { z } from 'zod';
import { schemaFraisHorsForfait } from './schemas/fiches.schemas.js';
const { $schema, ...nouveauFrais } = z.toJSONSchema(schemaFraisHorsForfait);
documentation.components.schemas.NouveauFraisHorsForfait = nouveauFrais;
{
"type": "object",
"properties": {
"date": { "type": "string", "format": "date", "pattern": "^(?:(?:\\d\\d[2468][048]|…" },
"libelle": { "type": "string", "minLength": 3, "maxLength": 100 },
"montant": { "type": "number", "exclusiveMinimum": 0, "maximum": 5000, "multipleOf": 0.01 }
},
"required": ["date", "libelle", "montant"],
"additionalProperties": false
}
La documentation montre exactement les règles appliquées par le middleware de validation, y compris celles qu'on aurait oublié de reporter dans le YAML (multipleOf: 0.01). Le motif de la date est long : c'est l'expression qui vérifie aussi les années bissextiles.
3. Tester la documentation. Un test automatique vérifie que chaque route décrite dans openapi.yaml existe vraiment dans l'API. Il parcourt les chemins du document, envoie une requête sur chacun, et échoue si c'est la route 404 du chapitre 4 qui répond :
// test/documentation.test.js
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { after, before, describe, it } from 'node:test';
import request from 'supertest';
import YAML from 'yaml';
import { app } from '../src/app.js';
import { pool } from '../src/db.js';
import { jetonDe, reinitialiserBase } from './outils.js';
const documentation = YAML.parse(await readFile(new URL('../docs/openapi.yaml', import.meta.url), 'utf8'));
const METHODES = ['get', 'post', 'put', 'patch', 'delete'];
let jeton;
before(async () => {
await reinitialiserBase();
jeton = await jetonDe('adurand', 'Comptable#2026');
});
after(() => pool.end());
describe('docs/openapi.yaml', () => {
for (const [chemin, operations] of Object.entries(documentation.paths)) {
for (const methode of METHODES.filter((m) => operations[m])) {
it(`${methode.toUpperCase()} ${chemin} existe dans l'API`, async () => {
const url = chemin.replaceAll(/\{[^}]+\}/g, '1'); // /v1/fiches/{id} devient /v1/fiches/1
const reponse = await request(app)[methode](url).set('Authorization', jeton);
assert.notEqual(reponse.body.type, 'https://api.gsb.example/problemes/route-inconnue');
});
}
}
});
▶ docs/openapi.yaml
✔ POST /v1/auth/connexion existe dans l'API (2.7ms)
✔ GET /v1/fiches existe dans l'API (2.3ms)
✔ POST /v1/fiches existe dans l'API (1.4ms)
✔ GET /v1/fiches/{id} existe dans l'API (1.6ms)
✔ POST /v1/fiches/{id}/frais-hors-forfait existe dans l'API (1.1ms)
Les requêtes partent avec un jeton de comptable. Sans lui, le middleware authentifier, branché sur tout le routeur des fiches, répondrait 401 à n'importe quel chemin sous /v1/fiches, même inexistant, et le test passerait à tort. Avec le jeton, une route documentée mais absente (GET /v1/fiches/{id}/historique, par exemple) arrive jusqu'à routeInconnue et le test échoue. Les routes qui existent répondent 200, 403 (réservée aux visiteurs), 404 (fiche inconnue) ou 422 (corps absent), mais jamais « route inconnue ». Le test ne vérifie pas le contenu des réponses : c'est le rôle des tests d'intégration du chapitre 12. Un test plus poussé validerait aussi les réponses réelles contre les schémas documentés, avec un validateur de JSON Schema ; c'est le niveau des API publiques.
Documenter les erreurs et donner des exemples
Une documentation qui ne montre que les cas de succès laisse le développeur du front deviner le reste. Deux ajouts la rendent vraiment utile :
- un exemple par réponse importante, avec la clé
examples: Swagger UI l'affiche tel quel, et le front sait exactement quoi attendre ; - la description des refus métier dans le texte de la réponse : pas seulement « 409 Conflict », mais dans quel cas il arrive.
'409':
description: >-
La fiche n'est plus en cours (état CL, VA ou MP) : ses frais ne se modifient plus.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Probleme' }
examples:
ficheCloturee:
summary: Ajout sur une fiche clôturée
value:
type: https://api.gsb.example/problemes/fiche-cloturee
title: Fiche non modifiable
status: 409
detail: "La fiche est à l'état CL : seuls les frais d'une fiche en cours se modifient."
instance: /v1/fiches/12/frais-hors-forfait
etatActuel: CL
L'exemple est la réponse réelle de l'API (tu peux la copier depuis Bruno ou curl) : il montre l'extension etatActuel, que le front peut utiliser pour afficher un message précis. C'est aussi ce que la commission lira si elle ouvre /docs pendant ta présentation de l'E6.
Le README de l'API
La documentation OpenAPI dit comment appeler l'API ; le README dit comment la faire tourner. C'est le premier fichier que lit un membre du jury ou un nouveau développeur.
# API AppliFrais
API REST de gestion des frais des visiteurs médicaux GSB (Node.js 24, Express 5, MySQL 8.4).
## Démarrer
1. npm ci
2. Créer la base : voir sql/schema.sql et sql/donnees.sql
3. cp .env.example .env, puis compléter les valeurs
4. npm run dev, puis ouvrir http://localhost:3000/docs
## Comptes de démonstration (jeu de données de test uniquement)
- lmartin / Visiteur#2026 (visiteur)
- adurand / Comptable#2026 (comptable)
## Scripts
- npm run dev : développement, relance automatique
- npm test : tests (base applifrais_test, fichier .env.test)
- npm start : production
## Variables d'environnement
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, JWT_SECRET (32 caractères au moins),
CORS_ORIGIN, PORT, LOG_LEVEL, DOSSIER_JUSTIFICATIFS
Les comptes de démonstration ne valent que pour le jeu de données de test : en production, les comptes sont créés par le script du chapitre 9 avec des mots de passe aléatoires. La rédaction d'un README est détaillée dans le cours API REST.
Exercices
Exercice 1 sur 3 Documenter une route Réussi À revoir
Écris la description OpenAPI de DELETE /v1/fiches/{id} : réservée au visiteur propriétaire, elle répond 204 en cas de succès, 401, 403, 404 ou 409 sinon. Réutilise les réponses de components/responses (NonAuthentifie, Interdit, Introuvable, Conflit).
Voir la réponseMasquer la réponse de l'exercice 1
/v1/fiches/{id}:
parameters:
- { name: id, in: path, required: true, schema: { type: integer, minimum: 1 } }
delete:
summary: Supprimer une fiche en cours
tags: [Fiches]
responses:
'204':
description: Fiche supprimée
'401':
$ref: '#/components/responses/NonAuthentifie'
'403':
$ref: '#/components/responses/Interdit'
'404':
$ref: '#/components/responses/Introuvable'
'409':
$ref: '#/components/responses/Conflit'
Pas de security à écrire : la règle de la racine (jeton) s'applique. Pas de content pour 204, qui n'a pas de corps. Si le chemin /v1/fiches/{id} existe déjà dans le fichier (pour get), on ajoute delete sous le même chemin au lieu de le répéter.
Tu avais trouvé ?
Exercice 2 sur 3 Trouver les écarts Réussi À revoir
La documentation déclare pour POST /v1/fiches le corps { "mois": string, "visiteurId": integer }, obligatoires tous les deux, et une seule réponse : 200. Compare avec le code de l'API et liste les écarts.
Voir la réponseMasquer la réponse de l'exercice 2
visiteurIdne doit pas figurer dans le corps : le code le prend dans le jeton, etz.strictObjectle refuse (422). La documentation inciterait le front à l'envoyer.- Le succès est
201, pas200, avec l'en-têteLocation. - Les refus manquent :
401(sans jeton),403(un comptable),409(fiche du mois déjà créée),422(mois invalide ou champ en trop). - Il manque
additionalProperties: falseet le format du mois (pattern: '^\d{4}-(0[1-9]|1[0-2])$').
Tu avais trouvé ?
Exercice 3 sur 3 Swagger UI répond 401 Réussi À revoir
Dans Swagger UI, GET /v1/fiches répond 401 alors que tu viens de te connecter avec succès. Donne deux causes possibles.
Voir la réponseMasquer la réponse de l'exercice 3
- Le jeton n'a pas été saisi dans Authorize : la connexion réussie ne le transmet pas automatiquement aux autres routes.
- Le jeton a expiré : il est valable 15 minutes. Il faut se reconnecter et le saisir à nouveau.
Autres causes possibles : le jeton a été collé avec le mot Bearer devant (Swagger UI l'ajoute déjà, l'en-tête devient Bearer Bearer …), ou le schéma de sécurité n'est pas déclaré (components.securitySchemes), ce qui fait que Swagger UI n'envoie aucun en-tête.
Tu avais trouvé ?