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

Codes de statut, en-têtes et redirections (@HttpCode, @Header, @Redirect)

Cette leçon explique comment maîtriser la réponse HTTP envoyée par un contrôleur NestJS : statuts par défaut, modification avec @HttpCode et l'énumération HttpStatus, réponses 204 sans contenu, except...

0 chapitres 0 QCM Accès gratuit
Codes de statut, en-têtes et redirections (@HttpCode, @Header, @Redirect)

À propos de ce cours

Cette leçon explique comment maîtriser la réponse HTTP envoyée par un contrôleur NestJS : statuts par défaut, modification avec @HttpCode et l'énumération HttpStatus, réponses 204 sans contenu, exceptions HTTP intégrées (400, 401, 403, 404, 409…) et HttpException personnalisée, en-têtes fixes avec @Header et calculés avec l'objet réponse en mode passthrough (X-Total-Count, Location après une création), redirections fixes et dynamiques avec @Redirect. Toutes les réponses sont obtenues sur une application réelle, suivies d'activités corrigées.

Objectifs de la leçon

  • Choisir le bon code de statut et le fixer avec @HttpCode.
  • Signaler les erreurs avec les exceptions HTTP de NestJS.
  • Ajouter des en-têtes fixes ou calculés.
  • Rediriger de façon fixe ou dynamique.

Codes de statut HTTP et exceptions NestJS

Succès et redirections se règlent par décorateurs ; les erreurs se signalent en levant une exception.

Le contrôleur d'exemple

📄 src/groupes.controller.ts

import {
  Body, ConflictException, Controller, Delete, ForbiddenException, Get, Header, HttpCode, HttpException,
  HttpStatus, NotFoundException, Param, ParseIntPipe, Post, Query, Redirect, Res,
} from '@nestjs/common';
import type { Response } from 'express';

interface Groupe { id: number; code: string; capacite: number; verrouille: boolean }

@Controller('groupes')
export class GroupesController {
  private groupes: Groupe[] = [
    { id: 1, code: 'DEV101', capacite: 24, verrouille: false },
    { id: 2, code: 'DEV102', capacite: 20, verrouille: true },
  ];

  @Get()
  @Header('Cache-Control', 'public, max-age=60')                 // en-tête fixe
  findAll(@Res({ passthrough: true }) res: Response) {
    res.setHeader('X-Total-Count', String(this.groupes.length));  // en-tête calculé
    return this.groupes;
  }

  @Post()
  create(@Body() body: { code: string; capacite: number }, @Res({ passthrough: true }) res: Response) {
    if (this.groupes.some((g) => g.code === body.code)) {
      throw new ConflictException(`Le groupe ${body.code} existe déjà`);        // 409
    }
    const g = { id: this.groupes.length + 1, verrouille: false, ...body };
    this.groupes.push(g);
    res.location(`/api/groupes/${g.id}`);                                       // 201 + Location
    return g;
  }

  @Post('recalculer')
  @HttpCode(HttpStatus.OK)                                         // 200 au lieu de 201
  recalculer() {
    return { recalcule: true };
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)                                 // 204 : pas de corps
  remove(@Param('id', ParseIntPipe) id: number) {
    const g = this.groupes.find((x) => x.id === id);
    if (!g) throw new NotFoundException(`Groupe ${id} introuvable`);             // 404
    if (g.verrouille) throw new ForbiddenException('Ce groupe est verrouillé');   // 403
    this.groupes = this.groupes.filter((x) => x.id !== id);
  }

  @Get('quota')
  quota() {
    throw new HttpException(                                        // statut et corps libres
      { statusCode: 429, erreur: 'Trop de requêtes', reessayerDans: '30 s' },
      HttpStatus.TOO_MANY_REQUESTS,
    );
  }

  @Get('emploi-du-temps')
  @Redirect('https://apcpedagogie.com', 302)                        // redirection fixe
  emploiDuTemps(@Query('groupe') groupe?: string) {
    if (groupe) return { url: `/edt/${groupe.toLowerCase()}`, statusCode: 307 };  // dynamique
  }
}

Requêtes envoyées et réponses réellement obtenues :

GET /api/groupes → 200 [{"id":1,"code":"DEV101","capacite":24,"verrouille":false},{"id":2,"code":"DEV102","capacite":20,"verrouille":true}]  [cache-control: public, max-age=60]  [x-total-count: 2]
POST /api/groupes → 201 {"id":3,"verrouille":false,"code":"INF101","capacite":18}  [location: /api/groupes/3]
POST /api/groupes → 409 {"message":"Le groupe DEV101 existe déjà","error":"Conflict","statusCode":409}
POST /api/groupes/recalculer → 200 {"recalcule":true}
DELETE /api/groupes/3 → 204
DELETE /api/groupes/2 → 403 {"message":"Ce groupe est verrouillé","error":"Forbidden","statusCode":403}
DELETE /api/groupes/9 → 404 {"message":"Groupe 9 introuvable","error":"Not Found","statusCode":404}
GET /api/groupes/quota → 429 {"statusCode":429,"erreur":"Trop de requêtes","reessayerDans":"30 s"}
GET /api/groupes/emploi-du-temps → 302 Found. Redirecting to https://apcpedagogie.com  [location: https://apcpedagogie.com]
GET /api/groupes/emploi-du-temps?groupe=DEV101 → 307 Temporary Redirect. Redirecting to /edt/dev101  [location: /edt/dev101]
1. Les statuts de succès et @HttpCode
  • Par défaut : 200, et 201 pour @Post().
  • @HttpCode(HttpStatus.OK) sur recalculer: un POST qui ne crée rien répond 200.
  • @HttpCode(HttpStatus.NO_CONTENT) sur remove: 204, et le corps est vide même si la méthode renvoyait quelque chose.
  • L'énumération HttpStatus évite les « nombres magiques » : HttpStatus.CREATED, HttpStatus.NO_CONTENT, HttpStatus.TOO_MANY_REQUESTS…
2. Les erreurs : lever une exception

On ne « renvoie » pas une erreur : on lève une exception. NestJS l'intercepte (filtre d'exception intégré, leçon 5.5) et construit la réponse { message, error, statusCode }. L'exception peut être levée dans le contrôleur ou, mieux, dans le service.

ExceptionStatutQuand l'utiliser
BadRequestException400données reçues incorrectes
UnauthorizedException401non authentifié (pas de jeton, jeton invalide)
ForbiddenException403authentifié mais pas autorisé
NotFoundException404ressource inexistante
ConflictException409conflit avec l'existant (doublon)
UnprocessableEntityException422données bien formées mais impossibles à traiter
InternalServerErrorException500erreur imprévue (à éviter de lever soi-même)

Pour un statut sans classe dédiée ou une forme de réponse particulière, new HttpException(corps, statut) accepte un objet libre (voir la route quota: 429 avec un corps personnalisé).

Erreur non HTTP: si le code lève une erreur ordinaire (throw new Error('…'), ou un bug comme undefined.nom), NestJS répond 500 avec « Internal server error », sans révéler le message au client, et affiche l'erreur dans le terminal. C'est voulu : les détails techniques ne doivent pas fuiter. Levez des exceptions HTTP pour les erreurs prévues.

3. Les en-têtes de réponse
  • En-tête fixe: @Header('Cache-Control', 'public, max-age=60').
  • En-tête calculé: @Res({ passthrough: true }) puis res.setHeader('X-Total-Count', …) (leçon 6.1).
  • Location après une création: la bonne pratique REST est de renvoyer, avec le 201, l'adresse de la nouvelle ressource (res.location('/api/groupes/3')).
4. Les redirections
  • @Redirect(url, statut): redirection fixe (302 par défaut).
  • Redirection dynamique: la méthode renvoie { url, statusCode }, qui remplace les valeurs du décorateur. Si elle ne renvoie rien, la redirection fixe s'applique.
  • 301: déplacement définitif (les navigateurs et moteurs de recherche le mémorisent) ; 302 / 307: temporaire (307 conserve la méthode et le corps, utile pour un POST).
Activités

Activité 1 Le bon statut

Quel statut et quel mécanisme NestJS pour : (a) suppression réussie ; (b) création d'un stagiaire dont l'e-mail existe déjà ; (c) POST /api/groupes/1/archiver réussi ; (d) consultation d'un groupe inexistant ; (e) stagiaire qui tente de modifier ses propres notes ; (f) requête sans jeton sur une route protégée ; (g) note de 25 sur 20 ?

