À propos de ce cours
Objectifs de la leçon
- Expliquer le rôle d'un contrôleur dans une application NestJS.
- Créer un contrôleur avec la CLI et l'enregistrer dans un module.
- Comprendre comment NestJS transforme la valeur renvoyée en réponse HTTP.
- Écrire des contrôleurs minces qui délèguent au service.
Le rôle d'un contrôleur
Un contrôleur est le guichet HTTP d'une ressource. Il reçoit les requêtes qui la concernent (/formations, /stagiaires…), en extrait les données utiles (paramètres, corps, en-têtes), appelle le service approprié et renvoie le résultat. NestJS se charge du reste : choisir la bonne méthode selon l'URL et le verbe HTTP, convertir le résultat en JSON, fixer le code de statut, transformer les exceptions en réponses d'erreur.
Préfixe, service injecté, décorateur de route, paramètre et valeur renvoyée.
Créer un contrôleur
nest g co formations # crée src/formations/formations.controller.ts (+ .spec.ts)
nest g co formations --no-spec # sans fichier de test
nest g co formations --flat # directement dans src/, sans sous-dossier
La CLI crée la classe et l'ajoute automatiquement au tableau controllers du module le plus proche (formations.module.ts s'il existe, sinon app.module.ts). Un contrôleur qui n'est déclaré dans aucun module n'existe pas pour NestJS : ses routes renvoient 404.
// formations.controller.ts (généré)
import { Controller } from '@nestjs/common';
@Controller('formations')
export class FormationsController {}
// formations.module.ts
@Module({
controllers: [FormationsController], // ← indispensable
providers: [FormationsService],
})
export class FormationsModule {}
Le décorateur @Controller
| Écriture | Effet |
|---|---|
@Controller() | pas de préfixe : les routes partent de la racine (ou du préfixe global) |
@Controller('formations') | toutes les routes commencent par /formations |
@Controller(['formations', 'cursus']) | deux préfixes équivalents |
@Controller({ path: 'formations', version: '2' }) | préfixe et version de l'API (leçon 6.6) |
Par convention, le préfixe est le nom de la ressource au pluriel, en minuscules et en kebab-case (groupes-formation), et la classe porte le même nom suivi de Controller.
Ce que NestJS fait de la valeur renvoyée
Contrairement à Express, on n'écrit pas res.json(…): on renvoie une valeur, et NestJS construit la réponse. Démonstration avec un contrôleur qui renvoie toutes sortes de valeurs :
📄 src/demo.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Observable, of } from 'rxjs';
@Controller('demo')
export class DemoController {
@Get('texte')
texte(): string {
return 'Bonjour depuis NestJS'; // chaîne → envoyée telle quelle
}
@Get('objet')
objet() {
return { formation: 'NestJS', heures: 120 }; // objet → JSON
}
@Get('tableau')
tableau() {
return ['DEV101', 'DEV102']; // tableau → JSON
}
@Get('nombre')
nombre(): number {
return 42; // nombre → JSON
}
@Get('promesse')
async promesse() {
await new Promise((r) => setTimeout(r, 50)); // NestJS attend la promesse
return { charge: true };
}
@Get('observable')
observable(): Observable<string[]> {
return of(['a', 'b']); // NestJS s'abonne à l'Observable
}
@Get('rien')
rien(): void {} // pas de valeur → réponse vide
}
Requêtes envoyées et réponses réellement obtenues :
GET /api/demo/texte → 200 Bonjour depuis NestJS [content-type: text/html; charset=utf-8]
GET /api/demo/objet → 200 {"formation":"NestJS","heures":120} [content-type: application/json; charset=utf-8]
GET /api/demo/tableau → 200 ["DEV101","DEV102"] [content-type: application/json; charset=utf-8]
GET /api/demo/nombre → 200 42 [content-type: text/html; charset=utf-8]
GET /api/demo/promesse → 200 {"charge":true} [content-type: application/json; charset=utf-8]
GET /api/demo/observable → 200 ["a","b"] [content-type: application/json; charset=utf-8]
GET /api/demo/rien → 200 [content-type: null]
- Objets et tableaux sont convertis en JSON, avec l'en-tête
Content-Type: application/json. - Chaînes et nombres sont envoyés tels quels : le type de contenu est alors
text/html. Pour une API, préférez renvoyer un objet ({ message: '…' }) : le client saura toujours qu'il reçoit du JSON. - Une promesse (méthode
async) est attendue ; un Observable RxJS est écouté jusqu'à sa dernière valeur. - Une méthode qui ne renvoie rien produit une réponse vide avec le statut 200 (204 serait plus juste : leçon 6.5).
- Le statut est 200 par défaut, 201 pour
@Post().
Et si j'ai besoin de l'objet réponse d'Express ?
On peut injecter l'objet réponse d'Express avec @Res(). Mais NestJS considère alors que vous gérez la réponse : la valeur renvoyée est ignorée, et les intercepteurs ne s'appliquent plus. Pour simplement ajouter un en-tête tout en laissant NestJS envoyer la réponse, utilisez l'option passthrough:
import type { Response } from 'express';
@Get()
findAll(@Res({ passthrough: true }) res: Response) {
res.setHeader('X-Total-Count', '42'); // on complète la réponse…
return this.formationsService.findAll(); // …et NestJS l'envoie normalement
}
import type { Response } from 'express': le mot type indique qu'on importe uniquement un type, effacé à la compilation. C'est nécessaire avec les options de compilation récentes (isolatedModules) quand un type est utilisé dans les paramètres d'une méthode décorée.
Un contrôleur mince
Au chapitre 05, nous avons vu qu'un contrôleur ne contient ni calcul ni règle métier. Chaque méthode suit le même schéma en trois temps :
- lire les données de la requête (décorateurs de paramètre) ;
- appeler une méthode du service ;
- renvoyer son résultat.
@Controller('formations')
export class FormationsController {
constructor(private readonly formationsService: FormationsService) {}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { // 1. lire
return this.formationsService.findOne(id); // 2. appeler + 3. renvoyer
}
}
Un module peut contenir plusieurs contrôleurs (par exemple FormationsController et FormationsAdminController), qui partagent les mêmes services.
Activités
Activité 1 Générer et enregistrer
Dans api-formation, générez un contrôleur salles sans module (nest g co salles --no-spec). Dans quel module la CLI l'a-t-elle déclaré ? Ajoutez une méthode @Get() qui renvoie [{ code: 'S1', places: 24 }] et vérifiez GET /api/salles. Supprimez ensuite la déclaration du module : que renvoie la même requête ?
Voir le corrigé
Sans SallesModule, la CLI déclare le contrôleur dans AppModule. La route renvoie le tableau en JSON. Sans déclaration, le terminal n'affiche plus RoutesResolver SallesController et la requête renvoie 404 « Cannot GET /api/salles ».
Activité 2 Texte ou JSON ?
Ajoutez deux routes : GET /api/salles/message qui renvoie la chaîne « Salles disponibles » et GET /api/salles/message-json qui renvoie { message: 'Salles disponibles' }. Comparez l'en-tête Content-Type des deux réponses (Thunder Client, onglet Headers). Laquelle préférer pour une API, et pourquoi ?
Voir le corrigé
La première renvoie text/html; charset=utf-8, la seconde application/json; charset=utf-8. Pour une API, on renvoie toujours du JSON : le client (Angular, Flutter) peut traiter toutes les réponses de la même façon, et ajouter plus tard d'autres champs ne casse rien.
Activité 3 Promesse et délai
Écrivez une route GET /api/salles/disponibilites, méthode async, qui attend 300 ms (simulation d'une requête en base) puis renvoie { salles: 3, libres: 1 }. Mesurez le temps de réponse dans votre client d'API. Que se passe-t-il pour une autre requête envoyée pendant ces 300 ms ?
Voir le corrigé
La réponse arrive après environ 300 ms. Une autre requête envoyée pendant l'attente est traitée immédiatement : l'attente est asynchrone et ne bloque pas Node.js (leçon 4.3). Si l'on remplaçait l'attente par une boucle de calcul de 300 ms, toutes les requêtes seraient bloquées.
Activité 4 Ajouter un en-tête sans perdre NestJS
Sur GET /api/salles, ajoutez l'en-tête X-Nombre-Salles. Faites-le une fois avec @Res() sans passthrough en gardant le return: que reçoit le client ? Corrigez.
Voir le corrigé
Avec @Res() seul, NestJS n'envoie plus la valeur renvoyée : la requête reste en attente jusqu'au délai maximum du client, car personne n'appelle res.json(). Correction : @Res({ passthrough: true }) res: Response, puis res.setHeader('X-Nombre-Salles', '1') et return [...].
Activité 5 Repérer le contrôleur obèse
Réécrivez cette méthode pour qu'elle respecte le schéma « lire, appeler, renvoyer » :
@Get('taux-occupation')
taux() {
const salles = [{ code: 'S1', places: 24, occupees: 20 }, { code: 'S2', places: 16, occupees: 4 }];
const total = salles.reduce((t, s) => t + s.places, 0);
const occupees = salles.reduce((t, s) => t + s.occupees, 0);
return { taux: Math.round((occupees / total) * 100) + '%' };
}
Voir le corrigé
// salles.service.ts
@Injectable()
export class SallesService {
private salles = [{ code: 'S1', places: 24, occupees: 20 }, { code: 'S2', places: 16, occupees: 4 }];
tauxOccupation() {
const total = this.salles.reduce((t, s) => t + s.places, 0);
const occupees = this.salles.reduce((t, s) => t + s.occupees, 0);
return { taux: Math.round((occupees / total) * 100) }; // un nombre : le client le formate
}
}
// salles.controller.ts
@Get('taux-occupation')
taux() {
return this.sallesService.tauxOccupation();
}
Le calcul devient réutilisable (tableau de bord, export) et testable sans HTTP. On renvoie aussi un nombre plutôt que « 60% » : la mise en forme est l'affaire du front-end.
À retenir
- Un contrôleur reçoit les requêtes d'une ressource et délègue au service.
- @Controller('ressources') fixe le préfixe ; le contrôleur doit être déclaré dans un module.
- On renvoie une valeur : objet/tableau → JSON, chaîne/nombre → texte, promesse/Observable → attendus.
- Statut 200 par défaut, 201 pour POST.
- @Res() fait perdre la gestion automatique : préférer @Res({ passthrough: true }).