nestjs-swagger、参数校验、请求响应配置

2,434 阅读11分钟

前言

这章主要讲解 swagger 的配置,输入输出结构配置等,也顺道会加入一些返回的请求格式的案例,方便参考

swagger配置

swagger配置一些会跟其前面介绍的一样,只是更加详细一些

安装swagger

先使用 npm、yarn 导入 swagge相关

yarn add @nestjs/swagger swagger-ui-express

配置main函数

然后再 main函数开启文档,当我们项目运行的时候,文档就能看到了

const options = new DocumentBuilder()
    .setTitle('nest demo api')
    .setDescription('This is nest demo api')
    .setVersion('1.0')
    .build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('api-docs', app, document);

//为了避免正式服务器暴漏文档的情况,可以设置正式服务器 swaggerUiEnabled 设置false,则就不显示
//或者部署到正式服务器是,直接删除文档即可,直接写到环境变量动态控制也挺好
//SwaggerModule.setup('api-docs', app, document, {
//    swaggerUiEnabled: envConfig.SWAGGER_UI_ENABLE ? true : false,
//});

//这个地址本地看得话就是 http://localhost:3000/api-docs了

main 函数配置的就是,swagger顶部那些,如下所示,下面的也会介绍

image.png

配置路由文档

配置模块名称 @ApiTags,也就是给一个模块添加一个大标题索引,方便快速区分api功能的

@ApiTags('user')
@Controller('user')
export class UserController {
    ......
}

配置接口路由备注 @ApiOperation,可以设置单个接口备注

配置 get 请求时, Query 类型,在 swagger 中也会默认为必填,我们也可以通过

// 使用query类型
// .../api?id=value&...
@ApiOperation({
    summary: '获取用户信息'
})
@Get('getUserInfo') //命名追加到url路径上
getUserInfo(
    @Query('id')
    // @Query('id', new ParseIntPipe()) //也可以通过 pipe 校验类型,如果不是int类型会报错
    id: number //声明一个 query 类型 id,类型为number
) {
    ...
}

post接口需要用到 dto,配置一下 dto校验 和 文档

@ApiOperation({
    summary: '修改用户信息2',
})
@Post('updateUserInfo')
updateUserInfo(//可以获取headers中的内容,例如版本号平台
    @Body() userInfo: UserDto
) {
   ......
}

配置body的dto文档

前面给 @Body 后续前面设置了 dtodto也可以配置上传参数的标签,以便于用户设置,再加上以前的参数验证pipe,如下所示

下面 description参数描述, example 为参数案例,@ApiProperty表示必填,@ApiPropertyOptional表示可选属性

export class UserDto {
    //api属性备注,必填
    @ApiProperty({description: '名字', example: '迪丽热巴'})
    //设置了 IsNotEmpty 就是必填属性了,文档也会根据该验证来显示是否必填
    @IsNotEmpty({ message: 'name不能为空' })//可以返回指定message,返回为数组
    // @IsNotEmpty()//返回默认message,默认为为原字段的英文提示
    readonly name: string

    //可选参数
    @ApiPropertyOptional({description: '年龄', example: 20})
    readonly age: number

    @ApiPropertyOptional({description: '手机号', example: '133****3333'})
    readonly mobile: string

    @ApiPropertyOptional({description: '性别 1男 2女 0未知', example: 1})
    @Max(2)
    @Min(0)
    readonly sex: number

    @ApiPropertyOptional({description: '是否已婚', example: false})
    @IsNotEmpty()
    marry: number
}

body 传参如下所示,可以点击 schema 查看必填项

image.png

image.png

其他校验和自定义校验

校验用的是这个 class-validator 仓库 , 有疑问的可以看看

我们常见的装饰器 @IsNotEmpty、@IsNumber、@MaxLength、@Max、@IsString 等等如果缺少的话,可以进入文档,或者随便一个装饰器点进去,看顶部有哪些可以用就可以了

ps@IsOptional可选类型,平时用的不多,默认可选,当存在多个校验时,如果可选则需要额外标记,不然不传也会报错误

//设置不为空
@IsNotEmpty()
account: string

//设置年龄范围
@Max(35)
@Min(20)
work_age: number

找装饰器的时候,如果忘了一些,懒得看文档,直接随便点一个校验装饰及,然后目录找,这样也能最快找到我们用的

image.png

自定义装饰器

上面那些装饰器,虽然很实用,但一些特殊的,我们仍然无法实现效果,例如:我要设置一个金额校验,最多保留小数两位小数,下面直接实现以下

//模仿一个,需要实现 ValidatorConstraintInterface 接口,类名我们需要主动使用时当做类型传递
@ValidatorConstraint()
export class IsMoney implements ValidatorConstraintInterface {
    //第一个参数是接收到的我们的参数,第二个是validate的三个参数中第二个传递的(为数组类型)
    validate(text: string, validationArguments: ValidationArguments) {
        return typeof text === 'string' && 
            /(^\d{1}$|^[1-9]\d+$)|(^[1-9]\d*|^0)\.\d{1,2}$/.test(text)
    }

