Kurs NestJS · Moduł 10: Walidacja danych

class-transformer - sztuka transformacji danych

4 min czytania
W tej lekcji5

Walidacja pilnuje tego, co wchodzi. Ta lekcja jest o czymś odwrotnym: co i w jakiej postaci wychodzi z twojego API.

Problem widać najprościej na przykładzie. Serwis pobiera z bazy encję senatora - z hasłem, z wysokością żołdu, z wewnętrznym identyfikatorem sesji. Kontroler zwraca ten obiekt. NestJS zamienia go na JSON i wysyła klientowi w całości, bo nikt nie powiedział, że czegoś nie powinien. Wyciek danych nie wymaga błędu; wystarczy brak decyzji.

Trzy dekoratory

class-transformer daje trzy narzędzia do opisania, co ma się stać z polem przy zamianie obiektu na odpowiedź:

1export class SenatorResponseDto {
2  @Expose()
3  name: string;
4
5  @Expose()
6  province: string;
7
8  @Expose()
9  @Transform(({ value }) => value.toISOString().slice(0, 10))
10  appointedAt: Date;
11
12  @Exclude()
13  password: string;
14
15  @Exclude()
16  salary: number;
17}

@Exclude() ukrywa pole przy konwersji obiektu na odpowiedź JSON. Nie usuwa pola z bazy danych - encja zostaje nietknięta. Nie wyłącza walidacji dla tego pola. I nie blokuje dostępu do niego w TypeScripcie; w kodzie serwisu senator.password nadal działa.

@Expose() robi rzecz odwrotną - oznacza pole jako widoczne. Domyślnie widoczne są wszystkie, więc sam @Expose() niewiele zmienia; nabiera znaczenia przy opcji excludeExtraneousValues: true, która odwraca zasadę: wtedy wychodzi tylko to, co jawnie wystawione.

@Transform() definiuje własną logikę transformacji wartości pola. Nie animuje zmiany wartości, nie tworzy kopii zapasowej i nie zmienia typu pola w TypeScripcie. Funkcja dostaje obiekt z polem value i zwraca to, co ma trafić do odpowiedzi - tu data skrócona do samego dnia.

Kiedy to naprawdę zadziała

Najważniejsze zdanie tej lekcji: same dekoratory nic nie robią. To tylko adnotacje; ktoś musi je wykonać.

Aby @Exclude i @Expose działały w kontrolerze, trzeba dodać @UseInterceptors(ClassSerializerInterceptor). Nie instalować dodatkowej biblioteki class-serializer - taka nie istnieje. Nie konfigurować middleware w app.module.ts. I nie ma żadnego specjalnego dekoratora @Serialize() nad metodą.

1@Controller('senators')
2@UseInterceptors(ClassSerializerInterceptor)
3export class SenatorsController {
4  @Get(':id')
5  findOne(@Param('id') id: string) {
6    return this.senatorsService.findOne(id);
7  }
8}

To jest ta klasa błędu, która nie daje żadnego sygnału. Kod się kompiluje, testy jednostkowe serwisu przechodzą, a hasła jadą do klienta - bo interceptora nikt nie podpiął. Sprawdzenie zajmuje pół minuty: wywołaj endpoint i przeczytaj odpowiedź.

Interceptor można też zarejestrować globalnie przez app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))) - i przy danych wrażliwych to bezpieczniejszy wybór, bo pominięcie dekoratora na jednym kontrolerze przestaje być możliwe.

Droga danych z bazy do klienta

Cała transformacja przebiega w czterech krokach, zawsze w tej kolejności:

  1. Serwis pobiera dane z bazy danych.
  2. Kontroler zwraca obiekt z serwisu.
  3. ClassSerializerInterceptor stosuje @Exclude/@Expose.
  4. Klient otrzymuje przefiltrowany JSON.

Zwróć uwagę, gdzie stoi krok trzeci: po kontrolerze, nie przed nim. Interceptor działa na tym, co metoda zwróciła - i dlatego serwis oraz kontroler operują na pełnym obiekcie, ze wszystkimi polami. Filtrowanie jest ostatnią czynnością przed wysłaniem, a nie warunkiem wcześniejszej pracy.

Stąd praktyczny wniosek: w logu wypisanym w serwisie hasło będzie widoczne, choć w odpowiedzi już nie. @Exclude() chroni wyjście z API, nie twoje własne logi.

Ręczna konwersja

Czasem potrzebujesz zamienić zwykły obiekt w instancję klasy bez udziału interceptora - na przykład w teście albo przy danych z kolejki:

1const dto = plainToInstance(CreateLegionaryDto, rawData);

Kolejność argumentów jest stała: plainToInstance( → CreateLegionaryDto → , rawData → ). Najpierw klasa docelowa, potem surowe dane - łatwo pomylić kierunek, a zamiana miejscami daje obiekt, który wygląda poprawnie i nie ma żadnego z twoich dekoratorów.

Funkcja odwrotna, instanceToPlain, zamienia instancję klasy w zwykły obiekt, stosując po drodze @Exclude i @Expose. To dokładnie ta operacja, którą wykonuje za ciebie ClassSerializerInterceptor.

Podsumowanie

Wyciek danych nie wymaga błędu, wystarczy brak decyzji:

  • @Exclude() ukrywa pole przy konwersji obiektu na odpowiedź JSON - nie usuwa go z bazy, nie wyłącza walidacji, nie blokuje dostępu w TypeScripcie,
  • @Expose() oznacza pole jako widoczne; nabiera znaczenia przy excludeExtraneousValues: true,
  • @Transform() definiuje własną logikę transformacji wartości pola - nie animuje, nie tworzy kopii, nie zmienia typu,
  • żeby @Exclude i @Expose zadziałały, kontroler potrzebuje @UseInterceptors(ClassSerializerInterceptor) - nie biblioteki class-serializer, nie middleware w app.module.ts, nie dekoratora @Serialize(),
  • brak interceptora nie daje żadnego błędu - dane po prostu wychodzą w całości,
  • droga danych: serwis pobiera z bazy → kontroler zwraca obiekt → ClassSerializerInterceptor stosuje @Exclude/@Expose → klient dostaje przefiltrowany JSON,
  • filtrowanie następuje po kontrolerze, więc w logach serwisu pola wykluczone nadal widać,
  • plainToInstance( → CreateLegionaryDto → , rawData → ): najpierw klasa, potem dane,
  • instanceToPlain działa w drugą stronę - to ta sama operacja, którą wykonuje interceptor.

W następnej lekcji zejdziemy do walidacji zagnieżdżonej. A na razie zapamiętaj: o tym, co wychodzi z twojego API, decydujesz ty albo przypadek. Trzeciej możliwości nie ma.

Kod do tej lekcji: src/class-transformer.ts
1// class-transformer - Transformacja danych
2import {
3  Exclude,
4  Expose,
5  Transform,
6  Type,
7  plainToInstance,
8  instanceToPlain,
9} from 'class-transformer';
10import { IsString, IsNumber } from 'class-validator';
11
12// TODO: Uzupełnij dekoratory transformacji
13
14export class LegionaryResponseDto {
15  // TODO: Dodaj @Expose()
16  name: string;
17
18  // TODO: Dodaj @Expose()
19  rank: string;
20
21  // TODO: Dodaj @Expose() i @Transform
22  // Transform: jeśli value > 10, zwróć 'Veteranus', inaczej 'Tiro'
23  experienceYears: number;
24
25  // TODO: Dodaj @Exclude() - ukryj tajny kod
26  secretMissionCode: string;
27
28  // TODO: Dodaj @Exclude() - ukryj żołd
29  salary: number;
30}
31
32export class CreateLegionaryDto {
33  @IsString()
34  @Transform(({ value }) => value.trim())
35  name: string;
36
37  @IsString()
38  rank: string;
39
40  @IsNumber()
41  age: number;
42}
43
44// Test transformacji
45const rawData = {
46  name: '  Marcus Aurelius  ',
47  rank: 'centurio',
48  age: 35,
49  secretMissionCode: 'ROMA-X-42',
50  salary: 5000,
51  experienceYears: 15,
52};
53
54console.log('Surowe dane:', rawData);
55console.log('Po transformacji pola tajne zostaną ukryte');
56

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. Co robi dekorator @Exclude() z biblioteki class-transformer?

  2. 2. Do czego służy dekorator @Transform() w class-transformer?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Dodaj dekoratory @Expose, @Exclude i @Transform do SenatorResponseDto

  • Układanie w poziomie

    Ułóż elementy wywołania funkcji plainToInstance w prawidłowej kolejności:

  • Klikanie w kolejności

    Ułóż kroki transformacji danych z bazy do odpowiedzi API:

  • Edytor kodu

    Uzupełnij brakujące dekoratory w CreateSoldierDto, UpdateSoldierDto i SoldierResponseDto

Przydatne artykuły