Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
NestJS

Préfixe global et versionnement d'une API (/api/v1)

Cette leçon montre comment organiser les adresses d'une API NestJS dans la durée : préfixe global /api avec exclusion de certaines routes, puis versionnement pour faire évoluer l'API sans casser les c...

0 chapitres 0 QCM Accès gratuit
Préfixe global et versionnement d'une API (/api/v1)

À propos de ce cours

Cette leçon montre comment organiser les adresses d'une API NestJS dans la durée : préfixe global /api avec exclusion de certaines routes, puis versionnement pour faire évoluer l'API sans casser les clients existants. Elle détaille le versionnement par URI (/api/v1, /api/v2) avec enableVersioning, la version par défaut, les versions déclarées par contrôleur ou par route (@Version), les routes neutres (VERSION_NEUTRAL) et le versionnement par en-tête, avec un piège réel mis en évidence et sa solution. Elle se termine par une stratégie de dépréciation d'une ancienne version.

Objectifs de la leçon

  • Configurer un préfixe global et exclure certaines routes.
  • Comprendre pourquoi versionner une API.
  • Mettre en place le versionnement par URI avec une version par défaut.
  • Déclarer des versions par contrôleur, par route ou neutres.
  • Connaître le versionnement par en-tête et ses pièges.
1. Le préfixe global

Au chapitre 03, nous avons ajouté app.setGlobalPrefix('api'). On peut en exclure certaines routes : typiquement une route de santé interrogée par l'hébergeur ou un outil de surveillance, qui doit rester à une adresse simple.

app.setGlobalPrefix('api', { exclude: ['sante'] });
// GET /api/stagiaires   → OK
// GET /sante            → OK (exclue du préfixe)
// GET /api/sante        → 404
2. Pourquoi versionner ?

Une API publiée a des clients que vous ne contrôlez pas tous : l'application mobile installée sur les téléphones des stagiaires ne sera pas mise à jour le jour où vous modifiez le format d'une réponse. Si GET /api/stagiaires passe d'un tableau à un objet paginé, toutes les anciennes versions de l'application cessent de fonctionner.

La solution : publier le nouveau format sous une nouvelle version, et garder l'ancienne active le temps que les clients migrent.

Versionnement d'une API

v1 et v2 coexistent : chaque client utilise la version qu'il connaît.

Changements qui nécessitent une nouvelle version (incompatibles) : renommer ou supprimer un champ, changer son type, changer la structure de la réponse, rendre obligatoire un paramètre facultatif. Changements qui n'en nécessitent pas (compatibles) : ajouter une route, ajouter un champ dans une réponse, ajouter un paramètre facultatif.

3. Le versionnement par URI

📄 src/main.ts

import { VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api', { exclude: ['sante'] });     // /sante reste hors de /api
  app.enableVersioning({
    type: VersioningType.URI,                              // la version dans l'URL : /api/v1/…
    defaultVersion: '1',                                   // version des routes sans @Version
  });
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

Les versions se déclarent sur les contrôleurs ou les routes :

📄 src/stagiaires-v1.controller.ts

import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { STAGIAIRES } from './stagiaires.donnees';

@Controller('stagiaires')                    // version par défaut : 1
export class StagiairesV1Controller {
  @Get()
  findAll() {
    return STAGIAIRES.map(({ id, nom, prenom }) => ({ id, nom, prenom }));
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return STAGIAIRES.find((s) => s.id === id);
  }
}

📄 src/stagiaires-v2.controller.ts

import { Controller, Get, Query } from '@nestjs/common';
import { STAGIAIRES } from './stagiaires.donnees';

@Controller({ path: 'stagiaires', version: '2' })
export class StagiairesV2Controller {
  @Get()                                     // v2 : nouveau format, paginé
  findAll(@Query('page') page = '1') {
    const p = Number(page);
    return {
      meta: { total: STAGIAIRES.length, page: p },
      donnees: STAGIAIRES.slice((p - 1) * 2, p * 2).map((s) => ({ id: s.id, nomComplet: `${s.prenom} ${s.nom.toUpperCase()}` })),
    };
  }
}

📄 src/autres.controller.ts

import { Controller, Get, Version, VERSION_NEUTRAL } from '@nestjs/common';

@Controller('formations')
export class FormationsController {
  @Get()                                     // v1 (par défaut)
  findAll() {
    return ['Développement digital'];
  }

  @Get('catalogue')
  @Version(['1', '2'])                       // même route pour deux versions
  catalogue() {
    return { catalogue: 'commun aux versions 1 et 2' };
  }
}

@Controller({ path: 'sante', version: VERSION_NEUTRAL })
export class SanteController {
  @Get()                                     // sans version, sans préfixe : /sante
  etat() {
    return { statut: 'ok' };
  }
}

Requêtes envoyées et réponses réellement obtenues :

GET /api/v1/stagiaires → 200 [{"id":1,"nom":"Ben Ali","prenom":"Sami"},{"id":2,"nom":"Trabelsi","prenom":"Ines"},{"id":3,"nom":"Gharbi","prenom":"Yassine"}]
GET /api/v1/stagiaires/2 → 200 {"id":2,"nom":"Trabelsi","prenom":"Ines"}
GET /api/v2/stagiaires → 200 {"meta":{"total":3,"page":1},"donnees":[{"id":1,"nomComplet":"Sami BEN ALI"},{"id":2,"nomComplet":"Ines TRABELSI"}]}
GET /api/v2/stagiaires?page=2 → 200 {"meta":{"total":3,"page":2},"donnees":[{"id":3,"nomComplet":"Yassine GHARBI"}]}
GET /api/v2/stagiaires/2 → 404 {"message":"Cannot GET /api/v2/stagiaires/2","error":"Not Found","statusCode":404}
GET /api/stagiaires → 404 {"message":"Cannot GET /api/stagiaires","error":"Not Found","statusCode":404}
GET /api/v1/formations → 200 ["Développement digital"]
GET /api/v2/formations → 404 {"message":"Cannot GET /api/v2/formations","error":"Not Found","statusCode":404}
GET /api/v1/formations/catalogue → 200 {"catalogue":"commun aux versions 1 et 2"}
GET /api/v2/formations/catalogue → 200 {"catalogue":"commun aux versions 1 et 2"}
GET /sante → 200 {"statut":"ok"}
GET /api/sante → 404 {"message":"Cannot GET /api/sante","error":"Not Found","statusCode":404}
  • Avec le versionnement URI, toute route versionnée a le préfixe /v1, /v2… /api/stagiaires sans version renvoie 404.
  • defaultVersion: '1' attribue la version 1 aux contrôleurs qui n'en déclarent pas (StagiairesV1Controller, FormationsController).
  • Une version n'hérite pas des routes d'une autre : /api/v2/stagiaires/2 n'existe pas tant que le contrôleur v2 ne déclare pas :id. Il faut soit la réécrire, soit déclarer la route pour les deux versions.
  • @Version(['1', '2']) sur une route la rend disponible dans plusieurs versions.
  • VERSION_NEUTRAL: route accessible sans numéro de version (/sante).
4. Le versionnement par en-tête

Autre stratégie : la version est transmise dans un en-tête, les URL restent identiques.

app.enableVersioning({
  type: VersioningType.HEADER,
  header: 'X-Api-Version',
  defaultVersion: '1',
});

Même requête, sans puis avec l'en-tête X-Api-Version: 2 :

GET /api/stagiaires → 404 {"message":"Cannot GET /api/stagiaires","error":"Not Found","statusCode":404}
GET /api/stagiaires → 200 {"meta":{"total":3,"page":1},"donnees":[{"id":1,"nomComplet":"Sami BEN ALI"},{"id":2,"nomComplet":"Ines TRABELSI"}]}

Piège: sans l'en-tête, la requête renvoie 404, même avec defaultVersion: '1'. La version par défaut sert à attribuer une version aux contrôleurs, pas à deviner celle d'une requête qui n'en indique aucune. Pour accepter les requêtes sans en-tête, il faut que les routes de la version 1 soient aussi « neutres » :

import { VERSION_NEUTRAL, VersioningType } from '@nestjs/common';

app.enableVersioning({
  type: VersioningType.HEADER,
  header: 'X-Api-Version',
  defaultVersion: [VERSION_NEUTRAL, '1'],   // sans en-tête → version 1
});

Même essai avec cette configuration :

GET /api/stagiaires → 200 [{"id":1,"nom":"Ben Ali","prenom":"Sami"},{"id":2,"nom":"Trabelsi","prenom":"Ines"},{"id":3,"nom":"Gharbi","prenom":"Yassine"}]
GET /api/stagiaires → 200 {"meta":{"total":3,"page":1},"donnees":[{"id":1,"nomComplet":"Sami BEN ALI"},{"id":2,"nomComplet":"Ines TRABELSI"}]}
StratégieExempleAvantagesInconvénients
URI/api/v2/stagiairesvisible, testable dans un navigateur, cache simpleURL qui changent
En-têteX-Api-Version: 2URL stablesinvisible, plus difficile à tester et à mettre en cache
Type de médiaAccept: application/json;v=2conforme à HTTPcomplexe pour les clients

Le versionnement par URI est le plus répandu et le plus simple à enseigner et à tester : c'est celui que nous utiliserons.

5. Retirer une ancienne version
  1. Publier la v2 et la documenter (Swagger, chapitre 14).
  2. Annoncer la fin de la v1, par exemple avec un en-tête Deprecation: true et Sunset: <date> sur ses réponses (un intercepteur, chapitre 09, peut l'ajouter à toutes les routes v1).
  3. Surveiller dans les journaux que plus aucun client n'appelle la v1.
  4. Supprimer les contrôleurs v1.

Organisation du code: pour éviter de dupliquer la logique, v1 et v2 utilisent le même service; seuls les contrôleurs (le format des entrées et des réponses) diffèrent. C'est ce que vous ferez dans le TP 3.

Activités

Activité 1 Préfixe et exclusion

Dans api-formation, ajoutez un SanteController (GET /sante → { statut: 'ok' }) exclu du préfixe api. Vérifiez que /sante répond et que /api/sante renvoie 404. Pourquoi une route de santé ne doit-elle pas exiger d'authentification ?

Voir le corrigé

app.setGlobalPrefix('api', { exclude: ['sante'] }). Une route de santé est appelée automatiquement par l'hébergeur, Docker ou un outil de surveillance pour savoir si l'application fonctionne ; ils n'ont pas de jeton. Elle ne doit donc rien révéler de sensible.

Activité 2 Passer en v1

Activez le versionnement URI avec defaultVersion: '1' dans api-formation. Quelles adresses changent ? Mettez à jour vos fichiers .http (une seule ligne si vous utilisez une variable @baseUrl).

Voir le corrigé

Toutes les routes passent de /api/… à /api/v1/…, sauf la route de santé si elle est déclarée VERSION_NEUTRAL. Avec @baseUrl = http://localhost:3000/api/v1, une seule ligne change : c'est l'intérêt des variables dans les fichiers .http.

Activité 3 Une v2 des groupes

Les groupes v1 renvoient [{ code, formationId, capacite }]. La v2 doit renvoyer { total, donnees: [{ code, formation: { id, intitule }, placesRestantes }] }. Écrivez GroupesV2Controller en réutilisant le même GroupesService, et déclarez GET :code pour les deux versions sans dupliquer la méthode.

Voir le corrigé
@Controller({ path: 'groupes', version: '2' })
export class GroupesV2Controller {
  constructor(private readonly groupesService: GroupesService) {}

  @Get()
  findAll() {
    const liste = this.groupesService.findAllDetailles();     // méthode ajoutée au service
    return { total: liste.length, donnees: liste };
  }
}

// Dans GroupesController (v1)
@Get(':code')
@Version(['1', '2'])            // même méthode pour les deux versions
findOne(@Param('code') code: string) {
  return this.groupesService.findOne(code);
}

Attention : si le contrôleur v1 déclare @Version(['1', '2']) sur :code, ne déclarez pas aussi :code dans le contrôleur v2, sinon deux routes identiques entreraient en concurrence.

Activité 4 Changement compatible ou non ?

Faut-il une nouvelle version pour : (a) ajouter le champ email dans la réponse d'un stagiaire ; (b) renommer nom en nomFamille; (c) ajouter GET /stagiaires/:id/absences; (d) rendre obligatoire le paramètre ?groupe; (e) remplacer le tableau renvoyé par un objet paginé ; (f) ajouter un paramètre facultatif ?tri?

Voir le corrigé

Nouvelle version pour (b), (d) et (e), qui cassent les clients existants. Pas de nouvelle version pour (a), (c) et (f), qui sont des ajouts compatibles : un client qui les ignore continue de fonctionner.

À retenir

  • setGlobalPrefix('api', { exclude: [...] }) préfixe les routes, sauf les exclues.
  • On versionne pour changer de format sans casser les anciens clients.
  • enableVersioning({ type: URI, defaultVersion: '1' }) → /api/v1/… ; @Controller({ version }) et @Version([...]).
  • VERSION_NEUTRAL : route sans version ; en-tête : prévoir [VERSION_NEUTRAL, '1'] pour les requêtes sans en-tête.
  • Même service pour toutes les versions ; seuls les contrôleurs diffèrent ; annoncer puis retirer l'ancienne version.