À propos de ce cours
Objectifs du TP
- Découper une API en modules de fonctionnalité reliés par
exportsetimports. - Créer un module partagé, un module dynamique et global.
- Brancher un middleware et utiliser les événements du cycle de vie.
- Travailler sur une branche Git et livrer une nouvelle version.
Durée: 3 h | Prérequis: TP 1, leçons 5.1 à 5.6 | Livrable: le dépôt api-formation avec l'étiquette v0.2.0.
Les modules à créer et leurs relations.
Contexte
L'API du TP 1 tient dans un seul module. L'établissement souhaite maintenant consulter ses formations, ses groupes (chacun rattaché à une formation) et ses stagiaires (chacun dans un groupe), avec un journal de toutes les requêtes. Les données restent en mémoire : la base de données arrivera au chapitre 11.
| Route | Réponse |
|---|---|
GET /api | « Bienvenue sur l'API de <établissement> » |
GET /api/infos | paramètres de l'application et nombre de messages journalisés |
GET /api/formations, GET /api/formations/:id | formations |
GET /api/groupes, GET /api/groupes/:code | groupes avec l'intitulé de leur formation |
GET /api/stagiaires?groupe=…, GET /api/stagiaires/:id | stagiaires (filtre facultatif), un stagiaire avec son groupe détaillé |
Partie 1 Préparer la branche de travail
- Dans votre dépôt
api-formation(TP 1), placez-vous surmainà jour :git switch mainpuisgit pull. - Créez la branche du TP :
git switch -c tp2-modules. - Lancez l'application et vérifiez que les routes du TP 1 fonctionnent toujours.
- Générez les modules avec la CLI (sans fichiers de test) :
nest g mo common nest g s common/journal --flat --no-spec nest g mo formations && nest g co formations --no-spec && nest g s formations --no-spec nest g mo groupes && nest g co groupes --no-spec && nest g s groupes --no-spec nest g mo stagiaires && nest g co stagiaires --no-spec && nest g s stagiaires --no-spec - Commit :
chore: génération des modules, puisgit push -u origin tp2-modules.
Partie 2 Le module partagé CommonModule
- Écrivez
JournalServiceavec une méthodeecrire(contexte, message)qui affiche[hh:mm:ss] [contexte] messageet un accesseurnombreMessages. CommonModuledéclare et exporteJournalService.- Écrivez
src/common/journal.middleware.ts: un middleware qui, à la fin de chaque réponse (événementres.on('finish')), écritGET /api/groupes → 200 (3 ms)avec le contexteHTTP. - Branchez ce middleware sur toutes les routes dans
AppModule(méthodeconfigure). - Commit :
feat: module commun et journal des requêtes.
Un middleware est une classe @Injectable(): il peut recevoir JournalService dans son constructeur, à condition que le module qui le branche ait accès à ce service.
Partie 3 Le module dynamique ParametresModule
- Créez
src/parametres/avecparametres.constants.ts(interfaceParametresOptions: établissement, année scolaire, version ; jetonPARAMETRES_OPTIONS),parametres.service.tsetparametres.module.ts. ParametresModuleest global et exposeforRoot(options).ParametresServiceinjecte les options avec@Inject(PARAMETRES_OPTIONS)et propose l'accesseuretablissementet la méthodeinfos().- Importez
ParametresModule.forRoot({ … })dansAppModuleet modifiezAppController:GET /apiutilise l'établissement,GET /api/infosrenvoie les paramètres etnombreMessages. - Commit :
feat: module de paramètres dynamique.
Partie 4 Formations et groupes
FormationsServicecharge deux formations dansonModuleInitet l'écrit dans le journal (« 2 formations chargées ») ;findOne(id)lèveNotFoundExceptionsi besoin.FormationsModuleimporteCommonModuleet exporteFormationsService.GroupesServicecontient trois groupes (code,formationId,capacite) et renvoie chaque groupe complété par l'intitulé de sa formation. La recherche par code ignore la casse.GroupesModuleimporteFormationsModuleet exporteGroupesService.- Écrivez les deux contrôleurs.
- Commit :
feat: modules formations et groupes.
Partie 5 Stagiaires et cycle de vie
StagiairesServicecontient trois stagiaires ;findAll(groupe?)filtre par groupe (insensible à la casse) ;findOne(id)renvoie le stagiaire avec l'objet groupe complet, obtenu parGroupesService.StagiairesModuleimporteGroupesModule.- Le contrôleur lit le filtre avec
@Query('groupe'). AppModuleimplémenteOnApplicationShutdownet écrit « API arrêtée (signal) » dans le journal ;main.tsappelleenableShutdownHooks().- Créez
requetes/tp2.http(voir ci-dessous) et testez toutes les routes. - Commit :
feat: module stagiaires et événements du cycle de vie.
@baseUrl = http://localhost:3000/api
### Accueil
GET {{baseUrl}}
### Informations (module dynamique)
GET {{baseUrl}}/infos
### Formations
GET {{baseUrl}}/formations
### Une formation
GET {{baseUrl}}/formations/2
### Groupes (avec l'intitulé de la formation)
GET {{baseUrl}}/groupes
### Un groupe (code insensible à la casse)
GET {{baseUrl}}/groupes/dev102
### Stagiaires d'un groupe
GET {{baseUrl}}/stagiaires?groupe=dev101
### Un stagiaire avec son groupe détaillé
GET {{baseUrl}}/stagiaires/3
### Stagiaire inexistant : 404
GET {{baseUrl}}/stagiaires/9
Partie 6 Analyser, documenter et livrer
- Analyse: retirez temporairement
exports: [GroupesService]deGroupesModule, relancez, copiez le message d'erreur dans un fichierARCHITECTURE.mdet expliquez-le en deux phrases. Rétablissez la ligne. - Dans
ARCHITECTURE.md, dessinez (en texte ou en image) l'arbre des modules et indiquez pour chacun ce qu'il exporte. - Mettez à jour le
README.md: tableau des routes, commande de lancement. - Vérifiez :
npm run verifier(lint, tests, build). - Commit
docs: architecture et README, push, puis ouvrez une Pull Request detp2-modulesversmainsur GitHub et fusionnez-la. - Sur
mainà jour :git tag -a v0.2.0 -m "TP 2 : architecture en modules"etgit push origin v0.2.0.
Les tests générés au TP 1 (app.controller.spec.ts, test e2e) attendent l'ancien texte d'accueil et ne connaissent pas ParametresService: npm run verifier échouera. Mettez-les à jour (texte attendu, et imports: [ParametresModule.forRoot({ … }), CommonModule] dans le module de test) : c'est un bon entraînement avant le chapitre 17.
Résultat attendu
Journal du terminal et réponses obtenues avec la correction (le préfixe >>> marque les réponses reçues par le client) :
[22:17:28] [Formations] 2 formations chargées
[22:17:28] [HTTP] GET /api → 200 (6 ms)
>>> GET /api → 200 Bienvenue sur l'API de Institut de formation de Tunis
[22:17:28] [HTTP] GET /api/formations → 200 (1 ms)
>>> GET /api/formations → 200 [{"id":1,"intitule":"Développement digital","heures":1200},{"id":2,"intitule":"Infrastructure digitale","heures":1100}]
[22:17:28] [HTTP] GET /api/groupes/dev102 → 200 (1 ms)
>>> GET /api/groupes/dev102 → 200 {"code":"DEV102","formationId":1,"capacite":20,"formation":"Développement digital"}
[22:17:28] [HTTP] GET /api/stagiaires?groupe=dev101 → 200 (1 ms)
>>> GET /api/stagiaires?groupe=dev101 → 200 [{"id":1,"nom":"Ben Ali","prenom":"Sami","groupe":"DEV101"},{"id":2,"nom":"Trabelsi","prenom":"Ines","groupe":"DEV101"}]
[22:17:28] [HTTP] GET /api/stagiaires/3 → 200 (1 ms)
>>> GET /api/stagiaires/3 → 200 {"id":3,"nom":"Gharbi","prenom":"Yassine","groupe":{"code":"INF101","formationId":2,"capacite":18,"formation":"Infrastructure digitale"}}
[22:17:28] [HTTP] GET /api/stagiaires/9 → 404 (1 ms)
>>> GET /api/stagiaires/9 → 404 {"message":"Stagiaire 9 introuvable","error":"Not Found","statusCode":404}
[22:17:28] [HTTP] GET /api/infos → 200 (1 ms)
>>> GET /api/infos → 200 {"application":"api-formation","etablissement":"Institut de formation de Tunis","anneeScolaire":"2026-2027","version":"0.2.0","messagesJournal":7}
[22:17:28] [App] API arrêtée (arrêt manuel)
Et l'erreur de la Partie 6 :
Nest can't resolve dependencies of the StagiairesService (?). Please make sure that the argument GroupesService at index [0] is available in the StagiairesModule module.
Pour aller plus loin
- Ajoutez
GET /api/groupes/:code/stagiaires. Dans quel module placer cette route sans créer d'import circulaire entre groupes et stagiaires ? - Ajoutez dans
ParametresService.onModuleInitla vérification du format de l'année scolaire (activité 3 de la leçon 5.6). - Ajoutez un module
StatistiquesModulequi importe les trois modules et exposeGET /api/statistiques(nombre de formations, de groupes, de stagiaires par groupe), calculé dansonApplicationBootstrappuis mis en cache.
Correction
src/common/journal.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class JournalService {
private compteur = 0;
ecrire(contexte: string, message: string): void {
this.compteur++;
const heure = new Date().toLocaleTimeString('fr-FR');
console.log(`[${heure}] [${contexte}] ${message}`);
}
get nombreMessages(): number {
return this.compteur;
}
}
src/common/journal.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { JournalService } from './journal.service';
@Injectable()
export class JournalMiddleware implements NestMiddleware {
constructor(private readonly journal: JournalService) {}
use(req: Request, res: Response, next: NextFunction) {
const debut = Date.now();
res.on('finish', () => {
this.journal.ecrire('HTTP', `${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - debut} ms)`);
});
next();
}
}
src/common/common.module.ts
import { Module } from '@nestjs/common';
import { JournalService } from './journal.service';
@Module({
providers: [JournalService],
exports: [JournalService],
})
export class CommonModule {}
src/parametres/parametres.constants.ts
export interface ParametresOptions {
etablissement: string;
anneeScolaire: string;
version: string;
}
export const PARAMETRES_OPTIONS = 'PARAMETRES_OPTIONS';
src/parametres/parametres.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { PARAMETRES_OPTIONS, ParametresOptions } from './parametres.constants';
@Injectable()
export class ParametresService {
constructor(@Inject(PARAMETRES_OPTIONS) private readonly options: ParametresOptions) {}
get etablissement(): string {
return this.options.etablissement;
}
infos() {
return { application: 'api-formation', ...this.options };
}
}
src/parametres/parametres.module.ts
import { DynamicModule, Global, Module } from '@nestjs/common';
import { PARAMETRES_OPTIONS, ParametresOptions } from './parametres.constants';
import { ParametresService } from './parametres.service';
@Global()
@Module({})
export class ParametresModule {
static forRoot(options: ParametresOptions): DynamicModule {
return {
module: ParametresModule,
providers: [{ provide: PARAMETRES_OPTIONS, useValue: options }, ParametresService],
exports: [ParametresService],
};
}
}
src/formations/formations.service.ts
import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
import { JournalService } from '../common/journal.service';
export interface Formation {
id: number;
intitule: string;
heures: number;
}
@Injectable()
export class FormationsService implements OnModuleInit {
private formations: Formation[] = [];
constructor(private readonly journal: JournalService) {}
onModuleInit() {
this.formations = [
{ id: 1, intitule: 'Développement digital', heures: 1200 },
{ id: 2, intitule: 'Infrastructure digitale', heures: 1100 },
];
this.journal.ecrire('Formations', `${this.formations.length} formations chargées`);
}
findAll(): Formation[] {
return this.formations;
}
findOne(id: number): Formation {
const f = this.formations.find((x) => x.id === id);
if (!f) throw new NotFoundException(`Formation ${id} introuvable`);
return f;
}
}
src/formations/formations.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { FormationsService } from './formations.service';
@Controller('formations')
export class FormationsController {
constructor(private readonly formationsService: FormationsService) {}
@Get()
findAll() {
return this.formationsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.formationsService.findOne(Number(id));
}
}
src/formations/formations.module.ts
import { Module } from '@nestjs/common';
import { CommonModule } from '../common/common.module';
import { FormationsController } from './formations.controller';
import { FormationsService } from './formations.service';
@Module({
imports: [CommonModule],
controllers: [FormationsController],
providers: [FormationsService],
exports: [FormationsService],
})
export class FormationsModule {}
src/groupes/groupes.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { FormationsService } from '../formations/formations.service';
export interface Groupe {
code: string;
formationId: number;
capacite: number;
}
@Injectable()
export class GroupesService {
private groupes: Groupe[] = [
{ code: 'DEV101', formationId: 1, capacite: 24 },
{ code: 'DEV102', formationId: 1, capacite: 20 },
{ code: 'INF101', formationId: 2, capacite: 18 },
];
constructor(private readonly formationsService: FormationsService) {}
private detailler(g: Groupe) {
const { intitule } = this.formationsService.findOne(g.formationId);
return { ...g, formation: intitule };
}
findAll() {
return this.groupes.map((g) => this.detailler(g));
}
findOne(code: string) {
const g = this.groupes.find((x) => x.code === code.toUpperCase());
if (!g) throw new NotFoundException(`Groupe ${code} introuvable`);
return this.detailler(g);
}
}
src/groupes/groupes.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { GroupesService } from './groupes.service';
@Controller('groupes')
export class GroupesController {
constructor(private readonly groupesService: GroupesService) {}
@Get()
findAll() {
return this.groupesService.findAll();
}
@Get(':code')
findOne(@Param('code') code: string) {
return this.groupesService.findOne(code);
}
}
src/groupes/groupes.module.ts
import { Module } from '@nestjs/common';
import { FormationsModule } from '../formations/formations.module';
import { GroupesController } from './groupes.controller';
import { GroupesService } from './groupes.service';
@Module({
imports: [FormationsModule],
controllers: [GroupesController],
providers: [GroupesService],
exports: [GroupesService],
})
export class GroupesModule {}
src/stagiaires/stagiaires.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { GroupesService } from '../groupes/groupes.service';
export interface Stagiaire {
id: number;
nom: string;
prenom: string;
groupe: string;
}
@Injectable()
export class StagiairesService {
private stagiaires: Stagiaire[] = [
{ id: 1, nom: 'Ben Ali', prenom: 'Sami', groupe: 'DEV101' },
{ id: 2, nom: 'Trabelsi', prenom: 'Ines', groupe: 'DEV101' },
{ id: 3, nom: 'Gharbi', prenom: 'Yassine', groupe: 'INF101' },
];
constructor(private readonly groupesService: GroupesService) {}
findAll(groupe?: string): Stagiaire[] {
return groupe ? this.stagiaires.filter((s) => s.groupe === groupe.toUpperCase()) : this.stagiaires;
}
findOne(id: number) {
const s = this.stagiaires.find((x) => x.id === id);
if (!s) throw new NotFoundException(`Stagiaire ${id} introuvable`);
return { ...s, groupe: this.groupesService.findOne(s.groupe) };
}
}
src/stagiaires/stagiaires.controller.ts
import { Controller, Get, Param, Query } from '@nestjs/common';
import { StagiairesService } from './stagiaires.service';
@Controller('stagiaires')
export class StagiairesController {
constructor(private readonly stagiairesService: StagiairesService) {}
@Get()
findAll(@Query('groupe') groupe?: string) {
return this.stagiairesService.findAll(groupe);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.stagiairesService.findOne(Number(id));
}
}
src/stagiaires/stagiaires.module.ts
import { Module } from '@nestjs/common';
import { GroupesModule } from '../groupes/groupes.module';
import { StagiairesController } from './stagiaires.controller';
import { StagiairesService } from './stagiaires.service';
@Module({
imports: [GroupesModule],
controllers: [StagiairesController],
providers: [StagiairesService],
})
export class StagiairesModule {}
src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { JournalService } from './common/journal.service';
import { ParametresService } from './parametres/parametres.service';
@Controller()
export class AppController {
constructor(
private readonly parametres: ParametresService,
private readonly journal: JournalService,
) {}
@Get()
accueil(): string {
return `Bienvenue sur l'API de ${this.parametres.etablissement}`;
}
@Get('infos')
infos() {
return { ...this.parametres.infos(), messagesJournal: this.journal.nombreMessages };
}
}
src/app.module.ts
import { MiddlewareConsumer, Module, NestModule, OnApplicationShutdown } from '@nestjs/common';
import { AppController } from './app.controller';
import { CommonModule } from './common/common.module';
import { JournalMiddleware } from './common/journal.middleware';
import { JournalService } from './common/journal.service';
import { FormationsModule } from './formations/formations.module';
import { GroupesModule } from './groupes/groupes.module';
import { ParametresModule } from './parametres/parametres.module';
import { StagiairesModule } from './stagiaires/stagiaires.module';
@Module({
imports: [
ParametresModule.forRoot({
etablissement: 'Institut de formation de Tunis',
anneeScolaire: '2026-2027',
version: '0.2.0',
}),
CommonModule,
FormationsModule,
GroupesModule,
StagiairesModule,
],
controllers: [AppController],
})
export class AppModule implements NestModule, OnApplicationShutdown {
constructor(private readonly journal: JournalService) {}
configure(consumer: MiddlewareConsumer) {
consumer.apply(JournalMiddleware).forRoutes('*');
}
onApplicationShutdown(signal?: string) {
this.journal.ecrire('App', `API arrêtée (${signal ?? 'arrêt manuel'})`);
}
}
src/main.ts
import { Logger } 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');
app.enableCors({ origin: ['http://localhost:4200'] });
app.enableShutdownHooks();
const port = process.env.PORT ?? 3000;
await app.listen(port);
Logger.log(`API api-formation démarrée sur http://localhost:${port}/api`, 'Bootstrap');
}
bootstrap();
Grille d'évaluation
| Critère | Points |
|---|---|
Branche tp2-modules, commits clairs à chaque partie, Pull Request fusionnée, étiquette v0.2.0 | 3 |
| CommonModule partagé et middleware de journalisation fonctionnel | 3 |
| ParametresModule dynamique et global (forRoot, jeton, @Inject) | 4 |
| Modules formations, groupes, stagiaires : exports/imports corrects, aucun provider redéclaré | 5 |
| onModuleInit et onApplicationShutdown, enableShutdownHooks | 2 |
| requetes/tp2.http, ARCHITECTURE.md (arbre, analyse d'erreur), README, tests à jour | 3 |
| Total | 20 |