Skip to content

7. Le cycle de vie d'une requête

Entre l'arrivée d'une requête et l'exécution de l'action, [NestJS] fait intervenir plusieurs types d'objets, chacun avec un rôle précis. Nous en avons déjà rencontré : les middlewares ([res.locals], [cookie-parser]), les pipes ([ParseIntPipe], [ValidationPipe]). L'étude de cas utilise les cinq :

Image

Objet
Rôle
Dans l’étude de cas
middleware
préparer la requête ; ne connaît pas encore l’action
lecture des cookies, langue, message flash
garde
autoriser ou refuser l’accès à l’action
authentification (JWT), rôles
intercepteur
du code avant et après l’action
l’utilisateur connecté, exposé aux vues
pipe
convertir et valider un paramètre
[ParseIntPipe]
filtre d'exceptions
transformer une exception en réponse
pages d’erreur HTML, redirection vers /login

7.1. Exemple 24 : les cinq étapes

L'exemple 24 a une action, qui calcule le carré d'un nombre, et un objet de chaque type. Chacun écrit une ligne dans la console : on voit ainsi dans quel ordre ils interviennent.

Le fichier [src/journal.middleware.ts] :

import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';

// 1. le MIDDLEWARE : le premier à voir la requête. Il ne sait pas encore
//    quelle action va la traiter
@Injectable()
export class JournalMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    console.log(`\n--- ${req.method} ${req.originalUrl}`);
    console.log('1. middleware');
    next(); // on passe la main à la suite de la chaîne
  }
}
  • ligne 7 : un middleware [NestJS] est une classe qui implémente [NestMiddleware]. Une simple fonction, comme dans les exemples précédents, convient aussi ;
  • ligne 11 : le middleware doit appeler [next()] pour passer la main à la suite (ou envoyer lui-même une réponse). Sinon, la requête reste bloquée.

Le fichier [src/app.module.ts] :

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { CalculController } from './calcul.controller';
import { JournalMiddleware } from './journal.middleware';

@Module({
  controllers: [CalculController],
})
export class AppModule implements NestModule {
  // un middleware de classe s'enregistre dans le module
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(JournalMiddleware).forRoutes('*');
  }
}

lignes 10-12 : un middleware de classe s'enregistre dans un module qui implémente [NestModule]. [forRoutes('*')] l'applique à toutes les routes.

Le fichier [src/bloquer.guard.ts] :

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';

// 2. la GARDE : décide si la requête a le droit d'atteindre l'action.
//    false -> NestJS lève une ForbiddenException (403)
@Injectable()
export class BloquerGuard implements CanActivate {
  canActivate(contexte: ExecutionContext): boolean {
    const req = contexte.switchToHttp().getRequest<Request>();
    // la garde connaît l'action visée (ce que le middleware ignorait)
    console.log(`2. garde (action visée : ${contexte.getHandler().name})`);
    return req.query.bloquer !== '1';
  }
}
  • ligne 7 : une garde implémente [CanActivate] ;
  • ligne 8 : sa méthode [canActivate] reçoit le contexte d'exécution, et rend [true] (la requête continue) ou [false] ([NestJS] lève une [ForbiddenException], code 403) ;
  • ligne 11 : contrairement au middleware, la garde connaît l'action visée ([getHandler()]) et son contrôleur ([getClass()]). C'est ce qui permet à une garde de lire les décorateurs posés sur l'action (cf. [@Roles], chapitre suivant).

Le fichier [src/chrono.interceptor.ts] :

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

// 3. l'INTERCEPTEUR : du code AVANT l'action, et APRÈS elle
@Injectable()
export class ChronoInterceptor implements NestInterceptor {
  intercept(contexte: ExecutionContext, suite: CallHandler): Observable<unknown> {
    console.log('3. intercepteur (avant)');
    const debut = Date.now();
    // suite.handle() exécute la suite : pipes puis action. Le résultat
    // de l'action arrive dans le flux (Observable) renvoyé
    return suite.handle().pipe(tap(() => console.log(`6. intercepteur (après, ${Date.now() - debut} ms)`)));
  }
}
  • ligne 6 : un intercepteur implémente [NestInterceptor] ;
  • ligne 8 : le code placé avant [suite.handle()] s'exécute avant l'action ;
  • ligne 12 : [suite.handle()] exécute la suite (pipes puis action) et rend un flux [RxJS] ([Observable]) qui produira le résultat de l'action. L'opérateur [tap] exécute du code après l'action, sans modifier son résultat (l'opérateur [map] permettrait de le modifier).

Le fichier [src/entier.pipe.ts] :

import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';

// 4. le PIPE : convertit et valide un paramètre de l'action
@Injectable()
export class EntierPipe implements PipeTransform<string, number> {
  transform(valeur: string): number {
    console.log(`4. pipe (valeur reçue : "${valeur}")`);
    const n = Number(valeur);
    if (!Number.isInteger(n)) {
      throw new BadRequestException(`"${valeur}" n'est pas un nombre entier`);
    }
    return n;
  }
}

