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.
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:
@Get i @Post z pierwszej lekcji modułu, a wstępną obróbką żądania - middleware;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.
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.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:1export class CreateTributeDto {
2 @IsNotEmpty()
3 @IsString()
4 province: string;
5
6 @IsNumber()
7 @Min(1)
8 amount: number;
9
10 @IsEnum(['gold', 'silver', 'goods'])
11 type: string;
12}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(['gold', 'silver', 'goods']) dopuszcza wyłącznie te trzy rodzaje. Reguły się sumują: amount musi spełnić obie naraz.Same dekoratory niczego nie sprawdzają; to tylko opis wymagań. Wykonawcą jest
, a jego praca to cztery kroki, zawsze w tej kolejności:ValidationPipe
BadRequestException, jeśli dane są niepoprawne.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@Post()
2createTribute(@Body(ValidationPipe) tribute: CreateTributeDto) {
3 return this.tributeService.create(tribute);
4}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ć.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}
obejmuje wszystkie endpointy aplikacji naraz. Zwróć uwagę, że to kod wykonywany przy starcie, a nie wpis w konfiguracji - dlatego nie znajdziesz tego ani w app.useGlobalPipes(new ValidationPipe())
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 i konwertuje typy proste, więc amount przyjdzie jako number, a nie '500'.Gdy reguła jest specyficzna dla twojej domeny, piszesz własny pipe - klasę implementującą interfejs
z jedną metodą PipeTransform
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).Wóz z daniną nie wjeżdża do obozu bez kontroli, @name:
ParseIntPipe konwertuje string na liczbę całkowitą albo rzuca wyjątek; podajesz go jako drugi argument: @Param('id', ParseIntPipe) id: number,export class CreateTributeDto { → @IsNotEmpty() → @IsString() → nazwa pola z typem,province z @IsString(), amount z @IsNumber() i @Min(1), type z @IsEnum(['gold', 'silver', 'goods']),ValidationPipe w czterech krokach: surowe dane z żądania → sprawdzenie reguł z dekoratorów DTO → BadRequestException przy błędzie → zwalidowane dane do handlera,createTribute(@Body( → ValidationPipe) → tribute: CreateTributeDto),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 z poprawnymi typami,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.