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

Pipes - kontrola przy bramie obozu

7 min czytania
W tej lekcji7

Do bramy obozu podjeżdża wóz z daniną. Wartownik nie pyta, kto go przysłał - to sprawdzono wcześniej, przy rogatce. Pyta o co innego: czy w skrzyniach jest to, co w liście przewozowym, czy ilości się zgadzają, czy „dwanaście" wypisane na kwicie da się w ogóle policzyć. Dopiero potem wóz wjeżdża, a kwatermistrz dostaje przeliczony, sprawdzony ładunek - nie stos worków do przejrzenia.

W tym module poznałeś już trzy mechanizmy stojące na drodze żądania: middleware, guards i interceptors. Pipes to czwarty i ostatni mechanizm cyklu żądania w NestJS - i robi coś, czego żaden z tamtych nie robi.

Dwa zadania, nie więcej

Pipes mają dwa zastosowania: walidację i transformację danych wejściowych. Warto od razu odgraniczyć je od sąsiadów, bo w cyklu żądania stoją tuż obok siebie:

  • routingiem zajmują się dekoratory @Get i @Post z pierwszej lekcji modułu, a wstępną obróbką żądania - middleware;
  • autoryzację, czyli pytanie „kto ma prawo wejść", rozstrzygają guards;
  • logowanie, cache i przerabianie odpowiedzi to praca interceptorów.

Pipe nie dotyka ani żądania jako całości, ani odpowiedzi. Dostaje jeden argument metody kontrolera i albo go przepuszcza - często przekształcony - albo rzuca wyjątek. Uruchamia się po guardach, tuż przed wejściem do metody.

ParseIntPipe - wbudowana transformacja

Parametr wyciągnięty z adresu URL jest zawsze stringiem. Żądanie /tributes/12 daje '12', nie 12:

1@Controller('tributes')
2export class TributeController {
3  @Get(':id')
4  findById(@Param('id', ParseIntPipe) id: number) {
5    return this.tributeService.findById(id);
6  }
7}

Pipe podajemy jako drugi argument @Param: pierwszy to nazwa parametru z trasy, drugi to pipe, który ma go obsłużyć. ParseIntPipe konwertuje string na liczbę całkowitą albo rzuca wyjątek - i tylko tyle. Nie parsuje JSON-a na obiekt, nie zamienia booleana na string, nie przerabia daty na timestamp.

Wyjątek jest tu równie ważny jak sama konwersja. Po wywołaniu /tributes/abc metoda findById nie wykona się w ogóle - klient dostanie 400 Bad Request, a wewnątrz metody id ma gwarantowany typ number. To dlatego adnotacja id: number nie jest tu pobożnym życzeniem.

Rodzeństwo działa analogicznie: ParseBoolPipe dla 'true' i 'false', ParseUUIDPipe dla identyfikatorów UUID.

DTO - reguły zapisane przy polach

Przy większym ładunku pojedyncze pipes nie wystarczą. Wtedy opisujemy oczekiwany kształt danych w DTO (Data Transfer Object) - klasie, której pola noszą dekoratory z biblioteki class-validator. Dozwolone rodzaje daniny zapisujemy wcześniej w enumie TributeType:

1export enum TributeType {
2  GOLD = 'gold',
3  SILVER = 'silver',
4  GOODS = 'goods',
5}
6
7export class CreateTributeDto {
8  @IsNotEmpty()
9  @IsString()
10  province: string;
11
12  @IsNumber()
13  @Min(1)
14  amount: number;
15
16  @IsEnum(TributeType)
17  type: TributeType;
18}

Kolejność zapisu jest stała: najpierw otwarcie klasy (export class CreateTributeDto {), potem dekoratory - każdy w osobnej linii - a na samym końcu pole, którego dotyczą. Dekorator stoi zawsze nad polem, nigdy obok niego, i tak samo zapiszesz każde kolejne pole tej klasy.

Każdy dekorator to jedna reguła. @IsNotEmpty() odrzuca wartość pustą, @IsString() pilnuje typu, @IsNumber() żąda liczby, @Min(1) - liczby co najmniej jeden, bo danina „zero sztuk złota" nie jest daniną, a @IsEnum(TributeType) dopuszcza wyłącznie wartości enuma (przy błędzie komunikat wymieni je wszystkie). Reguły się sumują: amount musi spełnić obie naraz.

ValidationPipe - cztery kroki

Same dekoratory niczego nie sprawdzają; to tylko opis wymagań. Wykonawcą jest ValidationPipe, a jego praca to cztery kroki, zawsze w tej kolejności:

  1. Otrzymanie surowych danych z żądania.
  2. Sprawdzenie reguł walidacji z dekoratorów DTO.
  3. Rzucenie BadRequestException, jeśli dane są niepoprawne.
  4. Zwrócenie zwalidowanych danych do handlera.

Krok trzeci przerywa cykl - handler nie zobaczy złych danych, bo w ogóle się nie uruchomi, a klient dostanie 400 z listą pól, które nie przeszły. Krok czwarty to powód, dla którego to całe zamieszanie ma sens: do metody trafia obiekt, o którym wiadomo, że spełnia wszystkie reguły.

Podpięcie do metody wygląda tak:

1@Controller('tributes')
2export class TributeController {
3  @Post()
4  createTribute(@Body(ValidationPipe) tribute: CreateTributeDto) {
5    return this.tributeService.create(tribute);
6  }
7}

Czyta się to od lewej. createTribute(@Body( otwiera parametr pobierany z ciała żądania, ValidationPipe) domyka @Body, wskazując pipe, który ma je sprawdzić, a tribute: CreateTributeDto) nazywa parametr i podaje klasę z regułami. Bez tego ostatniego pipe nie miałby czego sprawdzać - to właśnie typ mówi mu, których dekoratorów szukać.

Rejestracja globalna

Dopisywanie ValidationPipe przy każdym @Body szybko się nudzi, a jedno pominięcie to jeden endpoint bez kontroli. Rejestrujemy go więc raz, globalnie, w pliku main.ts:

1async function bootstrap() {
2  const app = await NestFactory.create(AppModule);
3
4  app.useGlobalPipes(
5    new ValidationPipe({
6      whitelist: true,
7      transform: true,
8    }),
9  );
10
11  await app.listen(3000);
12}

app.useGlobalPipes(new ValidationPipe()) obejmuje wszystkie endpointy aplikacji naraz. Zwróć uwagę, że to kod wykonywany przy starcie, a nie wpis w konfiguracji - dlatego nie znajdziesz tego ani w package.json (spis zależności), ani w tsconfig.json (ustawienia kompilatora), ani w .env (zmienne środowiskowe). Żaden z tych plików niczego nie uruchamia.

Dwie opcje warto włączyć od razu. whitelist: true wycina pola nieopisane w DTO - klient może przysłać isAdmin: true, ale do serwisu to nie dotrze. transform: true zamienia zwykły obiekt z JSON-a na instancję klasy DTO, a parametry trasy i zapytania z typem prostym zamienia z tekstu na ten typ: przy @Param('id') id: number dostaniesz 12, a nie '12'. Pól DTO nie przelicza - jeśli klient przyśle "amount": "500", @IsNumber() odrzuci żądanie z kodem 400. Gdy chcesz także takiej konwersji, dodaj transformOptions: { enableImplicitConversion: true }.

Własny pipe

Gdy reguła jest specyficzna dla twojej domeny, piszesz własny pipe - klasę implementującą interfejs PipeTransform z jedną metodą transform:

1@Injectable()
2export class TributeSealPipe implements PipeTransform {
3  transform(value: string, metadata: ArgumentMetadata) {
4    const seal = value.trim().toUpperCase();
5
6    if (seal.length !== 8 || !seal.startsWith('SPQR')) {
7      throw new BadRequestException(
8        `Pieczęć ${value} nie jest pieczęcią imperium`,
9      );
10    }
11
12    return seal;
13  }
14}

Metoda dostaje dwa argumenty: value - wartość do sprawdzenia - oraz metadata typu ArgumentMetadata, gdzie znajdziesz między innymi type ('body', 'query' czy 'param') i oczekiwany typ parametru. Kontrakt jest prosty: zwróć wartość albo rzuć wyjątek. Zwrócona wartość - tutaj przycięta i podniesiona do wielkich liter - trafia do metody kontrolera zamiast oryginalnej.

To znowu ta sama para zadań co na początku: walidacja (sprawdzenie długości i przedrostka) i transformacja (trim z toUpperCase). Używa się go dokładnie tak jak wbudowanego: @Param('seal', TributeSealPipe).

Podsumowanie

Wóz z daniną nie wjeżdża do obozu bez kontroli:

  • pipes służą do walidacji i transformacji danych wejściowych - nie do routingu i middleware, nie do autoryzacji i logowania, nie do cache'owania i kompresji,
  • pipe dostaje jeden argument metody i działa po guardach, tuż przed handlerem,
  • ParseIntPipe konwertuje string na liczbę całkowitą albo rzuca wyjątek; podajesz go jako drugi argument: @Param('id', ParseIntPipe) id: number,
  • pole DTO zapisujesz w kolejności: export class CreateTributeDto { → dekoratory (@IsNotEmpty(), @IsString(); ich wzajemna kolejność nie zmienia wyniku walidacji) → nazwa pola z typem,
  • reguły daniny: province z @IsString(), amount z @IsNumber() i @Min(1), type z @IsEnum(TributeType),
  • ValidationPipe w czterech krokach: surowe dane z żądania → sprawdzenie reguł z dekoratorów DTO → BadRequestException przy błędzie → zwalidowane dane do handlera,
  • w metodzie kontrolera: createTribute(@Body( → ValidationPipe) → tribute: CreateTributeDto),
  • globalnie w main.ts: app.useGlobalPipes(new ValidationPipe()) - nie w package.json, nie w tsconfig.json, nie w .env,
  • whitelist: true wycina pola spoza DTO, transform: true daje instancję DTO i zamienia proste typy parametrów trasy i zapytania,
  • własny pipe implementuje PipeTransform i metodę transform(value, metadata: ArgumentMetadata) - zwraca wartość albo rzuca wyjątek.

W następnej lekcji zaczniemy odciskać własne pieczęcie - napiszemy dekoratory szyte na miarę imperium. A na razie zapamiętaj: pipe to ostatnia brama przed twoim kodem, i wszystko, co ją minie, jest już tym, czego oczekujesz.

Kod do tej lekcji: src/pipes.ts
1// Pipes w NestJS - Rzemieślnicy i Kontrolerzy Jakości
2import {
3  PipeTransform, Injectable, ArgumentMetadata,
4  BadRequestException, ParseIntPipe, ParseBoolPipe,
5  DefaultValuePipe, ValidationPipe,
6} from '@nestjs/common';
7
8console.log("Pipes - transformacja i walidacja danych!");
9
10// ===========================================
11// 1. Wbudowane Pipes
12// ===========================================
13
14// ParseIntPipe - zamienia string na number
15// @Get(':id')
16// findOne(@Param('id', ParseIntPipe) id: number) { ... }
17
18// ParseBoolPipe - zamienia string na boolean
19// @Get()
20// findAll(@Query('active', ParseBoolPipe) active: boolean) { ... }
21
22// DefaultValuePipe - domyślna wartość
23// @Get()
24// findAll(@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number)
25
26// ===========================================
27// 2. Własny pipe - walidacja
28// ===========================================
29
30@Injectable()
31export class RomanRankValidationPipe implements PipeTransform {
32  private readonly validRanks = [
33    'Miles', 'Optio', 'Centurio', 'Tribunus', 'Legatus',
34  ];
35
36  transform(value: any, metadata: ArgumentMetadata) {
37    if (!this.validRanks.includes(value)) {
38      throw new BadRequestException(
39        'Ranga "' + value + '" nie istnieje w imperium! '
40        + 'Dostępne rangi: ' + this.validRanks.join(', ')
41      );
42    }
43    return value;
44  }
45}
46
47// Użycie: @Param('rank', RomanRankValidationPipe) rank: string
48
49// ===========================================
50// 3. Własny pipe - transformacja
51// ===========================================
52
53@Injectable()
54export class TrimPipe implements PipeTransform {
55  transform(value: any, metadata: ArgumentMetadata) {
56    if (typeof value === 'string') {
57      return value.trim();
58    }
59    if (typeof value === 'object' && value !== null) {
60      for (const key of Object.keys(value)) {
61        if (typeof value[key] === 'string') {
62          value[key] = value[key].trim();
63        }
64      }
65    }
66    return value;
67  }
68}
69
70// ===========================================
71// 4. ValidationPipe z class-validator
72// ===========================================
73
74// class CreateLegionDto {
75//   @IsString() @IsNotEmpty()
76//   name: string;
77//
78//   @IsNumber() @Min(1000) @Max(6000)
79//   soldiers: number;
80//
81//   @IsEnum(LegionType)
82//   type: LegionType;
83// }
84
85// Globalnie w main.ts:
86// app.useGlobalPipes(new ValidationPipe({
87//   whitelist: true,      // Usuń nieznane pola
88//   forbidNonWhitelisted: true, // Błąd przy nieznanych polach
89//   transform: true,      // Automatyczna transformacja typów
90// }));
91
92console.log("\n=== PODSUMOWANIE PIPES ===");
93console.log("ParseIntPipe, ParseBoolPipe - wbudowane transformatory");
94console.log("DefaultValuePipe - wartości domyślne");
95console.log("PipeTransform - interfejs własnego pipe'a");
96console.log("ValidationPipe + class-validator - walidacja DTO");
97console.log("whitelist: true - usuwa nieznane pola z body");
98

Widzisz błąd w tej lekcji?

Sprawdź się

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

  1. 1. Jakie są dwa główne zastosowania Pipes w NestJS?

  2. 2. Co robi ParseIntPipe w NestJS?

  3. 3. Jak zarejestrować ValidationPipe globalnie w aplikacji NestJS?

Zadania praktyczne w grze

  • Edytor kodu

    Napisz endpoint @Get(':id'), który używa ParseIntPipe do walidacji parametru id

  • Układanie w pionie

    Uporządkuj kroki walidacji danych przez ValidationPipe:

  • Klikanie w kolejności

    Ułóż elementy definicji pola DTO z walidacją w prawidłowej kolejności:

  • Edytor kodu

    Napisz CreateTributeDto z polami province (@IsString), amount (@IsNumber, @Min(1)) i type (@IsEnum)

  • Układanie w poziomie

    Ułóż elementy użycia ValidationPipe w metodzie kontrolera:

Przydatne artykuły