Kurs NestJS · Moduł 2: Routing i cykl żądania

Filtry wyjątków - sądy i trybunały imperium

6 min czytania
W tej lekcji6

Prowincjał pyta o trybut, którego nie ma w rejestrze, a serwer odpowiada kodem 500 i komunikatem „Internal server error". Klient nie wie, czy popełnił błąd, czy padła baza, a Ty nie wiesz, co mu właściwie wysłałeś. Pretor Augustus, najwyższy sędzia Rzymu, powtarza, że każda sprawa musi trafić przed właściwy trybunał i zakończyć się czytelnym wyrokiem. W NestJS tymi trybunałami są exception filters - klasy, które przechwytują wyjątki i zamieniają je w odpowiedzi HTTP.

Czym są Exception Filters?

W imperium rzymskim każdy rodzaj sprawy miał swój trybunał:

  • Trybunał pretorski - sprawy cywilne (404 Not Found)
  • Sąd cenzorski - kontrola dostępu (403 Forbidden)
  • Trybunał wojskowy - poważne naruszenia (500 Internal Error)

NestJS ma wbudowaną warstwę wyjątków, która działa jak domyślny trybunał: przechwytuje każdy nieobsłużony wyjątek. Własny filtr piszesz wtedy, gdy chcesz zmienić treść wyroku.

Wbudowane wyjątki NestJS

Zanim napiszesz własny sąd, poznaj gotowe wyroki. Wszystkie poniższe klasy dziedziczą po HttpException i same ustawiają właściwy kod statusu:

1import {
2  BadRequestException,      // 400
3  UnauthorizedException,     // 401
4  ForbiddenException,        // 403
5  NotFoundException,         // 404
6  ConflictException,         // 409
7  InternalServerErrorException, // 500
8  HttpException,             // bazowy wyjątek HTTP
9} from '@nestjs/common';

Komentarze przy importach podają kod, który trafi do klienta. Teraz rzucamy te wyjątki w kontrolerze trybutów:

1@Controller('tributa')
2export class TributeController {
3  private tributes = [
4    { id: 1, name: 'Aurum Galliae', amount: 50000 },
5    { id: 2, name: 'Argentum Hispaniae', amount: 30000 },
6  ];
7
8  @Get(':id')
9  findTribute(@Param('id', ParseIntPipe) id: number) {
10    const tribute = this.tributes.find(t => t.id === id);
11    if (!tribute) {
12      // Rzuć wyjątek 404
13      throw new NotFoundException(
14        `Tributum ${id} non inventum! (Trybut nie znaleziony)`
15      );
16    }
17    return tribute;
18  }
19
20  @Post()
21  @UseGuards(RolesGuard)
22  createTribute(@Body() dto: any) {
23    if (!dto.name || !dto.amount) {
24      // Rzuć wyjątek 400
25      throw new BadRequestException(
26        'Trybut musi zawierać nazwę i kwotę!'
27      );
28    }
29
30    const exists = this.tributes.find(t => t.name === dto.name);
31    if (exists) {
32      // Rzuć wyjątek 409
33      throw new ConflictException(
34        `Trybut o nazwie "${dto.name}" już istnieje!`
35      );
36    }
37
38    const newTribute = { id: this.tributes.length + 1, ...dto };
39    this.tributes.push(newTribute);
40    return newTribute;
41  }
42}

Słowo throw przerywa metodę, a warstwa wyjątków NestJS wysyła odpowiedź za Ciebie. Dla brakującego trybutu klient dostanie taki JSON:

1{
2  "message": "Tributum 3 non inventum! (Trybut nie znaleziony)",
3  "error": "Not Found",
4  "statusCode": 404
5}

Twój tekst trafia do pola message, a error i statusCode wynikają z klasy wyjątku. Gdy potrzebujesz kodu bez własnej klasy, sięgnij po bazowe HttpException z wartością z enuma HttpStatus:

1throw new HttpException('Wiadomość błędu', HttpStatus.I_AM_A_TEAPOT); // 418

Kod 418 istnieje naprawdę, choć urodził się jako żart. Uwaga na zwykły throw new Error(): taki wyjątek nie jest HttpException, więc klient zobaczy tylko { "statusCode": 500, "message": "Internal server error" }. Szczegóły błędu zostają na serwerze - i dobrze.

Tworzenie własnych filtrów wyjątków

Własny format wyroku zapewnia klasa implementująca interfejs ExceptionFilter. Dekorator @Catch(HttpException) mówi, jakie sprawy trafiają do tego sądu, a metoda catch() dostaje wyjątek oraz ArgumentsHost - obiekt, z którego wyciągasz request i response:

