Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds

Pipes w NestJS

Ave, sprytni legioniści! Konsul Caesar.js zabiera was w kolejną przygodę po świecie NestJS. Dziś poznamy Pipes - mistrzowskich rzemieślników naszego forum, którzy potrafią przekształcić surowe materiały w najcenniejsze zasoby i sprawdzić każdy ładunek przed przyjęciem do systemu.

Pipes w NestJS to jak najlepsi rzemieślnicy i kontrolerzy jakości w imperium rzymskim - sprawdzają każdy przychodzący ładunek, przekształcają surowe materiały w użyteczne przedmioty i odrzucają wszystko co nie spełnia standardów naszego legionu.

Czym są Pipes?

Pipes to klasy implementujące interfejs

PipeTransform
, które:

  • Transformują dane wejściowe do pożądanego formatu
  • Walidują dane przed przekazaniem do handlera
  • Mogą rzucać wyjątki jeśli dane są nieprawidłowe
  • Działają tuż przed wykonaniem metody kontrolera
1import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
2
3@Injectable()
4export class ImperiumTributeValidationPipe implements PipeTransform {
5 transform(value: any, metadata: ArgumentMetadata) {
6 console.log(`📦 Sprawdzanie ładunku: ${JSON.stringify(value)}`);
7
8 if (!this.isValidTribute(value)) {
9 throw new BadRequestException('To nie jest prawdziwy tribut imperium! 🏛️');
10 }
11
12 return this.polishTribute(value);
13 }
14
15 private isValidTribute(tribute: any): boolean {
16 return tribute &&
17 typeof tribute.name === 'string' &&
18 typeof tribute.value === 'number' &&
19 tribute.value > 0;
20 }
21
22 private polishTribute(tribute: any) {
23 return {
24 ...tribute,
25 name: tribute.name.trim(),
26 value: Math.round(tribute.value),
27 inspected: true,
28 inspectionDate: new Date().toISOString()
29 };
30 }
31}

Wbudowane Pipes

NestJS dostarcza kilka gotowych pipes:

ValidationPipe

Waliduje dane używając class-validator:

1import { IsString, IsNumber, IsPositive, IsOptional, Length } from 'class-validator';
2
3export class CreateTributeDto {
4 @IsString()
5 @Length(2, 50)
6 name: string;
7
8 @IsNumber()
9 @IsPositive()
10 value: number;
11
12 @IsString()
13 @IsOptional()
14 description?: string;
15
16 @IsString()
17 location: string;
18}
19
20@Controller('tributes')
21export class TributesController {
22 @Post()
23 @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
24 createTribute(@Body() tributeDto: CreateTributeDto) {
25 return {
26 message: 'Tribut dodany do skarbca imperium!',
27 tribute: tributeDto
28 };
29 }
30}

ParseIntPipe

Przekształca string na liczbę:

1@Controller('legion')
2export class LegionController {
3 @Get(':id')
4 getLegionary(@Param('id', ParseIntPipe) id: number) {
5 return {
6 id,
7 name: `Legionista #${id}`,
8 type: typeof id // będzie 'number'
9 };
10 }
11
12 @Get('rank/:level')
13 getByRank(
14 @Param('level', new ParseIntPipe({
15 errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE
16 })) level: number
17 ) {
18 if (level < 1 || level > 10) {
19 throw new BadRequestException('Ranga musi być między 1 a 10!');
20 }
21 return { rank: level };
22 }
23}

ParseBoolPipe

Przekształca string na boolean:

1@Controller('forum')
2export class ForumController {
3 @Get('status')
4 getForumStatus(@Query('detailed', ParseBoolPipe) detailed: boolean) {
5 const basicStatus = {
6 name: 'Legio X Equestris',
7 consul: 'Caesar.js'
8 };
9
10 if (detailed) {
11 return {
12 ...basicStatus,
13 legionaries: 300,
14 ballistae: 20,
15 speed: '30 km/dziennie',
16 supplies: {
17 food: '30 dni',
18 water: '25 dni',
19 wine: '100 amfor'
20 }
21 };
22 }
23
24 return basicStatus;
25 }
26}

Custom Validation Pipes

Tribute Type Validation Pipe

Sprawdza czy typ tributu jest dozwolony:

