Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Pipes - kontrola przy bramie obozu

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
:

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.

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@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ć.

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 i konwertuje typy proste, więc
amount
przyjdzie jako
number
, a nie
'500'
.

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, @name:

  • 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 {
    @IsNotEmpty()
    @IsString()
    → nazwa pola z typem,
  • reguły daniny:
    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,
  • 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 z poprawnymi typami,
  • 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.

Przejdź do CodeWorlds