Un pipe semblable à [ParseIntPipe] (cf. exemple 12).

Le fichier [src/page-erreur.filter.ts] :

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';

// x. le FILTRE D'EXCEPTIONS : reçoit toute exception non traitée, d'où
//    qu'elle vienne (garde, pipe, action...), et fabrique la réponse.
//    @Catch() sans argument : toutes les exceptions
@Catch()
export class PageErreurFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
    const message = exception instanceof HttpException ? exception.message : 'Erreur interne';
    console.log(`x. filtre d'exceptions (${status} : ${message})`);
    // une page HTML plutôt que la réponse JSON par défaut de NestJS
    res.status(status).render('erreur', { status, message });
  }
}
  • ligne 7 : [@Catch()] sans argument : le filtre reçoit toutes les exceptions. [@Catch(HttpException)] le limiterait aux exceptions HTTP ;
  • ligne 9 : la méthode [catch] reçoit l'exception et un objet [host] qui donne accès à la requête et à la réponse ;
  • ligne 11 : une [HttpException] porte son code de statut. Toute autre exception (une erreur de programmation, une base de données injoignable) est une erreur 500 ;
  • ligne 15 : au lieu du JSON par défaut, le filtre affiche une page HTML, avec le bon code de statut.

Le fichier [src/main.ts] :

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { dirname, join } from 'path';
import { AppModule } from './app.module';
import { ChronoInterceptor } from './chrono.interceptor';
import { PageErreurFilter } from './page-erreur.filter';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger: ['error', 'warn'] });
  app.useStaticAssets(join(dirname(require.resolve('bootstrap/package.json')), 'dist'), { prefix: '/bootstrap/' });
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  app.setViewEngine('hbs');
  // un intercepteur et un filtre GLOBAUX : ils s'appliquent à toutes les routes
  app.useGlobalInterceptors(new ChronoInterceptor());
  app.useGlobalFilters(new PageErreurFilter());
  await app.listen(3000);
  console.log('Serveur démarré sur http://localhost:3000');
}

bootstrap();
  • ligne 9 : on réduit les messages de [NestJS] aux erreurs et avertissements, pour mieux voir les nôtres ;
  • lignes 14-15 : l'intercepteur et le filtre sont globaux : ils s'appliquent à toutes les actions.

Le fichier [src/calcul.controller.ts] :

import { Controller, Get, Param, Render, UseGuards } from '@nestjs/common';
import { BloquerGuard } from './bloquer.guard';
import { EntierPipe } from './entier.pipe';

@Controller('carre')
@UseGuards(BloquerGuard) // la garde s'applique à toutes les actions du contrôleur
export class CalculController {
  @Get(':n')
  @Render('carre')
  carre(@Param('n', EntierPipe) n: number): object {
    console.log(`5. action carre(${n})`);
    if (n === 13) {
      throw new Error('13 porte malheur'); // une erreur "imprévue"
    }
    return { n, carre: n * n };
  }
}
  • ligne 6 : [@UseGuards] applique une garde à toutes les actions du contrôleur (on peut aussi le placer sur une seule action) ;
  • ligne 10 : le pipe convertit le paramètre ;
  • lignes 12-14 : une erreur « imprévue », pour voir le filtre à l'œuvre.

Quatre requêtes : un calcul normal, un paramètre invalide, une requête bloquée par la garde, et l'erreur imprévue. La console du serveur :

--- GET /carre/7
1. middleware
2. garde (action visée : carre)
3. intercepteur (avant)
4. pipe (valeur reçue : "7")
5. action carre(7)
6. intercepteur (après, 2 ms)

--- GET /carre/abc
1. middleware
2. garde (action visée : carre)
3. intercepteur (avant)
4. pipe (valeur reçue : "abc")
x. filtre d'exceptions (400 : "abc" n'est pas un nombre entier)

--- GET /carre/7?bloquer=1
1. middleware
2. garde (action visée : carre)
x. filtre d'exceptions (403 : Forbidden resource)

--- GET /carre/13
1. middleware
2. garde (action visée : carre)
3. intercepteur (avant)
4. pipe (valeur reçue : "13")
5. action carre(13)
x. filtre d'exceptions (500 : Erreur interne)

On lit dans ce journal :

  • l'ordre des étapes : middleware, garde, intercepteur (avant), pipe, action, intercepteur (après) ;
  • une exception interrompt la chaîne, où qu'elle se produise, et c'est le filtre qui fabrique la réponse : la garde qui refuse (403), le pipe qui refuse (400), l'action qui échoue (500).

Les pages obtenues :

Image

Image

Image

Image

Le filtre d'exceptions de l'étude de cas ([web/html-exception.filter.ts]) suit ce modèle, avec un cas particulier : une erreur 401 (utilisateur non connecté) n'affiche pas de page d'erreur mais redirige vers la page de connexion.