Kurs NestJS · Moduł 2: Routing i cykl żądania
Pipes - kontrola przy bramie obozu
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
@Geti@Postz 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:
- Otrzymanie surowych danych z żądania.
- Sprawdzenie reguł walidacji z dekoratorów DTO.
- Rzucenie
BadRequestException, jeśli dane są niepoprawne. - 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,
ParseIntPipekonwertuje 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:
provincez@IsString(),amountz@IsNumber()i@Min(1),typez@IsEnum(TributeType), ValidationPipew czterech krokach: surowe dane z żądania → sprawdzenie reguł z dekoratorów DTO →BadRequestExceptionprzy błędzie → zwalidowane dane do handlera,- w metodzie kontrolera:
createTribute(@Body(→ValidationPipe)→tribute: CreateTributeDto), - globalnie w
main.ts:app.useGlobalPipes(new ValidationPipe())- nie wpackage.json, nie wtsconfig.json, nie w.env, whitelist: truewycina pola spoza DTO,transform: truedaje instancję DTO i zamienia proste typy parametrów trasy i zapytania,- własny pipe implementuje
PipeTransformi 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");
98Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jakie są dwa główne zastosowania Pipes w NestJS?
2. Co robi ParseIntPipe w NestJS?
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: