Validation và transform dữ liệu trong NestJS sử dụng Class Validator và Class Transformer
- Published on
Nội dung
- Giới thiệu
- Cài đặt
- Class Validator
- Class Transformer
- Kết hợp Class Validator và Class Transformer
- Kích hoạt Validation toàn cục
- Xử lý lỗi validation
- Kết luận
Giới thiệu
Trong các ứng dụng web, việc xác thực dữ liệu đầu vào và chuyển đổi dữ liệu là hai nhiệm vụ quan trọng. NestJS cung cấp hai công cụ mạnh mẽ để thực hiện điều này:
- Class Validator: Giúp xác thực dữ liệu đầu vào theo các quy tắc định sẵn
- Class Transformer: Cho phép chuyển đổi dữ liệu giữa plain objects và class instances
Việc sử dụng chúng mang lại các lợi ích:
- Đảm bảo tính toàn vẹn của dữ liệu
- Tự động chuyển đổi kiểu dữ liệu
- Code dễ đọc và bảo trì hơn
- Tách biệt logic xử lý dữ liệu
Cài đặt
Trước tiên, bạn cần cài đặt các thư viện liên quan:
npm install class-validator class-transformer
Nếu bạn đang sử dụng @nestjs/common
và @nestjs/core
phiên bản mới nhất, các thư viện này đã được tích hợp sẵn.
Class Validator
Trường hợp cơ bản với Class Validator
import { IsString, IsEmail, IsNotEmpty, MinLength, IsOptional, IsNumber, Min, Max } from 'class-validator';
// DTO để tạo mới user
export class CreateUserDto {
// Validate tên người dùng:
// - Phải là string
// - Không được rỗng
// - Độ dài tối thiểu 2 ký tự
@IsString()
@IsNotEmpty({ message: 'Tên người dùng không được để trống' })
@MinLength(2, { message: 'Tên người dùng phải có ít nhất 2 ký tự' })
name: string;
// Validate email:
// - Phải đúng định dạng email
// - Không được rỗng
@IsEmail({}, { message: 'Email không hợp lệ' })
@IsNotEmpty({ message: 'Email không được để trống' })
email: string;
// Validate mật khẩu:
// - Phải là string
// - Độ dài tối thiểu 6 ký tự
@IsString()
@MinLength(6, { message: 'Mật khẩu phải có ít nhất 6 ký tự' })
password: string;
// Validate tuổi (không bắt buộc):
// - Phải là số
// - Giá trị từ 1-150
@IsOptional() // Trường không bắt buộc
@IsNumber({}, { message: 'Tuổi phải là số' })
@Min(1, { message: 'Tuổi phải lớn hơn 0' })
@Max(150, { message: 'Tuổi không hợp lệ' })
age?: number;
}
Trường hợp nâng cao với Class Validator
- Validation với điều kiện phức tạp
import { IsString, ValidateIf, IsNotEmpty, IsEnum, IsArray, ArrayMinSize, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
// Enum định nghĩa các role có thể có
enum UserRole {
ADMIN = 'admin',
USER = 'user',
MANAGER = 'manager'
}
// Class định nghĩa quyền hạn
class Permission {
@IsString()
@IsNotEmpty()
resource: string; // Tên resource (vd: 'users', 'products')
@IsArray()
@IsString({ each: true })
actions: string[]; // Các hành động được phép (vd: ['read', 'write'])
}
export class UpdateUserRoleDto {
// Validate role phải thuộc enum UserRole
@IsEnum(UserRole, { message: 'Role không hợp lệ' })
role: UserRole;
// adminCode chỉ bắt buộc khi role là ADMIN
@ValidateIf(o => o.role === UserRole.ADMIN)
@IsString()
@IsNotEmpty({ message: 'Admin code bắt buộc cho role admin' })
adminCode?: string;
// departments chỉ bắt buộc khi role là MANAGER
@ValidateIf(o => o.role === UserRole.MANAGER)
@IsArray()
@ArrayMinSize(1, { message: 'Manager cần ít nhất 1 department' })
@IsString({ each: true })
departments?: string[];
// permissions chỉ áp dụng cho ADMIN và MANAGER
@ValidateNested({ each: true })
@Type(() => Permission)
@ValidateIf(o => [UserRole.ADMIN, UserRole.MANAGER].includes(o.role))
permissions?: Permission[];
}
- Custom Validation Decorator
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
/**
* Custom decorator để kiểm tra mật khẩu mạnh
* Mật khẩu phải chứa:
* - Ít nhất 1 chữ hoa
* - Ít nhất 1 chữ thường
* - Ít nhất 1 số
* - Ít nhất 1 ký tự đặc biệt
*/
export function IsStrongPassword(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isStrongPassword',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const hasUpperCase = /[A-Z]/.test(value);
const hasLowerCase = /[a-z]/.test(value);
const hasNumbers = /\d/.test(value);
const hasSpecialChar = /[!@#$%^&*]/.test(value);
return hasUpperCase && hasLowerCase && hasNumbers && hasSpecialChar;
},
defaultMessage(args: ValidationArguments) {
return 'Mật khẩu phải chứa chữ hoa, chữ thường, số và ký tự đặc biệt';
},
},
});
};
}
/**
* Custom decorator để kiểm tra ngày trong tương lai
* Sử dụng cho các trường như:
* - Ngày hết hạn
* - Ngày dự kiến giao hàng
* - Ngày hẹn
*/
export function IsFutureDate(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isFutureDate',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any) {
return value instanceof Date && value > new Date();
},
defaultMessage(args: ValidationArguments) {
return `${args.property} phải là ngày trong tương lai`;
},
},
});
};
}
// Ví dụ sử dụng custom decorators
class EventDto {
@IsString()
title: string;
@IsFutureDate({ message: 'Ngày tổ chức phải là ngày trong tương lai' })
eventDate: Date;
@IsStrongPassword({ message: 'Mật khẩu không đủ mạnh' })
accessCode: string;
}
- Validation với Nested Objects và Arrays
import { Type } from 'class-transformer';
import { IsString, ValidateNested, IsArray, ArrayMinSize, IsNumber } from 'class-validator';
class AddressDto {
@IsString()
street: string;
@IsString()
city: string;
@IsString()
country: string;
}
class OrderItemDto {
@IsString()
productId: string;
@IsNumber()
quantity: number;
@IsNumber()
price: number;
}
export class CreateOrderDto {
@IsString()
userId: string;
@ValidateNested()
@Type(() => AddressDto)
shippingAddress: AddressDto;
@ValidateNested()
@Type(() => AddressDto)
billingAddress: AddressDto;
@IsArray()
@ValidateNested({ each: true })
@ArrayMinSize(1, { message: 'Đơn hàng phải có ít nhất 1 sản phẩm' })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
Class Transformer
Trường hợp cơ bản với Class Transformer
import { Exclude, Expose, Transform } from 'class-transformer';
export class UserResponseDto {
@Expose() // Cho phép hiển thị trường này trong response
id: string;
@Expose()
name: string;
@Expose()
email: string;
@Exclude() // Loại bỏ password khỏi response vì lý do bảo mật
password: string;
@Expose()
@Transform(({ value }) => value ? new Date(value) : null)
createdAt: Date; // Chuyển đổi string thành Date object
@Expose()
@Transform(({ value }) => value ? `${value}đ` : '0đ')
balance: number; // Thêm đơn vị tiền tệ vào số dư
@Expose()
@Transform(({ obj }) => obj.isActive ? 'Đang hoạt động' : 'Không hoạt động')
status: string; // Chuyển đổi boolean thành text dễ đọc
}
Trường hợp nâng cao với Class Transformer
- Transform với Logic Phức tạp
import { Expose, Transform, Type } from 'class-transformer';
class ProductDetailsDto {
@Expose()
name: string;
// Transform giá sản phẩm, làm tròn đến 2 chữ số thập phân
@Expose()
@Transform(({ value }) => value.toFixed(2))
price: number;
// Transform trạng thái tồn kho thành text dễ đọc
// - Nếu stock > 10: "Còn hàng"
// - Nếu 0 < stock <= 10: "Sắp hết hàng"
// - Nếu stock = 0: "Hết hàng"
@Expose()
@Transform(({ obj }) => {
if (obj.stock > 10) return 'Còn hàng';
if (obj.stock > 0) return 'Sắp hết hàng';
return 'Hết hàng';
})
stockStatus: string;
// Transform giá và tính toán chi tiết giảm giá
// Trả về object chứa:
// - originalPrice: Giá gốc
// - discountPercent: Phần trăm giảm giá
// - finalPrice: Giá sau giảm giá
// - saving: Số tiền tiết kiệm được
@Expose()
@Transform(({ obj }) => {
const discount = obj.discount || 0;
const price = obj.price;
return {
originalPrice: price,
discountPercent: discount,
finalPrice: price * (1 - discount/100),
saving: price * (discount/100)
};
})
priceDetails: any;
}
- Transform với Groups và Versions
import { Expose, Transform, Type } from 'class-transformer';
export class UserProfileDto {
// Basic fields - all versions
@Expose({ groups: ['basic', 'admin'] })
id: string;
// Version 1: Sử dụng name
@Expose({ groups: ['basic', 'admin'], toVersion: 1 })
name: string;
// Version 2: Tách thành firstName và lastName
@Expose({ groups: ['basic', 'admin'], since: 2 })
firstName: string;
@Expose({ groups: ['basic', 'admin'], since: 2 })
lastName: string;
// Chỉ admin mới xem được email
@Expose({ groups: ['admin'] })
email: string;
// Version 1: Format phone number bình thường
@Expose({ groups: ['admin'], toVersion: 1 })
@Transform(({ value }) => value?.toString())
phoneNumber: string;
// Version 2: Format phone number với mã quốc gia
@Expose({ groups: ['admin'], since: 2 })
@Transform(({ value }) => value ? `+84 ${value}` : null)
phoneNumber: string;
}
// user-profile.controller.ts
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UserProfileController {
constructor(private readonly userService: UserService) {}
// API v1 cho user thường
@Get('v1/:id')
async getBasicProfileV1(@Param('id') id: string) {
const user = await this.userService.findOne(id);
return plainToClass(UserProfileDto, user, {
groups: ['basic'],
version: 1,
excludeExtraneousValues: true
});
}
// API v2 cho admin
@Get('v2/:id/admin')
async getAdminProfileV2(@Param('id') id: string) {
const user = await this.userService.findOne(id);
return plainToClass(UserProfileDto, user, {
groups: ['basic', 'admin'],
version: 2,
excludeExtraneousValues: true
});
}
}
// Ví dụ response:
// 1. GET /v1/users/123 (Basic User - Version 1)
{
"id": "123",
"name": "John Doe"
}
// 2. GET /v2/users/123/admin (Admin - Version 2)
{
"id": "123",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phoneNumber": "+84 123456789"
}
Lưu ý khi sử dụng Groups và Versions:
- Groups dùng để phân quyền truy cập data:
basic
: Thông tin cơ bản mà ai cũng xem đượcadmin
: Thông tin chỉ admin mới xem được
- Versions dùng để quản lý thay đổi API:
toVersion
: Chỉ xuất hiện đến version nào đósince
: Bắt đầu xuất hiện từ version nào đó
Kết hợp Class Validator và Class Transformer
Ví dụ thực tế về một hệ thống đặt hàng:
import { IsString, IsNumber, Min, ValidateNested, IsArray, ArrayMinSize } from 'class-validator';
import { Expose, Transform, Type } from 'class-transformer';
// DTOs cho đầu vào
class OrderItemInputDto {
@IsString()
@Expose()
productId: string;
@IsNumber()
@Min(1)
@Expose()
quantity: number;
}
class AddressInputDto {
@IsString()
@Expose()
street: string;
@IsString()
@Expose()
city: string;
@IsString()
@Expose()
country: string;
}
export class CreateOrderInputDto {
@IsString()
@Expose()
userId: string;
@ValidateNested()
@Type(() => AddressInputDto)
@Expose()
shippingAddress: AddressInputDto;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => OrderItemInputDto)
@Expose()
items: OrderItemInputDto[];
@IsString()
@Expose()
paymentMethod: string;
}
// DTOs cho đầu ra
class OrderItemOutputDto {
@Expose()
productId: string;
@Expose()
productName: string;
@Expose()
quantity: number;
@Expose()
@Transform(({ value }) => `${value.toFixed(2)}đ`)
unitPrice: number;
@Expose()
@Transform(({ obj }) => `${(obj.quantity * obj.unitPrice).toFixed(2)}đ`)
totalPrice: string;
}
class AddressOutputDto {
@Expose()
@Transform(({ obj }) => `${obj.street}, ${obj.city}, ${obj.country}`)
fullAddress: string;
}
export class OrderOutputDto {
@Expose()
orderId: string;
@Expose()
@Transform(({ value }) => new Date(value).toLocaleString('vi-VN'))
createdAt: string;
@Expose()
@ValidateNested()
@Type(() => AddressOutputDto)
shippingAddress: AddressOutputDto;
@Expose()
@ValidateNested({ each: true })
@Type(() => OrderItemOutputDto)
items: OrderItemOutputDto[];
@Expose()
@Transform(({ obj }) => {
const total = obj.items.reduce((sum, item) =>
sum + (item.quantity * item.unitPrice), 0);
return `${total.toFixed(2)}đ`;
})
totalAmount: string;
@Expose()
@Transform(({ obj }) => {
const status = obj.status;
switch(status) {
case 'PENDING': return 'Chờ xử lý';
case 'CONFIRMED': return 'Đã xác nhận';
case 'SHIPPING': return 'Đang giao hàng';
case 'DELIVERED': return 'Đã giao hàng';
default: return 'Không xác định';
}
})
status: string;
}
Sử dụng trong controller:
plainToClass
là một hàm quan trọng trong class-transformer, giúp chuyển đổi plain JavaScript objects thành class instances.
import { Body, Controller, Post, Get, Param, UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common';
import { CreateOrderInputDto, OrderOutputDto } from './dto/order.dto';
import { plainToClass } from 'class-transformer';
@Controller('orders')
@UseInterceptors(ClassSerializerInterceptor)
export class OrdersController {
constructor(private readonly orderService: OrderService) {}
@Post()
async createOrder(@Body() createOrderDto: CreateOrderInputDto) {
const order = await this.orderService.create(createOrderDto);
return plainToClass(OrderOutputDto, order, { excludeExtraneousValues: true });
}
@Get(':id')
async getOrder(@Param('id') id: string) {
const order = await this.orderService.findOne(id);
return plainToClass(OrderOutputDto, order, { excludeExtraneousValues: true });
}
@Get()
async getAllOrders() {
const orders = await this.orderService.findAll();
return orders.map(order =>
plainToClass(OrderOutputDto, order, { excludeExtraneousValues: true })
);
}
}
Kích hoạt Validation toàn cục
NestJS cho phép bạn kích hoạt validation toàn cục để áp dụng tự động cho tất cả các DTO.
Trong file main.ts
, thêm middleware ValidationPipe
:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Kích hoạt ValidationPipe
app.useGlobalPipes(new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: true,
exposeDefaultValues: true
}
}));
await app.listen(3000);
}
bootstrap();
Xử lý lỗi validation
NestJS sẽ tự động trả về lỗi validation nếu dữ liệu không hợp lệ. Bạn có thể tuỳ chỉnh thông báo lỗi và thêm xử lý như sau:
app.useGlobalPipes(new ValidationPipe({
exceptionFactory: (errors) => {
return new BadRequestException(
errors.map(err => ({
field: err.property,
errors: Object.values(err.constraints),
}))
);
},
}));
Kết quả trả về:
{
"statusCode": 400,
"message": [
{
"field": "name",
"errors": ["Tên người dùng không được để trống"]
}
]
}
Kết luận
Class Validator và Class Transformer là bộ đôi công cụ mạnh mẽ trong NestJS. Khi được sử dụng riêng lẻ, mỗi công cụ đều có những ưu điểm riêng, nhưng khi kết hợp chúng lại, bạn có thể:
- Xác thực dữ liệu đầu vào một cách chặt chẽ
- Tự động chuyển đổi kiểu dữ liệu
- Kiểm soát dữ liệu đầu ra
- Tạo ra API có tính nhất quán và dễ bảo trì
Việc hiểu và sử dụng hiệu quả cả hai công cụ này sẽ giúp bạn xây dựng các ứng dụng NestJS chất lượng và bảo mật hơn.
Hãy thử áp dụng ngay hôm nay để thấy sự khác biệt trong cách xử lý dữ liệu của bạn!