Chuyển đến nội dung chính

第 8 課:驗證、管道和異常過濾器

類別驗證器、類別轉換器、ValidationPipe、自訂管道。內建異常過濾器、自訂異常過濾器、HTTP 異常和錯誤處理最佳實務。

💻 程式設計 — 第 8 課 第 8 課:驗證、管道和異常 過濾器

NestJS:從基礎到高級

第 2 部分:提供程式、依賴注入和資料層

亞洲開發網

1. ValidationPipe-自動驗證

# Cài packages
npm install class-validator class-transformer
// main.ts — Bật global validation
import { ValidationPipe } from '@nestjs/common';

const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,           // Strip properties không có decorator
  forbidNonWhitelisted: true, // Throw error nếu có property lạ
  transform: true,           // Auto-transform types (string → number)
  transformOptions: {
    enableImplicitConversion: true,
  },
}));

帶有驗證裝飾器的 DTO

import {
  IsString, IsEmail, IsOptional, IsEnum,
  MinLength, MaxLength, IsNumber, Min, Max,
  IsBoolean, IsArray, ValidateNested, IsUUID,
  IsNotEmpty, Matches, IsUrl, IsDateString,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  @MinLength(2)
  @MaxLength(100)
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
    message: 'Password phải có ít nhất 1 chữ hoa, 1 chữ thường, 1 số',
  })
  password: string;

  @IsOptional()
  @IsEnum(['admin', 'user'])
  role?: string;

  @IsOptional()
  @IsUrl()
  avatar?: string;
}

export class CreatePostDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(200)
  title: string;

  @IsString()
  content: string;

  @IsOptional()
  @IsBoolean()
  published?: boolean;

  @IsOptional()
  @IsArray()
  @IsString({ each: true })  // Validate từng phần tử
  tags?: string[];
}

// Nested validation
export class CreateOrderDto {
  @IsUUID()
  productId: string;

  @IsNumber()
  @Min(1)
  @Max(100)
  quantity: number;

  @ValidateNested()
  @Type(() => AddressDto)
  shippingAddress: AddressDto;
}

export class AddressDto {
  @IsString() street: string;
  @IsString() city: string;
  @IsString() country: string;
}

驗證錯誤回應

// POST /users với body: { "name": "", "email": "invalid" }
// Response 400:
{
  "statusCode": 400,
  "message": [
    "name should not be empty",
    "name must be longer than or equal to 2 characters",
    "email must be an email"
  ],
  "error": "Bad Request"
}

2. 定制管道

// Parse UUID Pipe
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { validate as isUUID } from 'uuid';

@Injectable()
export class ParseUUIDPipe implements PipeTransform {
  transform(value: string) {
    if (!isUUID(value)) {
      throw new BadRequestException(`"${value}" is not a valid UUID`);
    }
    return value;
  }
}

// Sử dụng
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
  return this.service.findOne(id);
}
// Transform Pipe — trim và lowercase
@Injectable()
export class TrimPipe implements PipeTransform {
  transform(value: any) {
    if (typeof value === 'string') {
      return value.trim().toLowerCase();
    }
    if (typeof value === 'object' && value !== null) {
      for (const key in value) {
        if (typeof value[key] === 'string') {
          value[key] = value[key].trim();
        }
      }
    }
    return value;
  }
}

內建管道

import { ParseIntPipe, ParseBoolPipe, ParseUUIDPipe, DefaultValuePipe } from '@nestjs/common';

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { ... }

@Get()
findAll(
  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
  @Query('active', new DefaultValuePipe(true), ParseBoolPipe) active: boolean,
) { ... }

@Get(':id')
findByUUID(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { ... }

3. 異常過濾器

內建 HTTP 異常

import {
  BadRequestException,     // 400
  UnauthorizedException,   // 401
  ForbiddenException,      // 403
  NotFoundException,       // 404
  ConflictException,       // 409
  UnprocessableEntityException, // 422
  InternalServerErrorException, // 500
} from '@nestjs/common';

@Injectable()
export class UsersService {
  async findOne(id: string): Promise<User> {
    const user = await this.repo.findOne({ where: { id } });
    if (!user) {
      throw new NotFoundException(`User với ID "${id}" không tồn tại`);
    }
    return user;
  }

  async create(dto: CreateUserDto): Promise<User> {
    const existing = await this.repo.findOne({ where: { email: dto.email } });
    if (existing) {
      throw new ConflictException('Email đã được sử dụng');
    }
    return this.repo.save(this.repo.create(dto));
  }
}

自訂異常過濾器

import {
  ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()  // Bắt TẤT CẢ exceptions
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal Server Error';
    let errors: any = null;

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const res = exception.getResponse();
      message = typeof res === 'string' ? res : (res as any).message;
      errors = typeof res === 'object' ? (res as any).errors : null;
    }

    response.status(status).json({
      success: false,
      statusCode: status,
      message,
      errors,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

// Đăng ký global
// main.ts
app.useGlobalFilters(new AllExceptionsFilter());

// Hoặc qua module
@Module({
  providers: [
    { provide: APP_FILTER, useClass: AllExceptionsFilter },
  ],
})
export class AppModule {}

自訂業務例外

// Tạo exception riêng cho business logic
export class InsufficientBalanceException extends HttpException {
  constructor(balance: number, required: number) {
    super(
      {
        message: 'Số dư không đủ',
        balance,
        required,
        deficit: required - balance,
      },
      HttpStatus.UNPROCESSABLE_ENTITY,
    );
  }
}

// Sử dụng
if (user.balance < order.total) {
  throw new InsufficientBalanceException(user.balance, order.total);
}

4. 總結

  • 驗證管道:全域啟用白名單、轉換 — 自動驗證 DTO
  • 類別驗證器:@IsString、@IsEmail、@MinLength 等裝飾器
  • 客製化管道:在到達處理程序之前轉換/驗證數據
  • 異常過濾器:為整個應用程式統一捕獲和格式化錯誤
  • 總是扔 HttpException 特定錯誤而不是通用錯誤

下一篇文章將會部署 使用 Passport 和 JWT 進行身份驗證。