在快节奏的开发世界中,数据完整性和可靠性至关重要。强大的数据验证和有效的用户数据处理可以带来流畅的体验和不一致的应用程序状态之间的差异。
下面引用 George Fuechsel 的话总结了本文的内容。
“垃圾进来,垃圾出去。” — 乔治·富克塞尔
在本文中,我们将深入研究 NestJS 中的数据验证。我们将探索类验证器和类转换器的一些复杂用例,以确保数据有效且格式正确。在此过程中,我们将讨论最佳实践、一些先进技术和常见陷阱,以将您的技能提升到新的水平。我的动机是让您能够使用 NestJS 构建更具弹性和防错的应用程序。
当我们一起经历这个旅程时,请记住,我们永远不应该信任应用程序外部的用户或客户端提交的任何输入,无论它是否是更大服务(微服务)的一部分。
DTO 是一种我们可以用来封装数据并将其传输到应用程序的不同层的模式。它们对于管理应用程序流入(请求)和流出(响应)的数据非常有用。
正如我们已经确定的,使用 DTO 的主要思想是传输数据,因此数据在创建后不应更改。一般来说,DTO 被设计为不可变的,这意味着一旦创建它们,它们的属性就无法修改。随之而来的一些好处包括但不限于:
JavaScript 没有用于创建不可变类型的内置类型,就像 Java 和 C# 中的 record 类型一样。我们可以通过将字段设置为只读来实现类似的行为。
我们将从一个小型用户管理项目开始,其中包括用于管理用户的基本 CRUD 操作。如果您想探索完整的源代码,可以点击此处访问 GitHub 上的项目。
安装 NestJS CLI
$ npm i -g @nestjs/cli $ nest new user-mgt
安装类验证器和类转换器
npm i --save class-validator class-transformer
生成用户模块
$ nest g resource users ? What transport layer do you use? REST API ? Would you like to generate CRUD entry points? No
创建一个空的 DTO 和实体文件夹。完成所有操作后,您应该拥有这样的结构。
我们首先创建必要的 DTO。本教程将仅关注两个操作:创建和更新用户。在DTO文件夹中创建两个文件
user-create.dto.ts
export class UserCreateDto { public readonly name: string; public readonly email: string; public readonly password: string; public readonly age: number; public readonly dateOfBirth: Date; public readonly photos: string[]; }
user-update.dto.ts
import { PartialType } from '@nestjs/mapped-types'; import { UserCreateDto } from './user-create.dto'; export class UserUpdateDto extends PartialType(UserCreateDto) {}
UserUpdateDto 扩展了 UserCreateDto 以继承所有属性,PartialType 确保所有字段都是可选的,允许部分更新。这节省了我们的时间,因此我们不必重复。
让我们详细介绍一下如何向字段添加验证。类验证器为我们提供了许多已经制作好的验证装饰器,我们可以将这些规则应用到我们的 DTO 中。现在,我们将使用一些来验证 UserCreateDto。单击此处查看完整列表。
import { IsString, IsEmail, IsInt, Min, Max, Length, IsDate, IsArray, ArrayNotEmpty, ValidateNested, IsUrl, } from 'class-validator'; import { Transform, Type } from 'class-transformer'; export class UserCreateDto { @IsString() @Length(2, 30, { message: 'Name must be between 2 and 30 characters' }) @Transform(({ value }) => value.trim()) public readonly name: string; @IsEmail({}, { message: 'Invalid email address' }) public readonly email: string; @IsString() @Length(8, 50, { message: 'Password must be between 8 and 50 characters' }) public readonly password: string; @IsInt() @Min(18, { message: 'Age must be at least 18' }) @Max(100, { message: 'Age must not exceed 100' }) public readonly age: number; @IsDate({ message: 'Invalid date format' }) @Type(() => Date) public readonly dateOfBirth: Date; @IsArray() @ValidateNested() @ArrayNotEmpty({ message: 'Photos array should not be empty' }) @IsString({ each: true, message: 'Each photo URL must be a string' }) @IsUrl({}, { each: true, message: 'Each photo must be a valid URL' }) public readonly photos: string[]; }
我们的简单类的大小已经增大,我们使用 Class-Validator 中的装饰器对字段进行了注释。这些装饰器将验证规则应用于字段。如果您是新手,您可能会对装饰器有疑问。例如,它们是什么意思?让我们分解一下我们使用过的一些基本验证器。
The UserCreateDto fields validator contains additional properties passed into it. These allow you to:
Unlike normal fields validating nested objects requires a bit of extra processing, class-transformer together with class-validator allows you to validate nested objects.
We did a little bit of nested validation in UserCreateDto when we validated the photos field.
@IsArray() @IsUrl({}, { each: true, message: 'Each photo must be a valid URL' }) public readonly photos: string[];
Photos are an array of strings. To validate the nested strings, we added ValidateNested() and { each: true } to ensure that, each link is a valid URL.
Let’s update photos a some-what complex structure. create a new file in DTO folder and name it user-photo.dto.ts
import { IsString, IsInt, Min, Max, IsUrl, Length } from 'class-validator'; export class UserPhotoDto { @IsString() @Length(2, 100, { message: 'Name must be between 2 and 100 characters' }) public readonly name: string; @IsInt() @Min(1, { message: 'Size must be at least 1 byte' }) @Max(5_000_000, { message: 'Size must not exceed 5MB' }) public readonly size: number; @IsUrl( { protocols: ['http', 'https'], require_protocol: true }, { message: 'Invalid URL format' }, ) public readonly url: string; }
Now let’s update the photos section of UserCreateDto
export class UserCreateDto { // Other fields @IsArray() @ArrayNotEmpty({ message: 'Photos array should not be empty' }) @ValidateNested({ each: true }) @Type(() => UserPhotoDto) public readonly photos: UserPhotoDto[]; }
The ValidateNested() decorator ensures that each element in the array is a valid photo object. The most important thing to be aware of when it comes to nested validation is that the nested object must be an instance of a class else ValidateNested() won’t know the target class for validation. This is where class-transformer comes in.
Class-transformer provides us with the @Type() decorator. Since Typescript doesn’t have good reflection capabilities yet, we use @Type(() => UserPhotoDto) to give an instance of the class.
We can also utilize the Type() decorator for basic data transformation in our DTO. The dateOfBirth field in UserCreateDto is transformed into a date object using @Type(() => Date).
For complex DTO fields transformation, the Tranform() decorator handles this perfectly. It allows you to access both the field value and the entire object being validated. Whether you’re converting data types, formatting strings, or applying custom logic, @Transform() gives you the control to return the exact version of the value that your application needs.
@Transform(({ value, obj }) => { // perform additional transformation return value; })
Most often, some fields need to be validated based on some business rules, we can use the ValidateIf() decorator, which allows you to apply validation to a field only if some condition is true. This is very useful if a field depends on other fields like multi-step forms.
Let’s update the UserPhotoDto to include an optional description field, which should only be validated if it is provided. If the description is present, it should be a string with a length between 10 and 200 characters.
export class UserPhotoDto { // Other fields @ValidateIf((o) => o.description !== undefined) @IsString({ message: 'Description must be a string' }) @Length(10, 200, { message: 'Description must be between 10 and 200 characters', }) public readonly description?: string; }
Before we dive into how NestJS handles validation errors, let’s first create simple handlers in the user.controller.ts. We need a basic route to handle user creation.
import { Body, Controller, Post } from '@nestjs/common'; import { UserCreateDto } from './dto/user-create.dto'; @Controller('users') export class UsersController { @Post() createUser(@Body() userCreateDto: UserCreateDto) { // delegating the creation to a service return { message: 'User created successfully!', user: userCreateDto, }; } }
Trying this endpoint on Postman with no payload gives us a successful response.
NestJS has a good integration with class-validator for data validation. Still, why wasn’t our request validated? To tell NestJS that we want to validate UserCreateDto we have to supply a pipe to the Body() decorator.
Pipes are flexible and powerful ways to transform and validate incoming data. Pipes are any class decorated with Injectable() and implement the PipeTransform interface. The usage of pipe we are interested is its ability to check that an incoming request meets a certain criteria or throw errors if otherwise.
The most common way to validate the UserCreateDto is to use the built-in ValidationPipe. This pipe validates rules in your DTO defined with class-validator
Now we pass a validation pipe to the Body() to validate the DTO
import { Body, Controller, Post, ValidationPipe } from '@nestjs/common'; import { UserCreateDto } from './dto/user-create.dto'; @Controller('users') export class UsersController { @Post() createUser(@Body(new ValidationPipe()) userCreateDto: UserCreateDto) { // delegating the creation to services return { message: 'User created successfully!', user: userCreateDto, }; } }
With this small change, we get the errors below if we try to create a user with no payload.
Awesome right :)
To ensure that all requests are validated across the entire application. We have to set up a global validation pipe so that we don’t have to pass validation pipe to every Body() decorator.
Update main.ts
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, }), ); await app.listen(3000); } bootstrap();
The built-in validation pipe uses class-transformer and class-validator, we can pass validations options to be used by these underlying packages. whitelist: true automatically strips any properties that are not defined in the DTO.transform: true automatically transforms the payload into the appropriate types defined in your DTO.
ValidationPipe({ whitelist: true, transform: true, }),
With this, we can remove the pipe we passed to createUser endpoint and it will still be validated. Passing it to parameters helps us fine-tune the validation we need for specific endpoints.
@Post() createUser(@Body() userCreateDto: UserCreateDto) { // ... }
The default validation errors format is not bad, we get to see all the errors for the validations that failed, Some frontend developers will scream at you though for mixing all the errors, I have been there?. Another reason to separate it is when you want to display errors under the fields that failed on the UI.
For nested objects, we also need to retrieve all the errors recursively for a smooth experience. We can achieve this by passing a custom exceptionFactory method to format the errors.
Update main.ts
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { BadRequestException, ValidationError, ValidationPipe, } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, exceptionFactory: (validationErrors: ValidationError[] = []) => { const getPrettyClassValidatorErrors = ( validationErrors: ValidationError[], parentProperty = '', ): Array<{ property: string; errors: string[] }> => { const errors = []; const getValidationErrorsRecursively = ( validationErrors: ValidationError[], parentProperty = '', ) => { for (const error of validationErrors) { const propertyPath = parentProperty ? `${parentProperty}.${error.property}` : error.property; if (error.constraints) { errors.push({ property: propertyPath, errors: Object.values(error.constraints), }); } if (error.children?.length) { getValidationErrorsRecursively(error.children, propertyPath); } } }; getValidationErrorsRecursively(validationErrors, parentProperty); return errors; }; const errors = getPrettyClassValidatorErrors(validationErrors); return new BadRequestException({ message: 'validation error', errors: errors, }); }, }), ); await app.listen(3000); } bootstrap();
This looks way better. Hopefully, you don’t go through what I went through with the front-end developers to get here ?. Let’s go through what is happening.
We passed an anonymous function to exceptionFactory. The functions accept the array of validation errors. Diving into the validationError interface.
export interface ValidationError { target?: Record<string, any>; property: string; value?: any; constraints?: { [type: string]: string; }; children?: ValidationError[]; contexts?: { [type: string]: any; }; }
For example, if we apply IsEmail() on a field and the provided value is not valid. A validation error is created. We also want to know the property where the error occurred. We need to keep in mind that, we can have nested objects for example the photos in UserCreateDto and therefore we can have a parent property let’s say, photos where the error is with the url in the UserPhotoDto.
We first declare an inner function, that takes the errors and sets the parent property to an empty string since it is the root field.
const getValidationErrorsRecursively = ( validationErrors: ValidationError[], parentProperty = '', ) => { };
We then loop through the errors and get the property. For nested objects, I prefer to show the fields as photos.0.url. Where 0 is the index of the invalid photo in the array.
The error messages are stored in the constraints field as it’s in the validationError interface. We retrieve these errors and store them under a specific field.
if (error.constraints) { errors.push({ property: propertyPath, errors: Object.values(error.constraints), }); }
For nested objects, the children property of a validation error contains an array of validationError for the nested objects. We can easily get the errors by recursively calling our function and passing the parent property.
if (error.children?.length) { getValidationErrorsRecursively(error.children, propertyPath); }
While Class-validator provides a comprehensive set of built-in validators, there are times when your requirements exceed the standard validation rules or the standard validation doesn’t fit what you want to do. Custom validators are useful when you need to enforce rules that aren’t covered by the standard validators. Examples:
To create a custom validator, we have to define a new class that implements the ValidatorConstraintInterface from class-validator. This requires us to implement two methods:
Create a new folder in users module named validators. Create two files, is-valid-password.validator.ts and is-username-unique.validator.ts. It should look like this.
A valid password in our use case is very simple. it should contains
Update is-valid-password.validator.ts
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, } from 'class-validator'; @ValidatorConstraint({ name: 'IsStrongPassword', async: false }) export class IsValidPasswordConstraint implements ValidatorConstraintInterface { validate(password: string, args: ValidationArguments) { return ( typeof password === 'string' && password.length > 5 && password.length <= 20 && /[A-Z]/.test(password) && /[a-z]/.test(password) && /[0–9]/.test(password) && /[!@#$%^&*(),.?":{}|<>]/.test(password) ); } defaultMessage(args: ValidationArguments) { return 'Password must be between 6 and 20 characters long and include at least one uppercase letter, one lowercase letter, one number, and one special character'; } }
IsValidPasswordContraint is a custom validator because it is decorated with ValidatorConstraint(), we provide our custom validation rules in the validate method. If the validate function returns false, the error message in the defaultMessage will be returned. Providing these methods implements the ValidatorContraintInterface. To use isValidPasswordContraint, update the password field in UserCreateDto. For ValidatorConstraint({ name: ‘IsStrongPassword’, async: false }), we provided the constraint name that will be used to retrieve the error and also, since all actions in the validate are synchronous, we set async to false.
import { Validate } from 'class-validator'; export class UserCreateDto { // other fields @Validate(IsValidPasswordConstraint) public readonly password: string; }
Now, if we try again with an invalid password, we get this result indicating our custom validator is working.
We can go further and create a decorator for the validator so that we can decorate the password field without using the Validate.
Update is-valid-password.validator.ts
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidatorOptions, } from 'class-validator'; @ValidatorConstraint({ name: 'IsStrongPassword', async: false }) class IsValidPasswordConstraint implements ValidatorConstraintInterface { // removing the implementation so that we focus on IsPasswordValid function } export function IsValidPassword(validationOptions?: ValidatorOptions) { return function (object: NonNullable<unknown>, propertyName: string) { registerDecorator({ target: object.constructor, propertyName: propertyName, options: validationOptions, constraints: [], validator: IsValidPasswordConstraint, }); }; }
Creating custom decorators makes working with validators a breeze, NestJs gives us registerDecorator to create our own. we provide it with the validator which is the IsValidPasswordContraint we created. We can use it like this
export class UserCreateDto { // other fields @IsValidPassword() public readonly password: string; }
It is common to encounter scenarios where you need to validate against external systems. Let’s assume that the username in UserCreateDto is unique across the various servers.
Update is-unique-username.validator.ts
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidationOptions, } from 'class-validator'; interface IsUsernameUniqueOptions { server: string; message?: string; } @ValidatorConstraint({ name: 'IsUsernameUnique', async: true }) export class IsUsernameUniqueConstraint implements ValidatorConstraintInterface { async validate(username: string, args: ValidationArguments) { const options = args.constraints[0] as IsUsernameUniqueOptions; const server = options.server; // server check, let assume username exist return !(await this.checkUsernameOnServer(username, server)); } defaultMessage(args: ValidationArguments) { const options = args?.constraints[0] as IsUsernameUniqueOptions; return options?.message || 'Username is already taken'; } async checkUsernameOnServer(username: string, server: string) { return true; } } export function IsUsernameUnique( options: IsUsernameUniqueOptions, validationOptions?: ValidationOptions,) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName: propertyName, options: validationOptions, constraints: [options], validator: IsUsernameUniqueConstraint, }); }; }
Usage
export class UserCreateDto { @IsString() @Length(2, 30, { message: 'Name must be between 2 and 30 characters' }) @Transform(({ value }) => value.trim()) @IsUsernameUnique({ server: 'east-1', message: 'Name already exists' }) public readonly name: string; // other fields }
We created a simple interface to show the possible options we can pass to the decorator. These options are constraints that will be used by IsUsernameUniqueConstraint, we can get them through the validation arguments . const options = args.constraints[0] as IsUsernameUniqueOptions;
Logging options give us { server: ‘east-1’, message: ‘Name already exists’ }, We then called the required service and passed the server name and username to validate the uniqueness of the name.
Also, async is set to true to allow asynchronous operations inside the validate function; ValidatorConstraint({ name: ‘IsUsernameUnique’, async: true }).
It is necessary to be aware of common pitfalls to ensure robust and maintainable code.
There is so much to add like validation groups, using service containers, etc, but this article is getting way longer than I anticipated ?. As you continue developing with NestJS, I encourage you to explore more complex use cases and scenarios and share your experiences to keep the learning journey going.
Data validation is crucial in ensuring data integrity within any application and the principles covered here will serve as a strong foundation for further growth and mastery in building secure and efficient applications.
This is my very first article, and I’m eager to hear your thoughts! ? Please feel free to leave any feedback in the comments.
If you’d like to connect and stay updated on future content, you can find me on LinkedIn
Happy Coding !!!
以上是掌握 NestJS 中的数据验证:类验证器和类转换器的完整指南的详细内容。更多信息请关注PHP中文网其他相关文章!