Kurs NestJS · Moduł 10: Walidacja danych
Projekt - kompletny system walidacji Imperium
W tej lekcji8
Osiem lekcji tego modułu poznawało strażników pojedynczo: dekoratory z class-validator, ValidationPipe, transformacje, walidację zagnieżdżoną, własne walidatory i grupy. Projekt jest miejscem, w którym staną razem przy jednej bramie - a wtedy okazuje się, że najważniejsze jest coś, o czym żadna lekcja z osobna nie mówiła: kolejność, w jakiej działają.
Zbudujesz system zarządzania legionami z pełnym CRUD-em i walidacją danych wejściowych: DTO legionu, legionisty i broni, własny walidator, transformacje odpowiedzi i globalna konfiguracja bramy.
Kolejność, która tłumaczy resztę
Zanim napiszesz pierwszą linijkę, zapamiętaj drogę, którą przebywa żądanie:
- Przychodzi zwykły obiekt JSON - bez klas, bez typów, wszystko jest tekstem albo liczbą.
class-transformerzamienia go w instancję klasy DTO, wykonując po drodze@Typei@Transform.class-validatorsprawdza dekoratory walidacji na tej instancji.- Dopiero wynik trafia do handlera.
Stąd wniosek, który oszczędzi ci wieczoru: transformacja dzieje się przed walidacją. @Transform(({ value }) => value.trim()) obetnie spacje, a dopiero potem @MinLength(2) policzy znaki - więc " A " zostanie odrzucone, choć na oko ma pięć znaków.
Krok 1 - enumy i pierwsze DTO
1// enums.ts
2export enum LegionaryRank {
3 MILES = 'miles',
4 OPTIO = 'optio',
5 CENTURIO = 'centurio',
6 TRIBUNUS = 'tribunus',
7 LEGATUS = 'legatus',
8}
9
10export enum WeaponType {
11 GLADIUS = 'gladius',
12 PILUM = 'pilum',
13 SCUTUM = 'scutum',
14 PUGIO = 'pugio',
15}
16
17// weapon.dto.ts
18export class CreateWeaponDto {
19 @IsString()
20 @IsNotEmpty()
21 name: string;
22
23 @IsEnum(WeaponType)
24 type: WeaponType;
25
26 @IsNumber()
27 @Min(1)
28 @Max(100)
29 damage: number;
30}Zwróć uwagę, że rangi i typy broni są enumami, a nie uniami typu 'miles' | 'optio'. Powód jest ten sam, dla którego cała ta biblioteka istnieje: typy TypeScriptu znikają przy kompilacji. Unia nie zostawia po sobie nic, czego walidator mógłby o cokolwiek zapytać w czasie działania programu; enum zostaje zwykłym obiektem, więc @IsEnum(WeaponType) ma z czym porównywać.
@Min i @Max przy damage to nie ozdobnik. Bez nich klient przyśle damage: -5 albo damage: 999999 i baza przyjmie obie wartości bez mrugnięcia.
Krok 2 - walidacja zagnieżdżona
1export class CreateLegionaryDto {
2 @IsString()
3 @IsNotEmpty()
4 @MinLength(2)
5 @MaxLength(50)
6 @Transform(({ value }) => value.trim())
7 name: string;
8
9 @IsEnum(LegionaryRank)
10 rank: LegionaryRank;
11
12 @IsNumber()
13 @Min(16)
14 @Max(65)
15 age: number;
16
17 @Matches(/^LEG-[A-Z]{2,4}-\d{4}$/, {
18 message: 'ID musi mieć format LEG-XX-0000',
19 })
20 militaryId: string;
21
22 @IsArray()
23 @ArrayMinSize(1)
24 @IsString({ each: true })
25 skills: string[];
26
27 @ValidateNested()
28 @Type(() => CreateWeaponDto)
29 primaryWeapon: CreateWeaponDto;
30
31 @IsOptional()
32 @ValidateNested()
33 @Type(() => CreateWeaponDto)
34 secondaryWeapon?: CreateWeaponDto;
35}Tutaj kryje się najczęstsza pułapka całego modułu. @ValidateNested() bez @Type() nie zadziała - i nie zgłosi błędu. Bez @Type transformer zostawia w polu primaryWeapon zwykły obiekt, a nie instancję CreateWeaponDto; walidator zagląda do niego, nie znajduje żadnych dekoratorów i uznaje, że wszystko w porządku. Broń o ujemnej sile przejdzie przez bramę, bo strażnika nikt nie postawił.
Dwa dopiski warte uwagi. @IsString({ each: true }) sprawdza każdy element tablicy, a nie tablicę jako całość - bez each reguła odnosiłaby się do samego pola skills. @IsOptional() musi stać przed pozostałymi dekoratorami pola opcjonalnego: mówi walidatorowi, żeby pominął resztę reguł, gdy wartości nie ma, zamiast wymagać jej.
Krok 3 - tablica zagnieżdżonych obiektów
1export class CreateLegionDto {
2 @IsString()
3 @IsNotEmpty()
4 @MinLength(3)
5 name: string;
6
7 @IsEnum(LegionStatus)
8 status: LegionStatus;
9
10 @IsNumber()
11 @Min(1000)
12 @Max(6000)
13 maxSoldiers: number;
14
15 @IsArray()
16 @ArrayMinSize(1)
17 @ValidateNested({ each: true })
18 @Type(() => CreateLegionaryDto)
19 soldiers: CreateLegionaryDto[];
20}Różnica wobec poprzedniego kroku mieści się w dwóch słowach: { each: true }. Bez tego @ValidateNested() sprawdziłby tablicę jako jeden obiekt - czyli w praktyce nic. Z tym zapisem każdy legionista przechodzi pełną walidację osobno, wraz ze swoją bronią, bo zagnieżdżenie działa rekurencyjnie: legion → legionista → broń.
Warto zdać sobie sprawę z ceny. Legion na sześć tysięcy żołnierzy, każdy z dwiema sztukami broni, to kilkadziesiąt tysięcy sprawdzeń przy jednym żądaniu. Przy takich rozmiarach dodaje się @ArrayMaxSize i rozważa przyjmowanie żołnierzy osobnym endpointem - walidacja jest tania, ale nie darmowa.
Krok 4 - własny walidator
1@ValidatorConstraint({ async: false })
2class IsValidMilitaryIdConstraint implements ValidatorConstraintInterface {
3 validate(value: string): boolean {
4 if (!value) return false;
5 const parts = value.split('-');
6 if (parts.length !== 3) return false;
7 if (parts[0] !== 'LEG') return false;
8 const numericPart = parseInt(parts[2], 10);
9 return numericPart > 0 && numericPart <= 9999;
10 }
11
12 defaultMessage(): string {
13 return 'Niepoprawny identyfikator wojskowy';
14 }
15}
16
17export function IsValidMilitaryId(options?: ValidationOptions) {
18 return function (object: object, propertyName: string) {
19 registerDecorator({
20 target: object.constructor,
21 propertyName,
22 options,
23 validator: IsValidMilitaryIdConstraint,
24 });
25 };
26}Własny walidator zawsze ma dwie części: klasę z regułą i funkcję-dekorator, która ją rejestruje. Klasa dostarcza validate zwracające wartość logiczną oraz defaultMessage z komunikatem; funkcja pozwala używać reguły tak samo jak wbudowanych, przez @IsValidMilitaryId().
Po co to, skoro @Matches z kroku drugiego robi podobną rzecz? Bo wyrażenie regularne sprawdza kształt, a nie sens. Tutaj dochodzi warunek, że numer mieści się w zakresie od 1 do 9999 - reguła należąca do domeny, nie do składni. Gdy takich warunków przybędzie, wszystkie mają jedno miejsce, a komunikat błędu można napisać po ludzku. Flaga { async: false } mówi, że reguła nie sięga do bazy; przy sprawdzeniu unikalności ustawisz true i validate zwróci Promise.
Krok 5 - DTO odpowiedzi
1export class LegionaryResponseDto {
2 @Expose()
3 name: string;
4
5 @Expose()
6 rank: string;
7
8 @Expose()
9 @Transform(({ value }) => (value > 10 ? 'Veteranus' : 'Tiro'))
10 experienceLevel: string;
11
12 @Exclude()
13 secretMissionCode: string;
14
15 @Exclude()
16 salary: number;
17}Walidacja pilnuje tego, co wchodzi. Tu pilnujemy tego, co wychodzi - a to równie ważne, bo wyciek żołdu i kodów misji nie zgłosi się sam. @Expose() przepuszcza pole, @Exclude() je usuwa, @Transform liczy wartość pochodną w locie.
Jedna rzecz, o której łatwo zapomnieć: same dekoratory nic nie zrobią. Aby zadziałały, kontroler musi mieć @UseInterceptors(ClassSerializerInterceptor) albo trzeba wywołać plainToInstance z opcją excludeExtraneousValues: true. Bez tego serwis zwróci zwykły obiekt, a @Exclude() pozostanie adnotacją bez skutku - i właśnie tak wyciekają dane, o których wszyscy myśleli, że są ukryte.
Krok 6 - konfiguracja bramy
1async function bootstrap() {
2 const app = await NestFactory.create(AppModule);
3
4 app.useGlobalPipes(
5 new ValidationPipe({
6 whitelist: true,
7 forbidNonWhitelisted: true,
8 transform: true,
9 transformOptions: {
10 enableImplicitConversion: true,
11 },
12 }),
13 );
14
15 await app.listen(3000);
16}Cztery opcje, cztery decyzje. whitelist: true wycina pola, których nie ma w DTO. forbidNonWhitelisted: true idzie dalej i odrzuca takie żądanie z błędem 400 - to wybór surowszy, a przez to uczciwszy: klient dowie się, że przysłał coś nieoczekiwanego, zamiast myśleć, że zapisałeś jego pole.
transform: true sprawia, że do handlera trafia instancja klasy DTO, nie zwykły obiekt - bez tego @Type i @Transform z wcześniejszych kroków pozostają martwe. enableImplicitConversion: true dokłada konwersję typów prostych: "25" z parametru zapytania stanie się liczbą 25, bo typ pola mówi number. Tę ostatnią opcję włączaj świadomie - bywa wygodna, ale potrafi zamienić "0" na 0, a "false" na true.
Co oddajesz
Projekt jest gotowy, gdy spełnia pięć warunków:
- CRUD dla legionów - endpointy tworzenia, odczytu, aktualizacji i usuwania.
- DTO z
class-validatordla każdego wejścia, z walidacją zagnieżdżoną i tablicami. - Co najmniej jeden własny walidator zarejestrowany przez
registerDecorator. - Transformacje z
class-transformerna wejściu (@Type,@Transform) i na wyjściu (@Expose,@Exclude). - Globalny
ValidationPipez opcjami z kroku szóstego.
Sprawdź na koniec dwie rzeczy, które przechodzą niezauważone najczęściej: czy każde @ValidateNested ma obok siebie @Type, i czy pola oznaczone @Exclude() naprawdę nie pojawiają się w odpowiedzi. Jedno i drugie zawodzi po cichu - bez błędu, bez ostrzeżenia, aż do dnia, w którym ktoś to zauważy.
Prześlij link do repozytorium, gdy skończysz.
Kod do tej lekcji: src/complete-validation.ts
1// Projekt główny - Kompletny system walidacji Imperium
2import {
3 IsString, IsNotEmpty, IsEnum, IsNumber,
4 Min, Max, MinLength, MaxLength, IsArray,
5 ArrayMinSize, ValidateNested, IsOptional, Matches,
6 ValidatorConstraint, ValidatorConstraintInterface,
7 registerDecorator, ValidationOptions,
8} from 'class-validator';
9import { Type, Transform, Exclude, Expose } from 'class-transformer';
10
11// === ENUMY ===
12enum LegionaryRank {
13 MILES = 'miles',
14 OPTIO = 'optio',
15 CENTURIO = 'centurio',
16 LEGATUS = 'legatus',
17}
18
19enum WeaponType {
20 GLADIUS = 'gladius',
21 PILUM = 'pilum',
22 SCUTUM = 'scutum',
23}
24
25// === CUSTOM VALIDATOR ===
26// TODO: Zaimplementuj IsValidMilitaryId
27@ValidatorConstraint({ async: false })
28class IsValidMilitaryIdConstraint
29 implements ValidatorConstraintInterface {
30 validate(value: string): boolean {
31 if (!value) return false;
32 return /^LEG-[A-Z]{2,4}-\d{4}$/.test(value);
33 }
34 defaultMessage(): string {
35 return 'Niepoprawny ID wojskowy!';
36 }
37}
38
39// === WEAPON DTO ===
40class CreateWeaponDto {
41 @IsString()
42 @IsNotEmpty()
43 name: string;
44
45 @IsEnum(WeaponType)
46 type: WeaponType;
47
48 @IsNumber()
49 @Min(1)
50 @Max(100)
51 damage: number;
52}
53
54// === LEGIONARY DTO ===
55// TODO: Uzupełnij brakujące dekoratory
56class CreateLegionaryDto {
57 @IsString()
58 @IsNotEmpty()
59 @MinLength(2)
60 @Transform(({ value }) => value.trim())
61 name: string;
62
63 @IsEnum(LegionaryRank)
64 rank: LegionaryRank;
65
66 @IsNumber()
67 @Min(16)
68 @Max(65)
69 age: number;
70
71 // TODO: Dodaj walidację zagnieżdżoną dla broni
72 @ValidateNested()
73 @Type(() => CreateWeaponDto)
74 weapon: CreateWeaponDto;
75
76 @IsArray()
77 @ArrayMinSize(1)
78 @IsString({ each: true })
79 skills: string[];
80}
81
82// === RESPONSE DTO ===
83class LegionaryResponse {
84 @Expose()
85 name: string;
86
87 @Expose()
88 rank: string;
89
90 @Exclude()
91 salary: number;
92}
93
94console.log('Kompletny system walidacji Imperium gotowy!');
95Widzisz błąd w tej lekcji?