    defaultMessage(args: ValidationArguments) {
        //也可以使用带属性命的来提示默认效果
        return `${args.property}不是金额类型字符串`;
    }
}
//设置价格类型,如果还需要参数,则可以在第二个参数中传递数组,上面的ValidationArguments可以访问到
//如果想设置新的提示,可以第二个或者第三个参数传递对象 {message: "新的提示"}
@Validate(IsMoney)
//@Validate(IsMoney, { message: '新的提示'})
price: string

配置返回格式swagger

正常我们不会用系统的状态码显示,而是配置成下面的样式,data 字段我们则是灵活的

//对象类型data
{
  "code": 200,
  "msg": "ok",
  "data": {
    "name": "西瓜",
    "age": 20,
    "mobile": "133****3333",
    "sex": 1,
    "marry": false
  }
}

//数组类型data
{
  "code": 200,
  "msg": "ok",
  "data": [
    {
      "name": "甜瓜",
      "age": 20,
      "mobile": "133****3333",
      "sex": 1,
      "marry": false
    }
  ]
}

//page页的长列表
{
  "code": 200,
  "msg": "ok",
  "data": {
    "items": [
      {
        "name": "哈密瓜",
        "age": 20,
        "mobile": "133****3333",
        "sex": 1,
        "marry": false
      }
    ],
    "itemCount": 0,
    "totalItems": 0,
    "totalPages": 0,
    "currentPage": 0,
    "itemsPerPage": 0
  }
}

先编写一个新的装饰器,系统有一个 ApiResponse,不好用,我们使用自己创建的,取名 APIResponse,如下所示

import { Type } from '@nestjs/common'
import { ApiResponse, getSchemaPath } from '@nestjs/swagger'

const baseTypeNames = ['String', 'Number', 'Boolean']

/**
 * @description: 生成返回结果装饰器
 */
export const APIResponse = <TModel extends Type<any>>(
    type?: TModel | TModel[],
    isPage?: boolean
) => {
    let prop = null
    if (Array.isArray(type)) {
        if (isPage) {
            prop = {
                type: 'object',
                properties: {
                    items: {
                        type: 'array',
                        items: { $ref: getSchemaPath(type[0]) },
                    },
                    itemCount: { type: 'number', default: 0 },
                    totalItems: { type: 'number', default: 0 },
                    totalPages: { type: 'number', default: 0 },
                    currentPage: { type: 'number', default: 0 },
                    itemsPerPage: { type: 'number', default: 0 },
                },
            }
        } else {
            prop = {
                type: 'array',
                items: { $ref: getSchemaPath(type[0]) },
            }
        }
    } else if (type) {
        if (type && baseTypeNames.includes(type.name)) {
            prop = { type: type.name.toLocaleLowerCase() }
        } else {
            prop = { $ref: getSchemaPath(type) }
        }
    } else {
        prop = { type: 'null', default: null }
    }

    let resProps = {
        type: 'object',
        properties: {
            code: { type: 'number', default: 200 },
            msg: { type: 'string', default: 'ok' },
            data: prop
        },
    }

    return applyDecorators(
        ApiExtraModels(type ? (Array.isArray(type) ? type[0] : type) : String),
        ApiResponse({
            schema: {
                allOf: [
                    resProps
                ],
            },
        }),
    )
}

装饰器使用如下所示

//单个类
...
@APIResponse(UserDto)
updateUserInfo(
    ...
) {
    ...
    //使用findOne获取一个user
    return ResponseData.ok(user);
}

//数组类型,一个普通的元组即可
...
@APIResponse([UserDto])
updateUserInfo(
    ...
) {
    ...
    //使用find获取多个users
    return ResponseData.ok(users);
}

//长列表,带page页的
...
@APIResponse([UserDto], true)
updateUserInfo(
    ...
) {
    ...
    //使用findAndCount 可以获取数据和总数量, userPage
    return ResponseData.pageOk(userPage, pageDto); //用后面的类即可
}

配置响应类

上面的只是配置了一个 swagger,实际使用如果不按照固定格式返回也是不友好的

同时上一篇文章也提到了,如果用全局拦截,除了状态码用的不太舒服,还有就是感觉逻辑不是很清晰,一股大杂烩的感觉,下面配置一下相对比较好用的格式

import { PageDto } from "./page.dto"

export interface PageItem {
    totalPages?: number
    itemCount?: number
    currentPage?: number
    itemsPerPage?: number
    totalItems?: number
}

export interface ReponsePage<T> extends PageItem {
    items: T[]
}

export class ResponseData<T> {
    code: number; //状态码
    msg: string; //消息
    data?: T; //数据内容

