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

Projekt - kompletny system walidacji Imperium

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, @name - bez błędu, bez ostrzeżenia, aż do dnia, w którym ktoś to zauważy.

Prześlij link do repozytorium, gdy skończysz.

Przejdź do CodeWorlds