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.
Zanim napiszesz pierwszą linijkę, zapamiętaj drogę, którą przebywa żądanie:
class-transformer zamienia go w instancję klasy DTO, wykonując po drodze @Type i @Transform.class-validator sprawdza dekoratory walidacji na tej instancji.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.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.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.
bez @ValidateNested()
nie zadziała - i nie zgłosi błędu. Bez @Type()
@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.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:
. Bez tego { each: true }
@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.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.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.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.Projekt jest gotowy, gdy spełnia pięć warunków:
class-validator dla każdego wejścia, z walidacją zagnieżdżoną i tablicami.registerDecorator.class-transformer na wejściu (@Type, @Transform) i na wyjściu (@Expose, @Exclude).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.