1// filters/roman-exception.filter.ts
2import {
3  ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
4} from '@nestjs/common';
5import { Request, Response } from 'express';
6
7@Catch(HttpException)
8export class RomanExceptionFilter implements ExceptionFilter {
9  catch(exception: HttpException, host: ArgumentsHost) {
10    const ctx = host.switchToHttp();
11    const response = ctx.getResponse<Response>();
12    const request = ctx.getRequest<Request>();
13    const status = exception.getStatus();
14
15    // Własny format odpowiedzi błędu w stylu imperium
16    response.status(status).json({
17      statusCode: status,
18      imperium: 'Roma Aeterna',
19      error: exception.message,
20      path: request.url,
21      method: request.method,
22      timestamp: new Date().toISOString(),
23    });
24  }
25}

Najpierw host.switchToHttp(), potem z kontekstu response i request, a na końcu wysłanie odpowiedzi. Status możesz odczytać z wyjątku (exception.getStatus()) w dowolnym miejscu przed wysłaniem. Ten filtr zmienia tylko kształt odpowiedzi - kod statusu przepisuje bez zmian z wyjątku. Jedna pułapka: przy błędach walidacji exception.message to ogólne „Bad Request Exception", a lista błędów pól leży w exception.getResponse().

Bindowanie filtrów - poziomy trybunału

Filtr podpinasz dekoratorem @UseFilters() do metody lub kontrolera albo globalnie dla całej aplikacji:

1// 1. Poziom metody - tylko ta metoda
2@Controller('tributa')
3export class TributeController {
4  @Get(':id')
5  @UseFilters(new RomanExceptionFilter()) // Tylko dla tej metody
6  findTribute(@Param('id') id: string) {
7    throw new NotFoundException('Trybut nie znaleziony!');
8  }
9}
10
11// 2. Poziom kontrolera - cały kontroler
12@Controller('legiones')
13@UseFilters(RomanExceptionFilter) // Cały kontroler
14export class LegionController {
15  @Get(':id')
16  findLegion(@Param('id') id: string) {
17    throw new NotFoundException('Legion nie znaleziony!');
18  }
19}
20
21// 3. Poziom globalny - cała aplikacja (w main.ts)
22// app.useGlobalFilters(new RomanExceptionFilter());

Możesz przekazać instancję (new RomanExceptionFilter()) albo samą klasę. Dokumentacja NestJS zaleca klasę, bo framework może wtedy używać jednej instancji w całym module i wstrzykiwać jej zależności. app.useGlobalFilters() ma dwa ograniczenia: filtr powstaje poza systemem modułów, więc nie dostanie zależności przez konstruktor, i nie obejmuje gatewayów WebSocket. Dlatego globalny filtr wolę rejestrować jako provider:

1// app.module.ts - filtr globalny zarejestrowany jako provider
2import { Module } from '@nestjs/common';
3import { APP_FILTER } from '@nestjs/core';
4import { RomanExceptionFilter } from './filters/roman-exception.filter';
5
6@Module({
7  providers: [{ provide: APP_FILTER, useClass: RomanExceptionFilter }],
8})
9export class AppModule {}

Działa tak samo jak useGlobalFilters, ale filtr może teraz wstrzyknąć na przykład logger. To polecam.

Filtr łapiący wszystko - najwyższy trybunał

Filtr z pustym @Catch() przechwytuje wszystko, także błędy, które nie są HttpException, na przykład zerwane połączenie z bazą:

1// filters/all-exceptions.filter.ts
2import {
3  ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
4} from '@nestjs/common';
5
6@Catch() // Bez argumentu = łapie WSZYSTKO
7export class AllExceptionsFilter implements ExceptionFilter {
8  catch(exception: unknown, host: ArgumentsHost) {
9    const ctx = host.switchToHttp();
10    const response = ctx.getResponse();
11    const request = ctx.getRequest();
12
13    const status = exception instanceof HttpException
14      ? exception.getStatus()
15      : HttpStatus.INTERNAL_SERVER_ERROR;
16
17    const message = exception instanceof HttpException
18      ? exception.message
19      : 'Błąd wewnętrzny serwera imperium!';
20
21    response.status(status).json({
22      statusCode: status,
23      error: message,
24      path: request.url,
25      timestamp: new Date().toISOString(),
26    });
27  }
28}

Operator instanceof rozdziela dwie ścieżki: wyjątki HTTP zachowują swój status i komunikat, a cała reszta dostaje 500 i ogólny tekst. Nie odsyłaj klientowi exception.message z nieznanego błędu - mógłby zdradzić szczegóły bazy. Taki błąd zapisz w logach. Jeden filtr może też obsłużyć kilka typów naraz: @Catch(HttpException, TypeError).

Kolejność wykonania filtrów

Filtry są jedynym elementem cyklu żądania, który nie zaczyna od poziomu globalnego:

  1. Filtr metody (jeśli istnieje) - sprawdzany jako pierwszy
  2. Filtr kontrolera (jeśli istnieje) - sprawdzany jako drugi
  3. Filtr globalny (jeśli istnieje) - sprawdzany jako ostatni