1@Injectable()
2export class TributeTypeValidationPipe implements PipeTransform {
3 private readonly allowedTypes = [
4 'gold',
5 'silver',
6 'jewels',
7 'artifacts',
8 'scrolls',
9 'armor'
10 ];
11
12 transform(value: string, metadata: ArgumentMetadata) {
13 if (!value) {
14 throw new BadRequestException('Typ tributu jest wymagany!');
15 }
16
17 const normalizedType = value.toLowerCase().trim();
18
19 if (!this.allowedTypes.includes(normalizedType)) {
20 throw new BadRequestException(
21 `Nieznany typ tributu: ${value}. Dozwolone typy: ${this.allowedTypes.join(', ')}`
22 );
23 }
24
25 return normalizedType;
26 }
27}
28
29@Controller('tributes')
30export class TributesController {
31 @Get('type/:type')
32 getTributesByType(
33 @Param('type', TributeTypeValidationPipe) type: string
34 ) {
35 return {
36 type,
37 tributes: this.getTributesOfType(type)
38 };
39 }
40}

Legionary Rank Validation Pipe

Waliduje i normalizuje rangi legionistów:

1enum LegionaryRank {
2 MILES = 'miles',
3 TESSERARIUS = 'tesserarius',
4 OPTIO = 'optio',
5 CENTURION = 'centurion',
6 TRIBUNE = 'trybun'
7}
8
9@Injectable()
10export class LegionaryRankPipe implements PipeTransform<string, LegionaryRank> {
11 transform(value: string, metadata: ArgumentMetadata): LegionaryRank {
12 if (!value) {
13 throw new BadRequestException('Ranga legionisty jest wymagana!');
14 }
15
16 const normalizedRank = value.toLowerCase().replace(/s+/g, '_');
17 const rankMapping = {
18 'miles': LegionaryRank.MILES,
19 'legionista': LegionaryRank.MILES,
20 'tesserarius': LegionaryRank.TESSERARIUS,
21 'straznik': LegionaryRank.TESSERARIUS,
22 'optio': LegionaryRank.OPTIO,
23 'zastepca': LegionaryRank.OPTIO,
24 'centurion': LegionaryRank.CENTURION,
25 'dowodca': LegionaryRank.CENTURION,
26 'trybun': LegionaryRank.TRIBUNE,
27 'tribune': LegionaryRank.TRIBUNE
28 };
29
30 const rank = rankMapping[normalizedRank];
31
32 if (!rank) {
33 throw new BadRequestException(
34 `Nieznana ranga: ${value}. Dostępne rangi: ${Object.values(LegionaryRank).join(', ')}`
35 );
36 }
37
38 return rank;
39 }
40}

File Upload Validation Pipe

Waliduje przesyłane pliki:

1@Injectable()
2export class FileValidationPipe implements PipeTransform {
3 constructor(
4 private readonly maxSize: number = 5 * 1024 * 1024, // 5MB
5 private readonly allowedTypes: string[] = ['image/jpeg', 'image/png', 'application/pdf']
6 ) {}
7 
8 transform(file: Express.Multer.File, metadata: ArgumentMetadata) {
9 if (!file) {
10 throw new BadRequestException('Nie znaleziono pliku! Gdzie mapa tributów? ');
11 }
12
13 // Sprawdzenie rozmiaru
14 if (file.size > this.maxSize) {
15 throw new BadRequestException(
16 `Plik zbyt duży! Maksymalny rozmiar: ${this.maxSize / 1024 / 1024}MB`
17 );
18 }
19
20 // Sprawdzenie typu
21 if (!this.allowedTypes.includes(file.mimetype)) {
22 throw new BadRequestException(
23 `Nieobsługiwany typ pliku: ${file.mimetype}. Dozwolone: ${this.allowedTypes.join(', ')}`
24 );
25 }
26
27 // Sprawdzenie nazwy pliku
28 if (!/^[a-zA-Z0-9._-]+$/.test(file.originalname)) {
29 throw new BadRequestException('Nazwa pliku zawiera niedozwolone znaki!');
30 }
31
32 return {
33 ...file,
34 validated: true,
35 validatedAt: new Date().toISOString(),
36 legionApproved: true
37 };
38 }
39}
40
41@Controller('maps')
42export class MapController {
43 @Post('upload')
44 @UseInterceptors(FileInterceptor('map'))
45 uploadTributeMap(
46 @UploadedFile(FileValidationPipe) file: Express.Multer.File
47 ) {
48 return {
49 message: 'Mapa tributów przesłana pomyślnie!',
50 file: {
51 name: file.originalname,
52 size: file.size,
53 type: file.mimetype
54 }
55 };
56 }
57}

