À propos de ce cours
Objectifs de la leçon
- Lire un ou plusieurs paramètres de route avec @Param.
- Convertir et valider un identifiant avec ParseIntPipe.
- Filtrer, trier et paginer une liste avec @Query.
- Donner des valeurs par défaut et convertir des booléens.
- Lire des en-têtes avec @Headers.
Paramètres de route, query string, en-têtes et corps : un décorateur pour chaque source.
Paramètre de route ou query string ?
| Paramètre de route | Query string | |
|---|---|---|
| Où ? | dans le chemin : /stagiaires/3 | après le ?: /stagiaires?groupe=DEV101 |
| Rôle | identifier une ressource | affiner une liste : filtre, recherche, tri, pagination |
| Obligatoire ? | oui (sinon la route ne correspond pas) | non, en général |
| Décorateur | @Param('id') | @Query('groupe') |
Une règle simple : si la réponse est une ressource précise, c'est un paramètre de route ; si c'est une liste filtrée, ce sont des paramètres de requête.
Le contrôleur d'exemple
📄 src/stagiaires.controller.ts
import {
Controller, DefaultValuePipe, Get, Headers, Ip, Param, ParseBoolPipe, ParseIntPipe, Query,
} from '@nestjs/common';
const STAGIAIRES = [
{ id: 1, nom: 'Ben Ali', prenom: 'Sami', groupe: 'DEV101', moyenne: 14, actif: true },
{ id: 2, nom: 'Trabelsi', prenom: 'Ines', groupe: 'DEV101', moyenne: 16.7, actif: true },
{ id: 3, nom: 'Gharbi', prenom: 'Yassine', groupe: 'DEV102', moyenne: 9.3, actif: false },
{ id: 4, nom: 'Mansouri', prenom: 'Lina', groupe: 'DEV102', moyenne: 13, actif: true },
{ id: 5, nom: 'Jlassi', prenom: 'Omar', groupe: 'INF101', moyenne: 11.5, actif: true },
];
@Controller()
export class StagiairesController {
// 1. Paramètre de route
@Get('stagiaires/:id')
findOne(@Param('id', ParseIntPipe) id: number) { // « 3 » → 3, sinon 400
return STAGIAIRES.find((s) => s.id === id) ?? { message: `Stagiaire ${id} introuvable` };
}
// 2. Plusieurs paramètres de route
@Get('groupes/:code/stagiaires/:id')
dansGroupe(@Param('code') code: string, @Param('id', ParseIntPipe) id: number) {
const s = STAGIAIRES.find((x) => x.id === id && x.groupe === code.toUpperCase());
return s ?? { message: `Aucun stagiaire ${id} dans le groupe ${code}` };
}
// 3. Tous les paramètres d'un coup
@Get('debug/:a/:b')
tous(@Param() params: Record<string, string>) {
return params;
}
// 4. Query string : filtres, recherche, tri, pagination
@Get('stagiaires')
findAll(
@Query('groupe') groupe?: string | string[],
@Query('q') recherche?: string,
@Query('actif', new DefaultValuePipe(true), ParseBoolPipe) actif?: boolean,
@Query('tri', new DefaultValuePipe('nom')) tri?: 'nom' | 'moyenne',
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page = 1,
@Query('parPage', new DefaultValuePipe(2), ParseIntPipe) parPage = 2,
) {
const groupes = groupe === undefined ? [] : ([] as string[]).concat(groupe).map((g) => g.toUpperCase());
let liste = STAGIAIRES.filter((s) => s.actif === actif);
if (groupes.length) liste = liste.filter((s) => groupes.includes(s.groupe));
if (recherche) liste = liste.filter((s) => `${s.nom} ${s.prenom}`.toLowerCase().includes(recherche.toLowerCase()));
liste = liste.toSorted((a, b) => (tri === 'moyenne' ? b.moyenne - a.moyenne : a.nom.localeCompare(b.nom)));
const debut = (page - 1) * parPage;
return {
total: liste.length,
page,
parPage,
donnees: liste.slice(debut, debut + parPage).map((s) => `${s.prenom} ${s.nom} (${s.groupe}, ${s.moyenne})`),
};
}
// 5. Toute la query string dans un objet
@Get('recherche')
recherche(@Query() query: Record<string, string | string[]>) {
return query;
}
// 6. En-têtes et adresse IP
@Get('client')
infosClient(
@Headers('accept-language') langue: string | undefined,
@Headers('x-cle-api') cle: string | undefined,
@Ip() ip: string,
) {
return { langue: langue?.slice(0, 2) ?? 'fr', cleFournie: Boolean(cle), ipLocale: ip.includes('127.0.0.1') || ip === '::1' };
}
}
1. Les paramètres de route
Requêtes envoyées et réponses réellement obtenues :
GET /api/stagiaires/3 → 200 {"id":3,"nom":"Gharbi","prenom":"Yassine","groupe":"DEV102","moyenne":9.3,"actif":false}
GET /api/stagiaires/abc → 400 {"message":"Validation failed (numeric string is expected)","error":"Bad Request","statusCode":400}
GET /api/groupes/dev101/stagiaires/2 → 200 {"id":2,"nom":"Trabelsi","prenom":"Ines","groupe":"DEV101","moyenne":16.7,"actif":true}
GET /api/debug/premier/second → 200 {"a":"premier","b":"second"}
- Tout ce qui vient de l'URL est d'abord une chaîne.
@Param('id', ParseIntPipe)la convertit en nombre… et renvoie automatiquement 400 si ce n'en est pas un : le contrôleur ne reçoit jamais « abc ». - Une route peut avoir plusieurs paramètres (
groupes/:code/stagiaires/:id), chacun lu par son propre@Param. @Param()sans argument renvoie un objet avec tous les paramètres.
Les pipes en avant-première: ParseIntPipe, ParseBoolPipe, ParseFloatPipe, DefaultValuePipe… sont des pipes intégrés à NestJS (leçon 5.5). Ils sont étudiés en détail au chapitre 08 ; on les utilise dès maintenant car ils évitent de nombreux bugs.
2. La query string : filtrer, rechercher, trier, paginer
Requêtes envoyées et réponses réellement obtenues :
GET /api/stagiaires → 200 {"total":4,"page":1,"parPage":2,"donnees":["Sami Ben Ali (DEV101, 14)","Omar Jlassi (INF101, 11.5)"]}
GET /api/stagiaires?page=2 → 200 {"total":4,"page":2,"parPage":2,"donnees":["Lina Mansouri (DEV102, 13)","Ines Trabelsi (DEV101, 16.7)"]}
GET /api/stagiaires?groupe=dev102&tri=moyenne → 200 {"total":1,"page":1,"parPage":2,"donnees":["Lina Mansouri (DEV102, 13)"]}
GET /api/stagiaires?groupe=DEV101&groupe=INF101&parPage=10 → 200 {"total":3,"page":1,"parPage":10,"donnees":["Sami Ben Ali (DEV101, 14)","Omar Jlassi (INF101, 11.5)","Ines Trabelsi (DEV101, 16.7)"]}
GET /api/stagiaires?q=man&actif=true → 200 {"total":1,"page":1,"parPage":2,"donnees":["Lina Mansouri (DEV102, 13)"]}
GET /api/stagiaires?actif=false → 200 {"total":1,"page":1,"parPage":2,"donnees":["Yassine Gharbi (DEV102, 9.3)"]}
GET /api/stagiaires?actif=peut-etre → 400 {"message":"Validation failed (boolean string is expected)","error":"Bad Request","statusCode":400}
- Valeur absente:
@Query('q')vautundefined.new DefaultValuePipe(1)fournit une valeur par défaut, placé avant le pipe de conversion. - Booléens:
?actif=falsedonne la chaîne'false'… qui est « vraie » en JavaScript !ParseBoolPipela convertit correctement et refuse les autres valeurs (400 pour « peut-etre »). - Valeurs multiples:
?groupe=DEV101&groupe=INF101donne un tableau, alors que?groupe=DEV101donne une chaîne. D'où le typestring | string[]et la normalisation[].concat(groupe). - Pagination:
pageetparPage, avec le total renvoyé pour que le client puisse afficher le nombre de pages.
3. Toute la query string dans un objet
Réponse obtenue :
GET /api/recherche?tag=nestjs&tag=api&niveau=2&filtre[groupe]=DEV101 → 200 {"tag":["nestjs","api"],"niveau":"2","filtre[groupe]":"DEV101"}
@Query() sans argument renvoie un objet avec toutes les valeurs, toujours sous forme de chaînes (niveau vaut "2"). Remarquez aussi que filtre[groupe]=DEV101 n'est pas transformé en objet imbriqué : depuis Express 5 (utilisé par NestJS 11), la query string est analysée en mode « simple ». Préférez des noms plats (?groupe=DEV101) ; au chapitre 08, un DTO validé transformera ces chaînes en vrais types.
4. Les en-têtes et l'adresse IP
Réponse (requête envoyée avec Accept-Language: ar-TN et X-Cle-Api: secret) :
GET /api/client → 200 {"langue":"ar","cleFournie":true,"ipLocale":true}
@Headers('accept-language')lit un en-tête. Les noms d'en-têtes sont insensibles à la casse; écrivez-les en minuscules dans@Headers.@Headers()sans argument donne l'objet de tous les en-têtes.@Ip()donne l'adresse IP du client (derrière un proxy comme Nginx, il faudra configurer Express pour lire l'en-têteX-Forwarded-For: chapitre 20).
Ne mettez jamais de données sensibles (mot de passe, jeton) dans l'URL : elle est enregistrée dans les journaux des serveurs et l'historique du navigateur. Les jetons voyagent dans un en-tête (Authorization), les mots de passe dans le corps d'un POST.
Activités
Activité 1 Route ou query ?
Pour chaque besoin, proposez l'URL : (a) le groupe DEV102 ; (b) les groupes de la formation 1 ; (c) les stagiaires dont le nom contient « ben », triés par moyenne ; (d) les absences du stagiaire 7 ; (e) la page 3 des absences du stagiaire 7, 20 par page ; (f) les notes du stagiaire 7 en NestJS.
Voir le corrigé
(a) /api/groupes/DEV102; (b) /api/groupes?formation=1 (ou /api/formations/1/groupes) ; (c) /api/stagiaires?q=ben&tri=moyenne; (d) /api/stagiaires/7/absences; (e) /api/stagiaires/7/absences?page=3&parPage=20; (f) /api/stagiaires/7/notes?matiere=nestjs.
Activité 2 Sécuriser un identifiant
Dans le CRUD des formations (leçon 6.2), remplacez les Number(id) par ParseIntPipe. Comparez la réponse à GET /api/formations/abc avant et après. Que devient le type du paramètre dans la signature ?
Voir le corrigé
Avant : 404 « Formation NaN introuvable » (message trompeur). Après : 400 « Validation failed (numeric string is expected) ». La signature devient findOne(@Param('id', ParseIntPipe) id: number): le paramètre est un vrai nombre, plus besoin de conversion dans le contrôleur.
Activité 3 Filtres booléens
Ajoutez au GET /api/formations un filtre facultatif ?publiee=true|false. Sans filtre, toutes les formations sont renvoyées. Pourquoi ne pas utiliser DefaultValuePipe ici ? Comment rendre ParseBoolPipe facultatif ?
Voir le corrigé
@Get()
findAll(@Query('publiee', new ParseBoolPipe({ optional: true })) publiee?: boolean) {
const liste = this.formationsService.findAll();
return publiee === undefined ? liste : liste.filter((f) => f.publiee === publiee);
}
Une valeur par défaut imposerait un filtre alors qu'on veut « tout » quand le paramètre est absent. L'option optional: true laisse passer undefined sans erreur.
Activité 4 Pagination réutilisable
Écrivez dans le service une méthode générique paginer<T>(liste: T[], page: number, parPage: number) qui renvoie { total, page, parPage, pages, donnees }, et utilisez-la dans GET /api/formations?page=&parPage=. Limitez parPage à 50 au maximum.
Voir le corrigé
paginer<T>(liste: T[], page: number, parPage: number) {
const taille = Math.min(Math.max(parPage, 1), 50);
const pages = Math.max(Math.ceil(liste.length / taille), 1);
const courante = Math.min(Math.max(page, 1), pages);
const debut = (courante - 1) * taille;
return { total: liste.length, page: courante, parPage: taille, pages, donnees: liste.slice(debut, debut + taille) };
}
// contrôleur
@Get()
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('parPage', new DefaultValuePipe(10), ParseIntPipe) parPage: number,
) {
return this.formationsService.paginer(this.formationsService.findAll(), page, parPage);
}
Les bornes protègent l'API : un client qui demande parPage=100000 ne fera pas charger toute la base. Le générique <T> (leçon 4.6) permet de réutiliser la méthode pour toutes les ressources.
Activité 5 Une clé d'API dans un en-tête
Ajoutez une route GET /api/formations/export qui exige l'en-tête X-Cle-Api égal à formation-2026 et renvoie 401 sinon. Où ce contrôle devrait-il être placé dans une vraie application ?
Voir le corrigé
@Get('export')
exporter(@Headers('x-cle-api') cle?: string) {
if (cle !== 'formation-2026') throw new UnauthorizedException('Clé d\'API absente ou invalide');
return this.formationsService.findAll();
}
Ce contrôle d'accès devrait être dans un guard (chapitres 09 et 13), réutilisable sur plusieurs routes, et la clé dans le fichier .env (chapitre 10), jamais écrite dans le code.
À retenir
- Paramètre de route (@Param) pour identifier une ressource, query (@Query) pour affiner une liste.
- Tout ce qui vient de l'URL est une chaîne : ParseIntPipe, ParseBoolPipe convertissent et renvoient 400 si besoin.
- DefaultValuePipe fournit une valeur par défaut ; { optional: true } accepte l'absence.
- Un paramètre répété donne un tableau ; les noms imbriqués (a[b]) ne sont pas analysés par Express 5.
- @Headers('nom-en-minuscules') lit un en-tête ; jamais de secret dans l'URL.