feat:自定义全局拦截器、过滤器、验证管道

This commit is contained in:
3R-喜东东 2023-12-08 13:31:53 +08:00
parent 781962dce0
commit f4b38483d6
15 changed files with 204 additions and 128 deletions

3
.gitignore vendored
View File

@ -32,4 +32,5 @@ lerna-debug.log*
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
.vercel

View File

@ -3,12 +3,33 @@ import { Module } from '@nestjs/common';
import { database } from '@/config'; import { database } from '@/config';
import { ContentModule } from '@/modules/content/content.module'; import { ContentModule } from '@/modules/content/content.module';
import { CoreModule } from '@/modules/core/core.module'; import { CoreModule } from '@/modules/core/core.module';
import { AppFilter, AppIntercepter, AppPipe } from '@/modules/core/providers';
import { DatabaseModule } from '@/modules/database/database.module'; import { DatabaseModule } from '@/modules/database/database.module';
import { WelcomeModule } from '@/modules/welcome/welcome.module'; import { WelcomeModule } from '@/modules/welcome/welcome.module';
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
@Module({ @Module({
imports: [DatabaseModule.forRoot(database), ContentModule, WelcomeModule, CoreModule.forRoot()], imports: [DatabaseModule.forRoot(database), ContentModule, WelcomeModule, CoreModule.forRoot()],
controllers: [], controllers: [],
providers: [], providers: [
{
provide: APP_PIPE,
useValue: new AppPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
},
{
provide: APP_INTERCEPTOR,
useClass: AppIntercepter,
},
{
provide: APP_FILTER,
useClass: AppFilter,
},
],
}) })
export class AppModule {} export class AppModule {}

View File

