206 lines
5.3 KiB
Markdown
206 lines
5.3 KiB
Markdown
---
|
|
title: Use Pipes for Input Transformation
|
|
impact: MEDIUM
|
|
impactDescription: Pipes ensure clean, validated data reaches your handlers
|
|
tags: api, pipes, validation, transformation
|
|
---
|
|
|
|
## Use Pipes for Input Transformation
|
|
|
|
Use built-in pipes like `ParseIntPipe`, `ParseUUIDPipe`, and `DefaultValuePipe` for common transformations. Create custom pipes for business-specific transformations. Pipes separate validation/transformation logic from controllers.
|
|
|
|
**Incorrect (manual type parsing in handlers):**
|
|
|
|
```typescript
|
|
// Manual type parsing in handlers
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string): Promise<User> {
|
|
// Manual validation in every handler
|
|
const uuid = id.trim();
|
|
if (!isUUID(uuid)) {
|
|
throw new BadRequestException('Invalid UUID');
|
|
}
|
|
return this.usersService.findOne(uuid);
|
|
}
|
|
|
|
@Get()
|
|
async findAll(
|
|
@Query('page') page: string,
|
|
@Query('limit') limit: string,
|
|
): Promise<User[]> {
|
|
// Manual parsing and defaults
|
|
const pageNum = parseInt(page) || 1;
|
|
const limitNum = parseInt(limit) || 10;
|
|
return this.usersService.findAll(pageNum, limitNum);
|
|
}
|
|
}
|
|
|
|
// Type coercion without validation
|
|
@Get()
|
|
async search(@Query('price') price: string): Promise<Product[]> {
|
|
const priceNum = +price; // NaN if invalid, no error
|
|
return this.productsService.findByPrice(priceNum);
|
|
}
|
|
```
|
|
|
|
**Correct (use built-in and custom pipes):**
|
|
|
|
```typescript
|
|
// Use built-in pipes for common transformations
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get(':id')
|
|
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
|
|
// id is guaranteed to be a valid UUID
|
|
return this.usersService.findOne(id);
|
|
}
|
|
|
|
@Get()
|
|
async findAll(
|
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
|
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
|
): Promise<User[]> {
|
|
// Automatic defaults and type conversion
|
|
return this.usersService.findAll(page, limit);
|
|
}
|
|
|
|
@Get('by-status/:status')
|
|
async findByStatus(
|
|
@Param('status', new ParseEnumPipe(UserStatus)) status: UserStatus,
|
|
): Promise<User[]> {
|
|
return this.usersService.findByStatus(status);
|
|
}
|
|
}
|
|
|
|
// Custom pipe for business logic
|
|
@Injectable()
|
|
export class ParseDatePipe implements PipeTransform<string, Date> {
|
|
transform(value: string): Date {
|
|
const date = new Date(value);
|
|
if (isNaN(date.getTime())) {
|
|
throw new BadRequestException('Invalid date format');
|
|
}
|
|
return date;
|
|
}
|
|
}
|
|
|
|
@Get('reports')
|
|
async getReports(
|
|
@Query('from', ParseDatePipe) from: Date,
|
|
@Query('to', ParseDatePipe) to: Date,
|
|
): Promise<Report[]> {
|
|
return this.reportsService.findBetween(from, to);
|
|
}
|
|
|
|
// Custom transformation pipes
|
|
@Injectable()
|
|
export class NormalizeEmailPipe implements PipeTransform<string, string> {
|
|
transform(value: string): string {
|
|
if (!value) return value;
|
|
return value.trim().toLowerCase();
|
|
}
|
|
}
|
|
|
|
// Parse comma-separated values
|
|
@Injectable()
|
|
export class ParseArrayPipe implements PipeTransform<string, string[]> {
|
|
transform(value: string): string[] {
|
|
if (!value) return [];
|
|
return value.split(',').map((v) => v.trim()).filter(Boolean);
|
|
}
|
|
}
|
|
|
|
@Get('products')
|
|
async findProducts(
|
|
@Query('ids', ParseArrayPipe) ids: string[],
|
|
@Query('email', NormalizeEmailPipe) email: string,
|
|
): Promise<Product[]> {
|
|
// ids is already an array, email is normalized
|
|
return this.productsService.findByIds(ids);
|
|
}
|
|
|
|
// Sanitize HTML input
|
|
@Injectable()
|
|
export class SanitizeHtmlPipe implements PipeTransform<string, string> {
|
|
transform(value: string): string {
|
|
if (!value) return value;
|
|
return sanitizeHtml(value, { allowedTags: [] });
|
|
}
|
|
}
|
|
|
|
// Global validation pipe with transformation
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true, // Strip non-DTO properties
|
|
transform: true, // Auto-transform to DTO types
|
|
transformOptions: {
|
|
enableImplicitConversion: true, // Convert query strings to numbers
|
|
},
|
|
forbidNonWhitelisted: true, // Throw on extra properties
|
|
}),
|
|
);
|
|
|
|
// DTO with transformation decorators
|
|
export class FindProductsDto {
|
|
@IsOptional()
|
|
@Type(() => Number)
|
|
@IsInt()
|
|
@Min(1)
|
|
page?: number = 1;
|
|
|
|
@IsOptional()
|
|
@Type(() => Number)
|
|
@IsInt()
|
|
@Min(1)
|
|
@Max(100)
|
|
limit?: number = 10;
|
|
|
|
@IsOptional()
|
|
@Transform(({ value }) => value?.toLowerCase())
|
|
@IsString()
|
|
search?: string;
|
|
|
|
@IsOptional()
|
|
@Transform(({ value }) => value?.split(','))
|
|
@IsArray()
|
|
@IsString({ each: true })
|
|
categories?: string[];
|
|
}
|
|
|
|
@Get()
|
|
async findAll(@Query() dto: FindProductsDto): Promise<Product[]> {
|
|
// dto is already transformed and validated
|
|
return this.productsService.findAll(dto);
|
|
}
|
|
|
|
// Pipe error customization
|
|
@Injectable()
|
|
export class CustomParseIntPipe extends ParseIntPipe {
|
|
constructor() {
|
|
super({
|
|
exceptionFactory: (error) =>
|
|
new BadRequestException(`${error} must be a valid integer`),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Or use options on built-in pipes
|
|
@Get(':id')
|
|
async findOne(
|
|
@Param(
|
|
'id',
|
|
new ParseIntPipe({
|
|
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE,
|
|
exceptionFactory: () => new NotAcceptableException('ID must be numeric'),
|
|
}),
|
|
)
|
|
id: number,
|
|
): Promise<Item> {
|
|
return this.itemsService.findOne(id);
|
|
}
|
|
```
|
|
|
|
Reference: [NestJS Pipes](https://docs.nestjs.com/pipes)
|