À propos de ce cours
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.
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/stagiairessans 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/2n'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égie | Exemple | Avantages | Inconvénients |
|---|---|---|---|
| URI | /api/v2/stagiaires | visible, testable dans un navigateur, cache simple | URL qui changent |
| En-tête | X-Api-Version: 2 | URL stables | invisible, plus difficile à tester et à mettre en cache |
| Type de média | Accept: application/json;v=2 | conforme à HTTP | complexe 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
- Publier la v2 et la documenter (Swagger, chapitre 14).
- Annoncer la fin de la v1, par exemple avec un en-tête
Deprecation: trueetSunset: <date>sur ses réponses (un intercepteur, chapitre 09, peut l'ajouter à toutes les routes v1). - Surveiller dans les journaux que plus aucun client n'appelle la v1.
- 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.