Kurs NestJS · Moduł 10: Walidacja danych
class-transformer - sztuka transformacji danych
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:
- Serwis pobiera dane z bazy danych.
- Kontroler zwraca obiekt z serwisu.
ClassSerializerInterceptorstosuje@Exclude/@Expose.- 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 przyexcludeExtraneousValues: true,@Transform()definiuje własną logikę transformacji wartości pola - nie animuje, nie tworzy kopii, nie zmienia typu,- żeby
@Excludei@Exposezadziałały, kontroler potrzebuje@UseInterceptors(ClassSerializerInterceptor)- nie bibliotekiclass-serializer, nie middleware wapp.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 →
ClassSerializerInterceptorstosuje@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,instanceToPlaindział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');
56Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Co robi dekorator @Exclude() z biblioteki class-transformer?
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