Wyjątek obsługuje tylko jeden filtr. Jeśli filtr metody go przejmie, filtr kontrolera i globalny nie zostaną wywołane. Gdy jednak @Catch() filtra metody wskazuje inny typ wyjątku, sprawa idzie wyżej, do kontrolera. Na tym samym poziomie filtr łapiący wszystko deklaruj przed bardziej szczegółowym, żeby ten drugi mógł obsłużyć swój typ.

W następnej lekcji zobaczysz, w którym miejscu cyklu życia żądania pracują filtry, a cały moduł 6 poświęcimy obsłudze błędów.

Pamiętaj: wyjątek to oskarżenie, a filtr to trybunał, który zamienia je w czytelny wyrok dla klienta.

Kod do tej lekcji: src/exception-filters.ts
1// Filtry wyjątków - Sądy i Trybunały Imperium
2import {
3  ExceptionFilter, Catch, ArgumentsHost,
4  HttpException, HttpStatus, NotFoundException,
5  BadRequestException, ForbiddenException,
6} from '@nestjs/common';
7
8console.log("Filtry wyjątków - system sądów imperium!");
9
10// ===========================================
11// 1. Wbudowane wyjątki NestJS
12// ===========================================
13
14function demonstrateExceptions() {
15  // 404 Not Found
16  // throw new NotFoundException('Tributum non inventum!');
17
18  // 400 Bad Request
19  // throw new BadRequestException('Brak wymaganych danych!');
20
21  // 403 Forbidden
22  // throw new ForbiddenException('Brak uprawnień!');
23
24  // Dowolny kod HTTP
25  // throw new HttpException('Wiadomość', HttpStatus.I_AM_A_TEAPOT);
26
27  console.log("Wbudowane wyjątki: NotFoundException, BadRequestException...");
28  console.log("Każdy generuje odpowiedni kod HTTP automatycznie");
29}
30
31demonstrateExceptions();
32
33// ===========================================
34// 2. Własny filtr wyjątków
35// ===========================================
36
37@Catch(HttpException)
38class RomanExceptionFilter implements ExceptionFilter {
39  catch(exception: HttpException, host: ArgumentsHost) {
40    const ctx = host.switchToHttp();
41    const response = ctx.getResponse();
42    const request = ctx.getRequest();
43    const status = exception.getStatus();
44
45    response.status(status).json({
46      statusCode: status,
47      imperium: 'Roma Aeterna',
48      error: exception.message,
49      path: request.url,
50      timestamp: new Date().toISOString(),
51    });
52  }
53}
54
55console.log("\n@Catch(HttpException) - przechwytuje wyjątki HTTP");
56console.log("@Catch() - przechwytuje WSZYSTKIE wyjątki");
57
58// ===========================================
59// 3. Bindowanie filtrów
60// ===========================================
61
62console.log("\nPoziomy bindowania:");
63console.log("@UseFilters(filter) na metodzie - tylko ta metoda");
64console.log("@UseFilters(filter) na kontrolerze - cały kontroler");
65console.log("app.useGlobalFilters(filter) - cała aplikacja");
66
67console.log("\n=== PODSUMOWANIE FILTRÓW WYJĄTKÓW ===");
68console.log("HttpException - bazowy wyjątek HTTP");
69console.log("@Catch(Type) - dekorator filtra wyjątków");
70console.log("ExceptionFilter - interfejs do implementacji");
71console.log("@UseFilters() - bindowanie na metodę/kontroler");
72console.log("Kolejność: metoda > kontroler > globalny");
73

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaki kod HTTP generuje rzucenie new NotFoundException() w NestJS?

  2. 2. Co oznacza dekorator @Catch(HttpException) na klasie filtru wyjątków?

  3. 3. Jak stworzyć filtr wyjątków przechwytujący WSZYSTKIE typy wyjątków (nie tylko HttpException)?

  4. 4. Jeśli zdefiniowano filtr na poziomie metody i kontrolera, który zostanie wykonany jako pierwszy?

Zadania praktyczne w grze

  • Edytor kodu

    Napisz RomanExceptionFilter z @Catch(HttpException), który zwraca JSON z polami: statusCode, imperium, error, path, timestamp

  • Układanie w pionie

    Uporządkuj poziomy bindowania Exception Filters od najwęższego do najszerszego zasięgu:

  • Klikanie w kolejności

    Ułóż elementy metody catch() filtra wyjątków w prawidłowej kolejności:

  • Edytor kodu

    Napisz AllExceptionsFilter z @Catch() (bez argumentów), który obsługuje zarówno HttpException, jak i inne błędy

  • Układanie w poziomie

    Ułóż elementy rzucenia wyjątku 404 w NestJS:

Przydatne artykuły