Kurs NestJS · Moduł 10: Walidacja danych

Projekt - kompletny system walidacji Imperium

6 min czytania
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:

  1. Przychodzi zwykły obiekt JSON - bez klas, bez typów, wszystko jest tekstem albo liczbą.
  2. class-transformer zamienia go w instancję klasy DTO, wykonując po drodze @Type i @Transform.
  3. class-validator sprawdza dekoratory walidacji na tej instancji.
  4. 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:

  1. CRUD dla legionów - endpointy tworzenia, odczytu, aktualizacji i usuwania.
  2. DTO z class-validator dla każdego wejścia, z walidacją zagnieżdżoną i tablicami.
  3. Co najmniej jeden własny walidator zarejestrowany przez registerDecorator.
  4. Transformacje z class-transformer na wejściu (@Type, @Transform) i na wyjściu (@Expose, @Exclude).
  5. Globalny ValidationPipe z 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!');
95

Widzisz błąd w tej lekcji?

Przydatne artykuły