    constructor(code = 200, msg: string, data: T = null) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    static ok<T>(data: T = null, message = 'ok'): ResponseData<T> {
        return new ResponseData(200, message, data);
    }

    static fail(message = 'fail', code = -1): ResponseData<null> {
        return new ResponseData(code, message);
    }

    //page直接使用 findAndCount + PageDto,直接解决
    static pageOk<T>(data: [T[], number] = [[], 0], page: PageDto, message = 'ok'): ResponseData<ReponsePage<T>> {
        let items = data[0]
        let totolCount = data[1]
        return new ResponseData(200, message, {
            items: items, //数据
            totalItems: totolCount,
            currentPage: page.page_num,
            itemsPerPage: page.page_size,
            itemCount: items.length,
            totalPages: Math.ceil(totolCount / page.page_size),
        });
    }
}

编写一个 PageDto 方便分页使用,会方便很多,后续查询会看到用着很舒服,具备分页特性的一般继承 PageDto 即可

import { ApiPropertyOptional } from '@nestjs/swagger';

export const defaultPageNum = 1; //默认第一页码
export const defaultPageSize = 10; //每页默认数量

export class PageDto {
    @ApiPropertyOptional({ description: '页码 1 开始', example: 1 })
    page_num: number

    @ApiPropertyOptional({ description: '每页数量', example: 10 })
    page_size: number

    get skip() {
        return (this.page_num - 1) * this.page_size;
    }

    get take() {
        return this.page_size;
    }

    constructor(page: PageDto) {
        //已经声明数字为何还要转化,因为前端传递过来的基本都是字符串,虽然实际查询等写入操作不影响,但实际操作和返回内容格式化时依照实际类型会出现异常(ts只是开发阶段规范,实际运行还是js),因此需要转化成数字,能避免一些问题
        this.page_num =
            (page?.page_num && parseInt(page.page_num + '')) || defaultPageNum;
        this.page_size =
            (page?.page_size && parseInt(page.page_size + '')) ||
            defaultPageSize;
    }
}

这样就完成了,设置到controller中吧

@Post('updateUserInfo')
@APIResponse(UserDto) //返回空
@APIResponse(UserDto) //返回一个基础类
@APIResponse([UserDto])//返回一个数组
@APIResponse([UserDto], true)//返回一个page类型的数组
updateUserInfo(
    @Body() userInfo: UserDto,
) {
    //实际逻辑应该写到 service 中
    return ResponseData.ok(userInfo)
}

配置嵌套类

如下所示配置一个可以嵌套的,其中包含对象和数字的,如下所示

//配置一个专栏,包含嵌套类,如下所示
export class FeatureDto {
    @ApiProperty({ description: 'id', example: 1 })
    id: number

    @ApiProperty({ description: '名称', example: '1231' })
    name: string

    //专栏状态,默认创建即送审,等待、审核中、成功、失败
    //平时可以数字或者单个字符,以提升实际效率和空间,文档注释最重要,这里纯粹为了看着清晰
    @ApiProperty({ description: '状态', example: 1 })
    status: FeatureStatus

    @ApiProperty({ description: '用户id', example: 1 })
    userId: number

    //专栏拥有者
    @ApiProperty({ description: '用户信息', type: () => UserDto,  example: UserDto })
    //@ApiProperty({ description: '用户信息', type: () => UserDto,  required: true, })
    user: UserDto

    //数组的类型要设置成数组,案例设置成对象即可显示一个对象
    @ApiProperty({ description: '文章列表', type: () => [ArticleDto],  example: ArticleDto })
    articles: ArticleDto[]

    @ApiProperty({ description: '订阅列表', type: () => [UserDto],  example: UserDto })
    subscribes: UserDto[]
}

查看返回结果

生成一个数据看看吧,挺成功的