Transform Pipes

Coordinate Transformation Pipe

Przekształca współrzędne geograficzne:

1interface Coordinates {
2 latitude: number;
3 longitude: number;
4 formatted: string;
5}
6
7@Injectable()
8export class CoordinateTransformPipe implements PipeTransform<string, Coordinates> {
9 transform(value: string, metadata: ArgumentMetadata): Coordinates {
10 if (!value) {
11 throw new BadRequestException('Współrzędne są wymagane dla nawigacji!');
12 }
13 
14 // Obsługa różnych formatów: "25.5,-80.2" lub "25°30'N,80°12'W"
15 let latitude: number;
16 let longitude: number;
17 
18 if (value.includes('°')) {
19 const result = this.parseDMSFormat(value);
20 latitude = result.lat;
21 longitude = result.lng;
22 } else {
23 const [lat, lng] = value.split(',').map(coord => parseFloat(coord.trim()));
24 
25 if (isNaN(lat) || isNaN(lng)) {
26 throw new BadRequestException('Nieprawidłowy format współrzędnych!');
27 }
28 
29 latitude = lat;
30 longitude = lng;
31 }
32 
33 // Walidacja zakresów
34 if (latitude < -90 || latitude > 90) {
35 throw new BadRequestException('Szerokość geograficzna musi być między -90 a 90');
36 }
37 
38 if (longitude < -180 || longitude > 180) {
39 throw new BadRequestException('Długość geograficzna musi być między -180 a 180');
40 }
41 
42 return {
43 latitude,
44 longitude,
45 formatted: this.formatCoordinates(latitude, longitude)
46 };
47 }
48 
49 private parseDMSFormat(value: string) {
50 // Przykład: "25°30'12"N,80°15'30"W"
51 const regex = /(d+)°(d+)'(d+)"([NSEW])/g;
52 const matches = Array.from(value.matchAll(regex));
53 
54 if (matches.length !== 2) {
55 throw new BadRequestException('Nieprawidłowy format DMS!');
56 }
57 
58 const lat = this.dmsToDecimal(matches[0]);
59 const lng = this.dmsToDecimal(matches[1]);
60 
61 return { lat, lng };
62 }
63 
64 private dmsToDecimal(match: RegExpMatchArray): number {
65 const degrees = parseInt(match[1]);
66 const minutes = parseInt(match[2]);
67 const seconds = parseInt(match[3]);
68 const direction = match[4];
69 
70 let decimal = degrees + minutes/60 + seconds/3600;
71 
72 if (direction === 'S' || direction === 'W') {
73 decimal = -decimal;
74 }
75 
76 return decimal;
77 }
78 
79 private formatCoordinates(lat: number, lng: number): string {
80 const latDir = lat >= 0 ? 'N' : 'S';
81 const lngDir = lng >= 0 ? 'E' : 'W';
82 
83 return `${Math.abs(lat).toFixed(6)}°${latDir}, ${Math.abs(lng).toFixed(6)}°${lngDir}`
1 }
2}

Date Range Pipe

Parsuje i waliduje zakresy dat:

1interface DateRange {
2 start: Date;
3 end: Date;
4 duration: number; // w dniach
5}
6
7@Injectable()
8export class DateRangePipe implements PipeTransform<string, DateRange> {
9 transform(value: string, metadata: ArgumentMetadata): DateRange {
10 if (!value) {
11 throw new BadRequestException('Zakres dat jest wymagany dla wyprawy!');
12 }
13
14 const [startStr, endStr] = value.split(',').map(date => date.trim());
15
16 if (!startStr || !endStr) {
17 throw new BadRequestException('Podaj datę rozpoczęcia i zakończenia oddzielone przecinkiem');
18 }
19
20 const start = new Date(startStr);
21 const end = new Date(endStr);
22
23 // Walidacja dat
24 if (isNaN(start.getTime())) {
25 throw new BadRequestException(`Nieprawidłowa data rozpoczęcia: ${startStr}`);
26 }
27
28 if (isNaN(end.getTime())) {
29 throw new BadRequestException(`Nieprawidłowa data zakończenia: ${endStr}`);
30 }
31
32 if (start >= end) {
33 throw new BadRequestException('Data rozpoczęcia musi być wcześniejsza niż zakończenia!');
34 }
35
36 const now = new Date();
37 if (end < now) {
38 throw new BadRequestException('Nie można planować wypraw w przeszłość!');
39 }
40
41 const duration = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
42
43 if (duration > 365) {
44 throw new BadRequestException('Wyprawy dłuższe niż rok są zbyt ryzykowne!');
45 }
46
47 return { start, end, duration };
48 }
49}
50
51@Controller('expeditions')
52export class ExpeditionController {
53 @Get('plan')
54 planExpedition(
55 @Query('dates', DateRangePipe) dateRange: DateRange,
56 @Query('destination', CoordinateTransformPipe) destination: Coordinates
57 ) {
58 return {
59 expedition: {
60 start: dateRange.start,
61 end: dateRange.end,
62 duration: `${dateRange.duration} dni`,
63 destination: destination.formatted
64 },
65 supplies: this.calculateSupplies(dateRange.duration),
66 legionaries: this.calculateLegionSize(dateRange.duration)
67 };
68 }
69}

Array Validation Pipe

1@Injectable()
2export class LegionArrayPipe implements PipeTransform {
3 transform(value: any, metadata: ArgumentMetadata) {
4 if (!Array.isArray(value)) {
5 throw new BadRequestException('Lista legionistów musi być tablicą!');
6 }
7
8 if (value.length === 0) {
9 throw new BadRequestException('Legion nie może być pusty! Kto będzie maszerował?');
10 }
11
12 if (value.length > 300) {
13 throw new BadRequestException('Zbyt duży legion! Fort nie pomieści więcej niż 300 legionistów!');
14 }
15
16 // Walidacja każdego legionisty
17 const validatedLegion = value.map((member, index) => {
18 if (!member.name || typeof member.name !== 'string') {
19 throw new BadRequestException(`Legionista #${index + 1} musi mieć imię!`);
20 }
21
22 if (!member.rank || !Object.values(LegionaryRank).includes(member.rank)) {
23 throw new BadRequestException(`Legionista ${member.name} ma nieprawidłową rangę!`);
24 }
25
26 return {
27 ...member,
28 name: member.name.trim(),
29 joinedAt: new Date().toISOString(),
30 legionaryId: `legionary-${Date.now()}-${index}`
31 };
32 });
33
34 // Sprawdzenie czy jest centurion
35 const hasCenturion = validatedLegion.some(member =>
36 [LegionaryRank.CENTURION, LegionaryRank.TRIBUNE].includes(member.rank)
37 );
38
39 if (!hasCenturion) {
40 throw new BadRequestException('Legion musi mieć centuriona!');
41 }
42
43 return validatedLegion;
44 }
45}

Optional Validation Pipe

1@Injectable()
2export class OptionalValidationPipe implements PipeTransform {
3 constructor(private readonly innerPipe: PipeTransform) {}
4 
5 transform(value: any, metadata: ArgumentMetadata) {
6 if (value === undefined || value === null || value === '') {
7 return undefined;
8 }
9 
10 return this.innerPipe.transform(value, metadata);
11 }
12}
13
14// Użycie
15@Controller('search')
16export class SearchController {
17 @Get('tributes')
18 searchTributes(
19 @Query('type', new OptionalValidationPipe(new TributeTypeValidationPipe()))
20 type?: string,
21 @Query('minValue', new OptionalValidationPipe(ParseIntPipe))
22 minValue?: number
23 ) {
24 return {
25 filters: { type, minValue },
26 results: this.searchWithFilters(type, minValue)
27 };
28 }
29}

Global Pipes

1// main.ts
2async function bootstrap() {
3 const app = await NestFactory.create(AppModule);
4 
5 app.useGlobalPipes(
6 new ValidationPipe({
7 whitelist: true,
8 forbidNonWhitelisted: true,
9 transform: true,
10 disableErrorMessages: false,
11 })
12 );
13 
14 await app.listen(3000);
15}
16
17// Lub w module
18@Module({
19 providers: [
20 {
21 provide: APP_PIPE,
22 useClass: ValidationPipe,
23 },
24 ],
25})
26export class AppModule {}
Ir a CodeWorlds