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

TP 2 : Structurer l'API de gestion de formation en modules

Ce TP fait évoluer l'API api-formation du TP 1 en une application découpée en modules, sur une branche GitHub dédiée : module partagé de journalisation avec un middleware HTTP, module de paramètres dy...

0 chapitres 0 QCM Accès gratuit
TP 2 : Structurer l'API de gestion de formation en modules

À propos de ce cours

Ce TP fait évoluer l'API api-formation du TP 1 en une application découpée en modules, sur une branche GitHub dédiée : module partagé de journalisation avec un middleware HTTP, module de paramètres dynamique et global configuré avec forRoot, modules des formations, des groupes et des stagiaires reliés par exports et imports, chargement des données au démarrage avec onModuleInit et message d'arrêt avec onApplicationShutdown. Il comprend un fichier de requêtes, une analyse d'erreur d'encapsulation, la correction complète vérifiée et une grille d'évaluation.

Objectifs du TP

  • Découper une API en modules de fonctionnalité reliés par exports et imports.
  • 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.

Architecture du TP 2

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.

RouteRéponse
GET /api« Bienvenue sur l'API de <établissement> »
GET /api/infosparamètres de l'application et nombre de messages journalisés
GET /api/formations, GET /api/formations/:idformations
GET /api/groupes, GET /api/groupes/:codegroupes avec l'intitulé de leur formation
GET /api/stagiaires?groupe=…, GET /api/stagiaires/:idstagiaires (filtre facultatif), un stagiaire avec son groupe détaillé
Partie 1 Préparer la branche de travail
  1. Dans votre dépôt api-formation (TP 1), placez-vous sur main à jour : git switch main puis git pull.
  2. Créez la branche du TP : git switch -c tp2-modules.
  3. Lancez l'application et vérifiez que les routes du TP 1 fonctionnent toujours.
  4. 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
  5. Commit : chore: génération des modules, puis git push -u origin tp2-modules.
Partie 2 Le module partagé CommonModule
  1. Écrivez JournalService avec une méthode ecrire(contexte, message) qui affiche [hh:mm:ss] [contexte] message et un accesseur nombreMessages.
  2. CommonModule déclare et exporte JournalService.
  3. Écrivez src/common/journal.middleware.ts: un middleware qui, à la fin de chaque réponse (événement res.on('finish')), écrit GET /api/groupes → 200 (3 ms) avec le contexte HTTP.
  4. Branchez ce middleware sur toutes les routes dans AppModule (méthode configure).
  5. 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
  1. Créez src/parametres/ avec parametres.constants.ts (interface ParametresOptions: établissement, année scolaire, version ; jeton PARAMETRES_OPTIONS), parametres.service.ts et parametres.module.ts.
  2. ParametresModule est global et expose forRoot(options).
  3. ParametresService injecte les options avec @Inject(PARAMETRES_OPTIONS) et propose l'accesseur etablissement et la méthode infos().
  4. Importez ParametresModule.forRoot({ … }) dans AppModule et modifiez AppController: GET /api utilise l'établissement, GET /api/infos renvoie les paramètres et nombreMessages.
  5. Commit : feat: module de paramètres dynamique.
Partie 4 Formations et groupes
  1. FormationsService charge deux formations dans onModuleInit et l'écrit dans le journal (« 2 formations chargées ») ; findOne(id) lève NotFoundException si besoin. FormationsModule importe CommonModule et exporte FormationsService.
  2. GroupesService contient trois groupes (code, formationId, capacite) et renvoie chaque groupe complété par l'intitulé de sa formation. La recherche par code ignore la casse. GroupesModule importe FormationsModule et exporte GroupesService.
  3. Écrivez les deux contrôleurs.
  4. Commit : feat: modules formations et groupes.
Partie 5 Stagiaires et cycle de vie
  1. StagiairesService contient trois stagiaires ; findAll(groupe?) filtre par groupe (insensible à la casse) ; findOne(id) renvoie le stagiaire avec l'objet groupe complet, obtenu par GroupesService. StagiairesModule importe GroupesModule.
  2. Le contrôleur lit le filtre avec @Query('groupe').
  3. AppModule implémente OnApplicationShutdown et écrit « API arrêtée (signal) » dans le journal ; main.ts appelle enableShutdownHooks().
  4. Créez requetes/tp2.http (voir ci-dessous) et testez toutes les routes.
  5. 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
  1. Analyse: retirez temporairement exports: [GroupesService] de GroupesModule, relancez, copiez le message d'erreur dans un fichier ARCHITECTURE.md et expliquez-le en deux phrases. Rétablissez la ligne.
  2. Dans ARCHITECTURE.md, dessinez (en texte ou en image) l'arbre des modules et indiquez pour chacun ce qu'il exporte.
  3. Mettez à jour le README.md: tableau des routes, commande de lancement.
  4. Vérifiez : npm run verifier (lint, tests, build).
  5. Commit docs: architecture et README, push, puis ouvrez une Pull Request de tp2-modules vers main sur GitHub et fusionnez-la.
  6. Sur main à jour : git tag -a v0.2.0 -m "TP 2 : architecture en modules" et git 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.onModuleInit la vérification du format de l'année scolaire (activité 3 de la leçon 5.6).
  • Ajoutez un module StatistiquesModule qui importe les trois modules et expose GET /api/statistiques (nombre de formations, de groupes, de stagiaires par groupe), calculé dans onApplicationBootstrap puis 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èrePoints
Branche tp2-modules, commits clairs à chaque partie, Pull Request fusionnée, étiquette v0.2.03
CommonModule partagé et middleware de journalisation fonctionnel3
ParametresModule dynamique et global (forRoot, jeton, @Inject)4
Modules formations, groupes, stagiaires : exports/imports corrects, aucun provider redéclaré5
onModuleInit et onApplicationShutdown, enableShutdownHooks2
requetes/tp2.http, ARCHITECTURE.md (arbre, analyse d'erreur), README, tests à jour3
Total20