Voir le corrigé

(a) 204 avec @HttpCode(HttpStatus.NO_CONTENT) (ou 200 en renvoyant l'élément supprimé) ; (b) 409, ConflictException; (c) 200 avec @HttpCode(HttpStatus.OK); (d) 404, NotFoundException; (e) 403, ForbiddenException; (f) 401, UnauthorizedException; (g) 400, BadRequestException (le chapitre 08 le fera automatiquement).

Activité 2 Créer avec Location

Modifiez POST /api/formations pour renvoyer l'en-tête Location de la formation créée, et GET /api/formations pour renvoyer X-Total-Count. Vérifiez les en-têtes dans votre client d'API.

Voir le corrigé
@Post()
create(@Body() dto: CreerFormationDto, @Res({ passthrough: true }) res: Response) {
  const f = this.formationsService.create(dto);
  res.location(`/api/formations/${f.id}`);
  return f;
}

@Get()
findAll(@Res({ passthrough: true }) res: Response) {
  const liste = this.formationsService.findAll();
  res.setHeader('X-Total-Count', String(liste.length));
  return liste;
}

Les valeurs d'en-tête sont des chaînes : String(liste.length). Un front-end Angular lira ces en-têtes avec observe: 'response', mais, en appel depuis un autre domaine, il faudra les autoriser dans CORS avec l'option exposedHeaders.

Activité 3 Doublon et verrou

Dans FormationsService.create, refusez une formation dont l'intitulé existe déjà (sans tenir compte des majuscules) avec un 409. Dans remove, refusez la suppression d'une formation publiée avec un 403 et le message « Dépubliez la formation avant de la supprimer ».

Voir le corrigé
create(dto: CreerFormationDto) {
  const existe = this.formations.some((f) => f.intitule.toLowerCase() === dto.intitule.toLowerCase());
  if (existe) throw new ConflictException(`La formation « ${dto.intitule} » existe déjà`);
  …
}

remove(id: number) {
  const f = this.findOne(id);
  if (f.publiee) throw new ForbiddenException('Dépubliez la formation avant de la supprimer');
  …
}

Activité 4 Erreur imprévue

Ajoutez une route GET /api/formations/bug qui exécute const f: any = undefined; return f.intitule;. Quel statut et quel corps reçoit le client ? Qu'affiche le terminal ? Pourquoi ce comportement est-il souhaitable ?

Voir le corrigé

Le client reçoit 500 { "statusCode": 500, "message": "Internal server error" }; le terminal affiche ERROR [ExceptionsHandler] TypeError: Cannot read properties of undefined (reading 'intitule') avec la pile d'appels. Le développeur dispose de tous les détails, l'utilisateur (ou un attaquant) d'aucun.

Activité 5 Redirections

Ajoutez GET /api/formations/:id/programme qui redirige (302) vers /programmes/<id>, et vers la page générale /programmes si la formation n'est pas publiée. Pourquoi ne pas utiliser 301 ici ?

Voir le corrigé
@Get(':id/programme')
@Redirect('/programmes', 302)
programme(@Param('id', ParseIntPipe) id: number) {
  const f = this.formationsService.findOne(id);
  if (f.publiee) return { url: `/programmes/${f.id}` };
}

Une redirection 301 est mémorisée par le navigateur : si la formation est publiée plus tard, les visiteurs continueraient d'être envoyés vers l'ancienne adresse. On réserve 301 aux adresses déplacées définitivement.

À retenir

  • 200 par défaut, 201 pour POST ; @HttpCode(HttpStatus.X) pour changer, 204 sans corps.
  • Les erreurs se lèvent : BadRequest 400, Unauthorized 401, Forbidden 403, NotFound 404, Conflict 409.
  • HttpException(corps, statut) pour un cas particulier ; une erreur imprévue donne 500 sans détail.
  • @Header pour un en-tête fixe ; passthrough + res.setHeader / res.location pour un en-tête calculé.
  • @Redirect(url, statut) ; renvoyer { url, statusCode } pour une redirection dynamique.