{
  "code": 200,
  "msg": "ok",
  "data": {
    "id": 1,
    "name": "1231",
    "status": 1,
    "userId": 1,
    "user": {
      "nickname": "小鬼快跑",
      "age": 20,
      "mobile": "133****3333",
      "sex": 1
    },
    "articles": [
      {
        "id": 1,
        "title": "标题",
        "desc": "描述",
        "content": "内容",
        "status": 0,
        "createTime": "2022",
        "updateTime": "2022",
        "user": {
          "nickname": "小鬼快跑",
          "age": 20,
          "mobile": "133****3333",
          "sex": 1
        },
        "userId": 1,
        "collectCount": 10,
        "featureId": 10
      }
    ],
    "subscribes": [
      {
        "nickname": "小鬼快跑",
        "age": 20,
        "mobile": "133****3333",
        "sex": 1
      }
    ]
  }

分页的数据是这样的

{
  "code": 200,
  "msg": "ok",
  "data": {
    "items": [
      {
        "id": 1,
        "title": "标题",
        "desc": "描述",
        "content": "内容",
        "status": 0,
        "createTime": "2022",
        "updateTime": "2022",
        "user": {
          "nickname": "小鬼快跑",
          "age": 20,
          "mobile": "133****3333",
          "sex": 1
        },
        "userId": 1,
        "collectCount": 10,
        "featureId": 10
      }
    ],
    "itemCount": 0,
    "totalItems": 0,
    "totalPages": 0,
    "currentPage": 0,
    "itemsPerPage": 0
  }
}

配置自定义返回类(针对数组文档不正常)

配置上面类的时候非常简单,然后有时候我们也会遇到数组,遇到略微自定义的复杂返回结果,此时如果使用原来的可能会遇到问题,主要是针对于数组类型,需要将 example 改成 examples,也可以直接 required: true

## 错误示范

//一个自定义类型的返回结果,包含数组
export class ArticleCollectsAndOthersDto {
    @ApiProperty({
        description: '收藏文章信息',
        type: () => [ArticleDto],
        example: [ArticleDto],
    })
    collects: ArticleDto[]

    @ApiProperty({
        description: '我自己文章信息',
        type: () => [ArticleDto],
        example: [ArticleDto],
    })
    others: ArticleDto[]
}

如果此种情况按照上面写,会发现文档里面会显示一个数组,里面都是 null,即格式为 [null],原因是参数使用错误,下面我纠正一下,只需要将 example 改成 examples 即可,这样就是我们想要的文档了

注意:除了这种会出现数组,我们多对多或者其他类型也会出现数组,文档格式稍微动一下就行了,没必要为了一个文档强行单独转化成 json 字符串返回

## 正确示范

//一个自定义类型的返回结果,包含数组
export class ArticleCollectsAndOthersDto {
    @ApiProperty({
        description: '收藏文章信息',
        type: () => [ArticleDto],
        examples: [ArticleDto],
    })
    collects: ArticleDto[]

    @ApiProperty({
        description: '我自己文章信息',
        type: () => [ArticleDto],
        examples: [ArticleDto],
    })
    others: ArticleDto[]
}

也可以直接统一required: true,我看好像都没问题

export class ArticleCollectsAndOthersDto {
    @ApiProperty({
        description: '收藏文章信息',
        type: () => [ArticleDto],
        required: true,
    })
    collects: ArticleDto[]

    @ApiProperty({
        description: '我自己文章信息',
        type: () => [ArticleDto],
        required: true,
    })
    others: ArticleDto[]
}

使用 OmitType、PickType 去除、取出参数生成新的dto

在创建 dto 的时候,很多时候我们都是以详情为基准创建,但是列表页有时候会因为性能把不必要的关联参数去掉,因此会需要丢失一些参数,但是文档使用详情的 dto 会多余,重新配置文档还要复制粘贴会多很多不必要的代码,因此 ts 对应的高级语法,在 nest-swagger 中也有体现,例如: OmitType 则可以去掉多余字段,跟 Omit 类似,PickType可以去除一些字段,和 Pick类似 都可以被继承

//例如:我们省略掉 FeatureDto 中的 articles、subscribes,作为List表示
export class FeatureListDto extends OmitType(FeatureDto, ['articles', 'subscribes']) {}

//我们取出里面的几个字段作为我们的新的dto
export class TemplateDto extends PickType(TemplateBaseDto, ['id', 'name', 'status']) {}

这样就好了,还有其他的例如: PartialType 为什么不用呢,因为一些额外的修改会去掉文档注释,因此不推荐(不需要文档那无所谓了)

设置api路由前缀

有时为了使接口api更加清晰化,或预留位置等情况,我们开发时,会给我们的项目添加一个全局路由

js
app.setGlobalPrefix('api');

这样接口的基础地址就会变成 http://localhost:3000/api
文档还是原来那个不受影响 /api-docs

设置参数映射swagger

正常开发时,如果比较忙,我们可能并不是所有的参数都需要写文档,有些参数根据名字就立马能知道啥意思,因此只需要把关键点给注释上即可,我们可以通过 设置 nest-cli.json 文件,添加插件即可实现参数映射到swagger文档,这样即使没有写 ApiProperty 文档也会出现参数了,写了会出现我们的注释

//默认的是这样子的
"compilerOptions": {
    "deleteOutDir": true,
}

//我们添加一个 @nestjs/swagger 映射
"compilerOptions": {
    "deleteOutDir": true,
    "plugins": [{
      "name": "@nestjs/swagger",
      "options": {
        "classValidatorShim": false,
        "introspectComments": true
      }
    }]
}

ps:这个映射会将我们controller的对象参数映射出来,其他文章使用headers装饰器的参数也会全部应用上(这样就会多出来很多无用的参数校验文档),如果用了为了方便可以放到request里面,自定义一个子类接受即可