Aller au contenu
Réussir mon BTS SIO

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

Par exemple

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émarchePrincipeOutils avec Express
Description d'abord (design-first)On écrit openapi.yaml, puis le code qui le respecteUn fichier YAML, swagger-ui-express
Code d'abord (code-first)On génère la description à partir du codeCommentaires 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

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éma jeton (un Bearer JWT) ; la route de connexion l'annule avec security: [] ;
  • 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: false est la traduction de z.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

bash
npm install swagger-ui-express yaml
js
// 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 :

js
import { z } from 'zod';
import { schemaFraisHorsForfait } from './schemas/fiches.schemas.js';

const { $schema, ...nouveauFrais } = z.toJSONSchema(schemaFraisHorsForfait);
documentation.components.schemas.NouveauFraisHorsForfait = nouveauFrais;
json
{
  "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 :

js
// 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');
      });
    }
  }
});
text
▶ 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.
yaml
'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.

text
# 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
yaml
/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
  1. visiteurId ne doit pas figurer dans le corps : le code le prend dans le jeton, et z.strictObject le refuse (422). La documentation inciterait le front à l'envoyer.
  2. Le succès est 201, pas 200, avec l'en-tête Location.
  3. Les refus manquent : 401 (sans jeton), 403 (un comptable), 409 (fiche du mois déjà créée), 422 (mois invalide ou champ en trop).
  4. Il manque additionalProperties: false et 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
  1. Le jeton n'a pas été saisi dans Authorize : la connexion réussie ne le transmet pas automatiquement aux autres routes.
  2. 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é ?

Chapitre terminé !

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

chapitres
–
de série
–
du cours
–
Chapitre suivant : Déployer une API Node.js : variables d'environnement, Docker et NginxRevoir le sommaire du cours