@ -9,15 +9,11 @@ import {
Post, Post,
Query, Query,
SerializeOptions, SerializeOptions,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos'; import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos';
import { CategoryService } from '@/modules/content/services'; import { CategoryService } from '@/modules/content/services';
import { AppIntercepter } from '@/modules/core/providers';
@UseInterceptors(AppIntercepter)
@Controller('categories') @Controller('categories')
export class CategoryController { export class CategoryController {
constructor(protected service: CategoryService) {} constructor(protected service: CategoryService) {}
@ -31,15 +27,7 @@ export class CategoryController {
@Get() @Get()
@SerializeOptions({ groups: ['category-list'] }) @SerializeOptions({ groups: ['category-list'] })
async list( async list(
@Query( @Query()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
)
options: QueryCategoryDto, options: QueryCategoryDto,
) { ) {
return this.service.paginate(options); return this.service.paginate(options);
@ -54,16 +42,7 @@ export class CategoryController {
@Post() @Post()
@SerializeOptions({ groups: ['category-detail'] }) @SerializeOptions({ groups: ['category-detail'] })
async create( async create(
@Body( @Body()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
groups: ['create'],
}),
)
data: CreateCategoryDto, data: CreateCategoryDto,
) { ) {
return this.service.create(data); return this.service.create(data);
@ -72,16 +51,7 @@ export class CategoryController {
@Patch() @Patch()
@SerializeOptions({ groups: ['category-detail'] }) @SerializeOptions({ groups: ['category-detail'] })
async update( async update(
@Body( @Body()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
groups: ['update'],
}),
)
data: UpdateCategoryDto, data: UpdateCategoryDto,
) { ) {
return this.service.update(data); return this.service.update(data);

View File

@ -8,15 +8,11 @@ import {
Post, Post,
Query, Query,
SerializeOptions, SerializeOptions,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos'; import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos';
import { CommentService } from '@/modules/content/services'; import { CommentService } from '@/modules/content/services';
import { AppIntercepter } from '@/modules/core/providers';
@UseInterceptors(AppIntercepter)
@Controller('comments') @Controller('comments')
export class CommentController { export class CommentController {
constructor(protected service: CommentService) {} constructor(protected service: CommentService) {}
@ -24,15 +20,7 @@ export class CommentController {
@Get('tree') @Get('tree')
@SerializeOptions({ groups: ['comment-tree'] }) @SerializeOptions({ groups: ['comment-tree'] })
async tree( async tree(
@Query( @Query()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
)
query: QueryCommentTreeDto, query: QueryCommentTreeDto,
) { ) {
return this.service.findTrees(query); return this.service.findTrees(query);
@ -41,13 +29,7 @@ export class CommentController {
@Get() @Get()
@SerializeOptions({ groups: ['comment-list'] }) @SerializeOptions({ groups: ['comment-list'] })
async list( async list(
@Query( @Query()
new ValidationPipe({
transform: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
)
query: QueryCommentDto, query: QueryCommentDto,
) { ) {
return this.service.paginate(query); return this.service.paginate(query);
@ -56,15 +38,7 @@ export class CommentController {
@Post() @Post()
@SerializeOptions({ groups: ['comment-detail'] }) @SerializeOptions({ groups: ['comment-detail'] })
async store( async store(
@Body( @Body()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
)
data: CreateCommentDto, data: CreateCommentDto,
) { ) {
return this.service.create(data); return this.service.create(data);

View File

@ -9,19 +9,16 @@ import {
Post, Post,
Query, Query,
SerializeOptions, SerializeOptions,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos'; import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos';
import { PostService } from '@/modules/content/services'; import { PostService } from '@/modules/content/services';
import { AppIntercepter } from '@/modules/core/providers';
/** /**
* *
* *
*/ */
@UseInterceptors(AppIntercepter)
@Controller('posts') @Controller('posts')
export class PostController { export class PostController {
constructor(private postService: PostService) {} constructor(private postService: PostService) {}
@ -29,15 +26,7 @@ export class PostController {
@Get() @Get()
@SerializeOptions({ groups: ['post-list'] }) @SerializeOptions({ groups: ['post-list'] })
async list( async list(
@Query( @Query()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
)
options: QueryPostDto, options: QueryPostDto,
) { ) {
return this.postService.paginate(options); return this.postService.paginate(options);
@ -52,15 +41,7 @@ export class PostController {
@Post() @Post()
@SerializeOptions({ groups: ['post-detail'] }) @SerializeOptions({ groups: ['post-detail'] })
async store( async store(
@Body( @Body()
new ValidationPipe({
transform: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
groups: ['create'],
}),
)
data: CreatePostDto, data: CreatePostDto,
) { ) {
return this.postService.create(data); return this.postService.create(data);
@ -69,15 +50,7 @@ export class PostController {
@Patch() @Patch()
@SerializeOptions({ groups: ['post-detail'] }) @SerializeOptions({ groups: ['post-detail'] })
async update( async update(
@Body( @Body()
new ValidationPipe({
transform: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
groups: ['update'],
}),
)
data: UpdatePostDto, data: UpdatePostDto,
) { ) {
return this.postService.update(data); return this.postService.update(data);

View File

@ -9,15 +9,11 @@ import {
Post, Post,
Query, Query,
SerializeOptions, SerializeOptions,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateTagDto, QueryCategoryDto, UpdateTagDto } from '@/modules/content/dtos'; import { CreateTagDto, QueryCategoryDto, UpdateTagDto } from '@/modules/content/dtos';
import { TagService } from '@/modules/content/services'; import { TagService } from '@/modules/content/services';
import { AppIntercepter } from '@/modules/core/providers';
@UseInterceptors(AppIntercepter)
@Controller('tags') @Controller('tags')
export class TagController { export class TagController {
constructor(protected service: TagService) {} constructor(protected service: TagService) {}
@ -25,15 +21,7 @@ export class TagController {
@Get() @Get()
@SerializeOptions({}) @SerializeOptions({})
async list( async list(
@Query( @Query()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
)
options: QueryCategoryDto, options: QueryCategoryDto,
) { ) {
return this.service.paginate(options); return this.service.paginate(options);
@ -51,16 +39,7 @@ export class TagController {
@Post() @Post()
@SerializeOptions({}) @SerializeOptions({})
async store( async store(
@Body( @Body()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
groups: ['create'],
}),
)
data: CreateTagDto, data: CreateTagDto,
) { ) {
return this.service.create(data); return this.service.create(data);
@ -69,16 +48,7 @@ export class TagController {
@Patch() @Patch()
@SerializeOptions({}) @SerializeOptions({})
async update( async update(
@Body( @Body()
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
groups: ['update'],
}),
)
data: UpdateTagDto, data: UpdateTagDto,
) { ) {
return this.service.update(data); return this.service.update(data);

View File

@ -12,8 +12,10 @@ import {
} from 'class-validator'; } from 'class-validator';
import { toNumber } from 'lodash'; import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorators';
import { PaginateOptions } from '@/modules/database/types'; import { PaginateOptions } from '@/modules/database/types';
@DtoValidation({ type: 'query' })
export class QueryCategoryDto implements PaginateOptions { export class QueryCategoryDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value)) @Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页数必须大于1' }) @Min(1, { message: '当前页数必须大于1' })
@ -31,6 +33,7 @@ export class QueryCategoryDto implements PaginateOptions {
/** /**
* *
*/ */
@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto { export class CreateCategoryDto {
@MaxLength(25, { @MaxLength(25, {
always: true, always: true,
@ -56,6 +59,7 @@ export class CreateCategoryDto {
/** /**
* *
*/ */
@DtoValidation({ groups: ['update'] })
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) { export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {
@IsUUID(undefined, { groups: ['update'], message: '分类ID格式不正确' }) @IsUUID(undefined, { groups: ['update'], message: '分类ID格式不正确' })
@IsDefined({ groups: ['update'], message: '分类ID必须指定' }) @IsDefined({ groups: ['update'], message: '分类ID必须指定' })

View File

@ -18,12 +18,14 @@ import {
import { isNil, toNumber } from 'lodash'; import { isNil, toNumber } from 'lodash';
import { PostOrderType } from '@/modules/content/constants'; import { PostOrderType } from '@/modules/content/constants';
import { DtoValidation } from '@/modules/core/decorators';
import { toBoolean } from '@/modules/core/helpers'; import { toBoolean } from '@/modules/core/helpers';
import { PaginateOptions } from '@/modules/database/types'; import { PaginateOptions } from '@/modules/database/types';
/** /**
* *
*/ */
@DtoValidation({ type: 'query' })
export class QueryPostDto implements PaginateOptions { export class QueryPostDto implements PaginateOptions {
@Transform(({ value }) => toBoolean(value)) @Transform(({ value }) => toBoolean(value))
@IsBoolean() @IsBoolean()
@ -59,6 +61,7 @@ export class QueryPostDto implements PaginateOptions {
/** /**
* *
*/ */
@DtoValidation({ groups: ['create'] })
export class CreatePostDto { export class CreatePostDto {
@MaxLength(255, { @MaxLength(255, {
always: true, always: true,
@ -119,6 +122,7 @@ export class CreatePostDto {
/** /**
* *
*/ */
@DtoValidation({ groups: ['update'] })
export class UpdatePostDto extends PartialType(CreatePostDto) { export class UpdatePostDto extends PartialType(CreatePostDto) {
@IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' }) @IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' })
@IsDefined({ groups: ['update'], message: '文章ID必须指定' }) @IsDefined({ groups: ['update'], message: '文章ID必须指定' })

View File

@ -11,11 +11,13 @@ import {
} from 'class-validator'; } from 'class-validator';
import { toNumber } from 'lodash'; import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorators';
import { PaginateOptions } from '@/modules/database/types'; import { PaginateOptions } from '@/modules/database/types';
/** /**
* *
*/ */
@DtoValidation({ type: 'query' })
export class QueryTagsDto implements PaginateOptions { export class QueryTagsDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value)) @Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页数必须大于1' }) @Min(1, { message: '当前页数必须大于1' })
@ -33,6 +35,7 @@ export class QueryTagsDto implements PaginateOptions {
/** /**
* *
*/ */
@DtoValidation({ groups: ['create'] })
export class CreateTagDto { export class CreateTagDto {
@MaxLength(25, { @MaxLength(25, {
always: true, always: true,
@ -53,6 +56,7 @@ export class CreateTagDto {
/** /**
* *
*/ */
@DtoValidation({ groups: ['update'] })
export class UpdateTagDto extends PartialType(CreateTagDto) { export class UpdateTagDto extends PartialType(CreateTagDto) {
@IsUUID(undefined, { groups: ['update'], message: '标签ID格式不正确' }) @IsUUID(undefined, { groups: ['update'], message: '标签ID格式不正确' })
@IsDefined({ groups: ['update'], message: '标签ID必须指定' }) @IsDefined({ groups: ['update'], message: '标签ID必须指定' })

View File

@ -0,0 +1,4 @@
/**
* DTOValidation
*/
export const DTO_VALIDATION_OPTIONS = 'dto_validation_options';

View File

@ -0,0 +1,14 @@
import { DTO_VALIDATION_OPTIONS } from '@/modules/core/constants';
import { Paramtype, SetMetadata } from '@nestjs/common';
import { ClassTransformOptions } from 'class-transformer';
import { ValidatorOptions } from 'class-validator';
/**
* DTO类装饰器
* @params options
*/
export const DtoValidation = (
options?: ValidatorOptions & { transformOptions?: ClassTransformOptions } & {
type?: Paramtype;
},
) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});

View File

@ -0,0 +1 @@
export * from './dto-validation.decorator';

View File

@ -0,0 +1,52 @@
import { ArgumentsHost, Catch, HttpException, HttpStatus, Type } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { isObject } from 'lodash';
import { EntityNotFoundError, EntityPropertyNotFoundError, QueryFailedError } from 'typeorm';
/**
*
*/
@Catch()
export class AppFilter<T = Error> extends BaseExceptionFilter<T> {
protected resExceptions: Array<{ class: Type<Error>; status?: number } | Type<Error>> = [
{ class: EntityNotFoundError, status: HttpStatus.NOT_FOUND },
{ class: QueryFailedError, status: HttpStatus.BAD_REQUEST },
{ class: EntityPropertyNotFoundError, status: HttpStatus.BAD_REQUEST },
];
// eslint-disable-next-line consistent-return
catch(exception: T, host: ArgumentsHost) {
const applicationRef =
this.applicationRef || (this.httpAdapterHost && this.httpAdapterHost.httpAdapter)!;
// 是否在自定义的异常处理类列表中
const resException = this.resExceptions.find((item) =>
'class' in item ? exception instanceof item.class : exception instanceof item,
);
// 如果不在自定义异常处理类列表也没有继承自HttpException
if (!resException && !(exception instanceof HttpException)) {
return this.handleUnknownError(exception, host, applicationRef);
}
let res: string | object = '';
let status = HttpStatus.INTERNAL_SERVER_ERROR;
if (exception instanceof HttpException) {
res = exception.getResponse();
status = exception.getStatus();
} else if (resException) {
// 如果在自定义异常处理类列表中
const e = exception as unknown as Error;
res = e.message;
if ('class' in resException && resException.status) {
status = resException.status;
}
}
const message = isObject(res)
? res
: {
statusCode: status,
message: res,
};
applicationRef!.reply(host.getArgByIndex(1), message, status);
}
}

View File

@ -0,0 +1,82 @@
import { DTO_VALIDATION_OPTIONS } from '@/modules/core/constants';
import { deepMerge } from '@/modules/core/helpers';
import {
ArgumentMetadata,
BadRequestException,
Injectable,
Paramtype,
ValidationPipe,
} from '@nestjs/common';
import { isObject, omit } from 'lodash';
/**
* DTO验证
*/
@Injectable()
export class AppPipe extends ValidationPipe {
async transform(value: any, metadata: ArgumentMetadata) {
const { metatype, type } = metadata;
// 获取要验证的dto类
const dto = metatype as any;
// 获取dto类的装饰器元数据中的自定义验证选项
const options = Reflect.getMetadata(DTO_VALIDATION_OPTIONS, dto) || {};
// 把当前已设置的选项解构到备份对象
const originOptions = { ...this.validatorOptions };
// 把当前已设置的class-transform选项解构到备份对象
const originTransform = { ...this.transformOptions };
// 把自定义的class-transform和type选项解构
const { transformOptions, type: optionsType, ...customOptions } = options;
// 根据DTO类上设置的type来设置当前的DTO请求类型,默认为'body'
const requestType: Paramtype = optionsType ?? 'body';
// 如果被验证的DTO设置的请求类型与被验证的数据的请求类型不是同一种类型 跳过此管道
if (requestType !== type) return value;
// 合并当前transform选项和自定义选项
if (transformOptions) {
this.transformOptions = deepMerge(
this.transformOptions,
transformOptions ?? {},
'replace',
);
}
// 合并当前验证选项和自定义选项
this.validatorOptions = deepMerge(this.validatorOptions, customOptions ?? {}, 'replace');
const toValidate = isObject(value)
? Object.fromEntries(
Object.entries(value as RecordAny).map(([key, v]) => {
if (!isObject(v) || !('mimetype' in v)) return [key, v];
return [key, omit(v, ['fields'])];
}),
)
: value;
try {
// 序列化并验证dto对象
let result = await super.transform(toValidate, metadata);
// 如果dto类中存在transform静态方法,则返回调用进一步transform之后的结果
if (typeof result.transform === 'function') {
result === (await result.transform(result));
const { transform, ...data } = result;
result = data;
}
// 重置验证选项
this.validatorOptions = originOptions;
// 重置transform选项
this.transformOptions = originTransform;
return result;
} catch (error: any) {
// 重置验证选项
this.validatorOptions = originOptions;
// 重置transform选项
this.transformOptions = originTransform;
if ('response' in error) throw new BadRequestException(error.response);
throw new BadRequestException(error);
}
}
}

View File

@ -1 +1,3 @@
export * from './app.filter';
export * from './app.interceptor'; export * from './app.interceptor';
export * from './app.pipe';