Nest 使用笔记
第十四章:Swagger——从代码自动生成可交互 API 文档
打开 learnhub 的 main.ts 和真实 controller/DTO,讲透 Swagger 的挂载、@Api*/@ApiProperty 装饰器、Bearer 鉴权联调、多版本分组、multipart 上传文档,以及 OpenAPI JSON 为什么不只是「一个文档页」。每段代码都能在 learnhub 里指到对应文件。
- Nest
- Swagger
前面十一十二章做完了登录和权限,第八章做完了帖子 CRUD,第六章做完了文件上传——接口已经一大堆,但前端每次对接都要问「这个接口路径是什么、要传什么、返回什么结构」。手写一份 Markdown 当然可以,问题是它和代码没绑死:今天改了 DTO、明天忘了更新文档,前端就踩坑。Swagger 解决这件事——扫描代码里的装饰器,自动生成一份能在浏览器里点开就调的接口文档。这一章打开 learnhub 的 main.ts、post.controller.ts 和几个真实 DTO,把文档化的全套装饰器和它在生产里真正能省的事讲透。
Swagger 的整条流水线是从装饰器到文档页的一条单向链路:
先搞懂:Swagger / OpenAPI 是什么 / 为什么需要 / 企业级怎么用
Swagger / OpenAPI 是什么:OpenAPI 是一套描述 RESTful 接口的规范——一份 JSON(或 YAML),把每个接口的路径、方法、参数、请求体、响应体写成机器可读的结构。Swagger UI 是它的可视化壳,把这份 JSON 渲染成网页,能看、能点、能发请求。Nest 的 @nestjs/swagger 包做的是反过来:你不手写 OpenAPI JSON,而是在 controller 和 DTO 上加 @ApiTags / @ApiOperation / @ApiProperty 这些装饰器,启动时 Nest 扫描这些装饰器 + 路由元数据,自动拼出 OpenAPI JSON,喂给 Swagger UI。
为什么需要它:前后端协作最痛的事就是文档和代码脱节——接口签名一改,Word/Markdown 文档没跟上,前端对接时才发现。装饰器生成的文档和实现同源:DTO 上的字段类型改了,文档里的 schema 同步变;新增一个路由,文档里立刻出现。再附赠三件事:能在页面上直接发请求验证(不用再开 Postman)、能拿这份 JSON 自动生成前端 SDK 和 TS 类型、能生成 mock server 给前端先联调。判断要不要上:只要接口是给别人(前端 / 第三方 / 测试 / 将来的自己)用的,就上;纯内部一次性脚本可以不管。
企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):
- 挂载路径避开全局前缀,文档页和业务接口不撞车——第一步
main.ts落地; - controller 用
@ApiTags按模块分组、每个路由用@ApiOperation写摘要——第二步post.controller.ts落地; - DTO 字段用
@ApiProperty写示例和约束(example/minimum/maximum/type/default),请求体 schema 自动出——第三步create-post.dto.ts/page.dto.ts落地; addBearerAuth()配合第十一章的 JWT,在文档页里直接 Authorize 填 token 调鉴权接口——第四步落地;- 多版本接口(v1 TypeORM / v2 Prisma)用不同
@ApiTags在一份文档里分组——第五步post-v2.controller.ts落地; - 文件上传这种 multipart 接口,用
@ApiBody描述 binary 字段——第六步upload.controller.ts落地; - 拿
/api/doc-json这份 OpenAPI JSON 做代码生成、Postman 导入、mock server——第七步落地。
这一章你会做出什么
- 打开 learnhub 的
main.ts,看DocumentBuilder+SwaggerModule.setup怎么把文档挂到/api/doc。 - 在真实
post.controller.ts/create-post.dto.ts/page.dto.ts/page-result.dto.ts里吃透@ApiTags/@ApiOperation/@ApiBearerAuth/@ApiProperty一整套装饰器。 - 在文档页用 Authorize 填第十一章的 JWT,直接调带权限的接口,不切 curl/Postman。
- 看懂 v1 和 v2 接口怎么在一份文档里分组、multipart 上传怎么描述、OpenAPI JSON 除了渲染页面还能干什么。
前置:第十一章做完了 JWT 登录(能拿到 accessToken),第八章做完了帖子接口(有一组真实路由可标注)。
第一步:挂载——DocumentBuilder + SwaggerModule.setup
打开 learnhub 的启动文件,Swagger 的挂载就夹在 useGlobalPipes 和 app.listen 之间:
// learnhub/src/main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // 第一章讲过:所有路由加 /api 前缀
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); // /api/v1/xxx
// ... ValidationPipe ...
// Swagger 接口文档:访问 http://localhost:3000/api/doc
const config = new DocumentBuilder()
.setTitle('learnhub API')
.setDescription('learnhub 综合实战项目 · 接口文档')
.setVersion('0.1.0')
.addBearerAuth() // Swagger 顶部 Authorize 输入 Bearer token
.build();
const document = SwaggerModule.createDocument(app, config);
// 注意:挂在 /api/doc 而非默认 /api(避开前缀冲突)
SwaggerModule.setup('api/doc', app, document);
await app.listen(process.env.PORT || 3000);
}
三个东西要分清:
DocumentBuilder配置文档的元信息(标题、描述、版本)和全局能力(鉴权方式)。setTitle是页面上最大那行字、setDescription是副标题、setVersion是文档本身的版本号(和接口版本 v1/v2 没关系,是这份文档的版本)。它产出的是一个配置对象,本身还没生成文档。SwaggerModule.createDocument(app, config)这一步才真正扫描整个应用的 controller、DTO、路由元数据,结合上面的 config,产出一份 OpenAPI JSON 对象。这份 JSON 才是文档的本体,Swagger UI 只是它的渲染器。SwaggerModule.setup('api/doc', app, document)把这份 JSON 挂到一个 URL,同时给它配一个 Swagger UI 页面。访问http://localhost:3000/api/doc就能看到;JSON 本体在/api/doc-json(第七步讲它有什么用)。
几个真实项目才会注意的点:
addBearerAuth()干的事是在文档里声明「这套接口支持 HTTP Bearer 认证」,于是页面右上角会出现一个 Authorize 按钮。没有这一行,下面即使每个接口都标了@ApiBearerAuth(),Authorize 按钮也不出现,没法在文档里填 token 调鉴权接口。它配合的是第十一章的 JWT——登录拿到accessToken,在 Authorize 里填进去,之后所有标了@ApiBearerAuth()的接口会自动带上Authorization: Bearer xxx请求头。第四步细讲。- 挂在
/api/doc而不是/api-doc或/doc是有意的。learnhub 设了全局前缀/api,所有业务路由都带/api前缀(/api/v1/posts);如果文档也挂/api,会和前缀撞车。setup的第一个参数不走 globalPrefix,是字面路径,所以写'api/doc'它就是/api/doc,不重复、不冲突。第一章main.ts那五件事里提过「全局前缀要在 Swagger 挂载之前设好」,就是为了文档里显示的路径能正确带上/api/v1前缀。
注意:SwaggerModule.setup 默认把页面挂在第一个参数这个完整路径上,它不读 globalPrefix。这是新手最容易踩的——以为写 'doc' 就会自动变 /api/doc,结果实际是 /doc。learnhub 直接写字面路径 'api/doc',省心。
第二步:给 Controller 加标签和说明——@ApiTags / @ApiOperation / @ApiBearerAuth
光挂载一份空文档没意义,得在 controller 上标装饰器才有内容。learnhub 的帖子控制器:
// learnhub/src/modules/post/post.controller.ts
@ApiTags('帖子') // 侧边栏按「帖子」分组
@ApiBearerAuth() // 这个 controller 默认需要 Bearer token
@UseInterceptors(ClassSerializerInterceptor)
@Controller({ path: 'posts', version: '1' }) // → /api/v1/posts
export class PostController {
constructor(private readonly postService: PostService) {}
@Post()
@RequirePermission('post:create') // 第十二章功能级权限
@ApiOperation({ summary: '发帖(需 post:create)' })
create(@Body() dto: CreatePostDto, @CurrentUser() user: RequestUser): Promise<PostEntity> {
return this.postService.create(dto, user.userId);
}
@Public() // 绕过全局登录守卫
@Get()
@ApiOperation({ summary: '帖子列表(分页,公开)' })
list(@Query() q: QueryPostDto): Promise<PageResult<PostEntity>> {
return this.postService.findMany(q);
}
@Public()
@Get('search')
@ApiOperation({ summary: '全文检索帖子(ES,公开)' })
search(@Query('q') q: string, @Query('page') page = 1, @Query('size') size = 10) { /* ... */ }
@Public()
@Get(':id')
@ApiOperation({ summary: '帖子详情(公开,浏览量 +1)' })
detail(@Param('id') id: number): Promise<PostEntity> { /* ... */ }
@Patch(':id')
@ApiOperation({ summary: '修改帖子(本人或 admin)' })
update(@Param('id') id: number, @Body() dto: UpdatePostDto, @CurrentUser() user: RequestUser) { /* ... */ }
@Delete(':id')
@ApiOperation({ summary: '删除帖子(本人或 admin)' })
async remove(@Param('id') id: number, @CurrentUser() user: RequestUser): Promise<void> { /* ... */ }
@Patch(':id/pin')
@RequirePermission('post:pin')
@ApiOperation({ summary: '置顶/取消置顶(仅 admin)' })
async pin(@Param('id') id: number, @Body() body: { pinned: boolean }): Promise<void> { /* ... */ }
}
每个装饰器的作用:
@ApiTags('帖子')写在类上,是分组。文档侧边栏按 tag 把接口归类——「帖子」「上传」「用户」各一组,不然几十个接口平铺没法看。一个 controller 也能挂多个 tag(@ApiTags('帖子', '管理')),接口会同时出现在多个分组里。@ApiBearerAuth()写在类上,声明这个 controller 下的接口默认需要 Bearer token——文档里每个接口右边会出现一个小锁图标。它只影响文档展示,真正的鉴权是全局守卫(第十一章 JwtAuthGuard)干的,文档只是「告诉你这接口要带 token」。这个声明要生效,前提是main.ts里调过addBearerAuth()——否则 Swagger 不知道「Bearer」是什么安全方案,锁图标也不出现。更通用的同类装饰器是@ApiSecurity('name'),用来声明任意命名的安全方案(比如自定义的X-API-Key请求头),bearer 是它最常用的特例。@ApiOperation({ summary })写在方法上,是单个接口的一句话摘要——文档里每条接口展开前看到的那行字。description可以再附一段详细说明(接口行为、注意事项)。把 summary 写清楚(带上权限要求、是否公开),前端扫一眼列表就知道每个接口干啥、要不要登录。@Public()是 learnhub 自己的装饰器(第十一章做登录守卫时写的),标记接口绕过全局 JwtAuthGuard——这是运行时的鉴权行为。Swagger 本身不认识它,但因为 controller 上有@ApiBearerAuth(),所以list/detail/search这几个公开接口在文档里也带锁,有点误导。learnhub 靠 summary 里写「公开」补足,能接受;更严谨的做法是把@ApiBearerAuth()放到方法级——只在真正要登录的方法上标,公开方法不标,锁图标就只出现在该出现的地方。
思考:你大概注意到了——summary 里写的「本人或 admin」「仅 admin」「公开」这些信息,和第十二章 RBAC 的 @RequirePermission、第十一章的 @Public() 是重复的。这就是「文档和代码同源」最大的红利,也是负担:装饰器信息天然来自代码,所以一个 DTO 改了,校验(@IsXxx)、路由(@Get/@Post)、文档(@ApiProperty/@ApiOperation)三处同步变,不存在「文档忘了更新」这回事——这是这套装饰器真正值钱的地方。但前提是你真的把权限要求写进了 summary,或用方法级 @ApiBearerAuth() 精确声明。否则文档还是会骗人——接口实际要登录但文档没说,前端照样踩坑。装饰器只能生成「签名」层面的信息(参数、类型、是否标了 Bearer),业务语义(这接口到底谁能调)还是得人写。
第三步:给 DTO 加字段说明——@ApiProperty / @ApiPropertyOptional
接口的请求体长什么样、字段有什么约束,全部来自 DTO。DTO 上加了 @ApiProperty,文档里就会自动生成 schema + 示例。看 learnhub 的发帖 DTO:
// learnhub/src/modules/post/dto/create-post.dto.ts
export class CreatePostDto {
@ApiProperty({ example: 'TypeORM 三种关系怎么记' })
@IsString()
@Length(1, 100)
title: string;
@ApiProperty({ example: '一对多看 owning side...' })
@IsString()
@IsNotEmpty()
content: string;
@ApiPropertyOptional({ type: [Number], description: '标签 ID 列表(可选)' })
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@Type(() => Number)
tagIds?: number[];
}
@ApiProperty 和 @ApiPropertyOptional 的区别就一个:前者标必填字段、后者标可选字段(schema 里 required 数组的差别)。两个装饰器接的参数高度重合,常用的有:
example——文档里「Example」区显示的值,最有用的一个。前端复制粘贴就能改改发请求,比抽象的 schema 描述有用十倍。description——字段说明,写业务含义(「标签 ID 列表」「页码,从 1 开始」)。minimum/maximum——数值字段的上下界,文档里会显示,也影响 schema 校验。enum——枚举字段,文档里列成下拉。type/type: [Number]——显式指定类型,数组必须这么写(type: [Number]表示number[]),否则 Swagger 推断不出。default——默认值,配合可选字段写清楚「不传时默认 1」。
分页 DTO 是这套装饰器用得最全的地方,看 learnhub 的公共 PageDto:
// learnhub/src/common/dto/page.dto.ts
export class PageDto {
@ApiPropertyOptional({ default: 1, minimum: 1, description: '页码,从 1 开始' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100, description: '每页条数' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize: number = 10;
@ApiPropertyOptional({ description: '关键词(模糊搜索)' })
@IsOptional()
@IsString()
keyword?: string;
get skip(): number { return (this.page - 1) * this.pageSize; }
get take(): number { return this.pageSize; }
}
QueryPostDto extends PageDto(第二章讲过 DTO 继承),所以帖子的列表接口文档里自动带 page / pageSize / keyword / tagId / authorId 一整套 query 参数,每个都带默认值和约束——前端不用问「pageSize 能传多少」,文档里「minimum 1, maximum 100」写得清清楚楚。
返回结构也要标,看 PageResult:
// learnhub/src/common/dto/page-result.dto.ts
export class PageResult<T> {
@ApiProperty({ type: Array, description: '当前页数据' })
list: T[];
@ApiProperty({ example: 100, description: '总条数' })
total: number;
@ApiProperty({ example: 10, description: '每页条数' })
pageSize: number;
@ApiProperty({ example: 1, description: '当前页码' })
page: number;
static of<T>(list: T[], total: number, page: number, pageSize: number): PageResult<T> { /* ... */ }
}
这套字段(list / total / page / pageSize)是所有列表接口的统一返回形状,前端写一个通用的分页解析就够。example 在这里特别重要——它就是文档里看到的「响应示例」,前端对照它写类型定义和 mock。
注意(容易漂移):@ApiProperty 的约束和 class-validator 的 @IsXxx 校验是两套独立的装饰器,写得不对齐就会出问题。常见漂移:DTO 上加了 @Min(1) 但 @ApiProperty 没写 minimum: 1,文档显示「可以传 0」、实际接口报 400;或者反过来,文档写了 maximum: 100、校验没加 @Max(100),前端照文档传 100、后端不拦但下游炸。规则:校验装饰器和文档装饰器成对加,加 @Min(1) 就顺手加 minimum: 1。类型推断偶尔也会出错——tagIds?: number[] 这种数组,必须显式 type: [Number],否则 Swagger 当成单个 number 而不是数组。
注意(继承):UpdatePostDto extends PartialType(CreatePostDto)(第二章 mapped-types)会把 CreatePostDto 上的 @ApiProperty 一起继承过来,并自动转成 @ApiPropertyOptional——所以改 PATCH 接口的文档不用重标,改 CreatePostDto 就够。这是「单点修改」的最大红利,前提是字段确实复用。
第四步:在线调试——Authorize + Try it out
Swagger UI 不只是「看」,最大的价值是直接发请求。先拿第十一章的登录接口换一个 accessToken(learnhub 种子数据里有 admin / admin123456):
# 真实接口:learnhub 的登录路由
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}
拿到 token 后回到文档页:
- 浏览器打开
http://localhost:3000/api/doc。 - 右上角点 Authorize 按钮,弹框里填上面那个 accessToken——只填 token 本身,不要带
Bearer前缀,Swagger 会自动加。点 Save。 - 展开一个带锁的接口(比如
POST /api/v1/posts发帖),点右上角 Try it out。 - 请求体改成
CreatePostDto的 example 值(第三步那个example已经预填进来了),点 Execute。 - 下方直接看到
curl命令、响应状态码、响应体、响应头——和真正用 curl 调一模一样,但不用切终端。
标了 @Public() 的接口(GET /api/v1/posts 列表、GET /api/v1/posts/:id 详情)不 Authorize 也能调;带锁的(发帖、改、删、置顶)必须先 Authorize 才能调通,否则拿到 401。
这一步是 Swagger 真正打败 Postman 的地方:Postman 要手动建集合、手动填 URL、手动维护环境变量,文档和请求是两套东西;Swagger 文档页里点一下就调,文档即请求定义。团队协作里前端不用配 Postman 集合,打开 /api/doc 直接调。
注意:生产环境的 /api/doc 要不要暴露是个安全问题。文档把所有接口的结构明明白白告诉任何能访问的人,等于主动扩大攻击面。learnhub 这种内部项目可以留着;面向公网的项目要么用环境变量控制只在非生产 setup(if (process.env.NODE_ENV !== 'production') SwaggerModule.setup(...)),要么在 Nginx 层给 /api/doc 加 IP 白名单或基本认证(第十六章 Nginx 会讲)。
第五步:多版本接口在文档里分组——v1 TypeORM / v2 Prisma
learnhub 同时跑两套帖子接口:v1 用 TypeORM(第八章),v2 用 Prisma(第十章)。两者路径分别是 /api/v1/posts 和 /api/v2/posts,文档里怎么区分?答案是用不同的 @ApiTags:
// learnhub/src/modules/prisma/post-v2.controller.ts
@ApiTags('帖子 v2(Prisma)') // 注意 tag 带 v2 标识
@ApiBearerAuth()
@Controller({ path: 'posts', version: '2' }) // → /api/v2/posts
export class PostV2Controller {
constructor(private readonly postService: PostV2Service) {}
@Post()
@RequirePermission('post:create')
@ApiOperation({ summary: '发帖(v2 Prisma,需 post:create)' })
create(@Body() dto: CreatePostDto, @CurrentUser() user: RequestUser) { /* ... */ }
@Public()
@Get()
@ApiOperation({ summary: '帖子列表(v2 Prisma,分页)' })
list(@Query() q: QueryPostDto): Promise<PageResult<any>> { /* ... */ }
@Public()
@Get(':id')
@ApiOperation({ summary: '帖子详情(v2 Prisma)' })
detail(@Param('id') id: number) { /* ... */ }
}
对照 v1 的 @ApiTags('帖子'),v2 是 @ApiTags('帖子 v2(Prisma)')——侧边栏里就是两个独立的分组:「帖子」下面是 /api/v1/posts/*,「帖子 v2(Prisma)」下面是 /api/v2/posts/*。每个接口的 summary 里也带上 (v2 Prisma) 标识,展开看也清楚。
注意 v1 和 v2 共用同一份 DTO(CreatePostDto、QueryPostDto)——DTO 的 schema 在文档里只生成一次,两套接口都引用它,不会重复。这也是「单点修改」的红利:改 CreatePostDto,v1/v2 两套发帖接口的文档同时更新。
Nest 还有更精细的多版本文档做法:给每个版本单独生成一份 Document,挂在不同的 URL(/api/doc/v1、/api/doc/v2)。做法是 SwaggerModule.createDocument 时传第三个参数 options.include,过滤只包含某个版本的 controller。learnhub 没这么做(接口量不大,一份文档分两个 tag 够用),但接口多到几十上百、版本开始分叉时,分文档比一个长页面好读。
第六步:文件上传怎么描述——multipart / @ApiBody
文件上传接口的请求体不是 JSON,是 multipart/form-data,Swagger 默认拿不到 schema,要手动用 @ApiBody 描述。learnhub 的头像上传是这样:
// learnhub/src/modules/upload/upload.controller.ts
@ApiTags('上传')
@ApiBearerAuth()
@Controller({ path: 'upload', version: '1' })
export class UploadController {
constructor(
private readonly minio: MinioService,
private readonly userService: UserService,
) {}
@Post('avatar')
@ApiOperation({ summary: '上传头像(multipart,服务端代传)' })
@ApiBody({
schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } },
})
@UseInterceptors(
FileInterceptor('file', { /* multer 配置:限 2MB、只能图片 */ }),
)
async uploadAvatar(
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: RequestUser,
): Promise<{ url: string }> { /* ... */ }
}
关键是 @ApiBody 的 schema——它直接是一段 OpenAPI schema 对象,描述「请求体是个 object,里面有一个 file 字段,类型是 string、格式是 binary(二进制文件)」。有了这段,Swagger UI 的 Try it out 会显示一个文件选择按钮,前端能在文档页里直接选文件上传测试。
FileInterceptor('file', ...) 是真正接收文件的拦截器(第六章细讲),它和 @ApiBody 描述的字段名(file)必须一致——文档说字段叫 file,拦截器也认 file,前端才知道表单字段名填什么。
注意:learnhub 这里没有写 @ApiConsumes('multipart/form-data')——这是更严谨的做法。@ApiConsumes('multipart/form-data') 显式告诉 Swagger 这个接口只接受 multipart,文档页会正确显示 Content-Type: multipart/form-data,某些代码生成器(如 openapi-generator)也靠它来决定生成 multipart 客户端代码。learnhub 单写 @ApiBody 也能跑(Swagger 会从 schema 推断),但养成习惯:multipart 接口 @ApiConsumes + @ApiBody 一起写,最不容易出歧义。
第七步:OpenAPI JSON 不只是文档页——codegen / Postman / mock
回到第一步提过的:SwaggerModule.setup('api/doc', ...) 把 Swagger UI 挂在 /api/doc,同一份 OpenAPI JSON 挂在 /api/doc-json(setup 路径加 -json)。这串 JSON 是文档的本体,能拿去做三件生产级的事:
# 真实接口:拉 learnhub 的 OpenAPI JSON
curl http://localhost:3000/api/doc-json > learnhub-openapi.json
- 生成前端 SDK / TS 类型:把
/api/doc-json的输出喂给openapi-typescript(生成 TS 类型)或openapi-generator-cli(生成完整 SDK),前端拿到的就是一套和后端 DTO 同源的types.ts和能直接调的api.ts。后端改了 DTO,前端重新生成一次,类型对不齐立刻编译报错——比手写类型、手维护靠谱得多。 - 导入 Postman / Apifox:Postman 支持
Import → Link,把http://localhost:3000/api/doc-json贴进去,一键导入所有接口、参数、示例,不用手动建集合。 - 生成 mock server:
@stoplight/prism-cli这类工具吃 OpenAPI JSON,起一个本地 mock server,按example字段返回假数据。前端不用等后端接口做完,对着 mock 先联调;后端改字段,重新生成 JSON,mock 自动跟着变。
所以 Swagger 不是「装个好看的文档页」这么简单——它是一份机器可读的接口契约,能驱动前后端协作的整条工具链。前提是装饰器标得足够细:example 写全、description 写清、@ApiProperty 和校验对齐。装饰器写得糙,生成的 JSON 就糙,下游工具用起来全是坑。
装饰器速查
| 装饰器 | 用在哪 | 作用 |
|---|---|---|
@ApiTags('分组名') | controller | 接口分组(侧边栏分类) |
@ApiBearerAuth() | controller/handler | 标记需要 Bearer token(出现锁图标) |
@ApiSecurity('name') | controller/handler | 标记自定义安全方案(如 X-API-Key) |
@ApiOperation({ summary }) | handler | 单个接口的摘要 + 描述 |
@ApiQuery / @ApiParam | handler | 描述 query / path 参数(DTO 没覆盖时) |
@ApiBody({ schema / type }) | handler | 描述请求体(multipart 必用) |
@ApiConsumes('multipart/form-data') | handler | 声明请求体格式 |
@ApiResponse({ status, type }) | handler | 描述响应(默认 200 自动推断) |
@ApiProperty({ example }) | DTO 字段 | 描述字段 + 示例 + 约束 |
@ApiPropertyOptional | DTO 字段 | 可选字段(同上,但非必填) |
@ApiHideProperty() | Entity/DTO 字段 | 隐藏字段(如 password、token) |
@ApiExcludeEndpoint() | handler | 单个接口不出现在文档 |
@ApiExcludeController() | controller | 整个 controller 不出现在文档 |
注意:Swagger 默认把所有 controller 都展示——内部管理接口、调试接口、未完成的功能都会暴露。对于不想给文档读者的接口,单个方法加 @ApiExcludeEndpoint(),整个 controller 加 @ApiExcludeController(),标记弃用用 @ApiOperation({ deprecated: true })。生产前过一遍文档列表,确认没有不该露的东西。
这一章的成果
- 在
main.ts用DocumentBuilder+SwaggerModule.createDocument+SwaggerModule.setup把文档挂到/api/doc,搞清了三者的分工,以及挂在/api/doc避开 globalPrefix 撞车的原因。 - 在真实
post.controller.ts吃透@ApiTags(分组)、@ApiOperation(摘要)、@ApiBearerAuth(标记鉴权)、@ApiSecurity(自定义安全方案),以及@Public()路由在文档里的表现。 - 在
create-post.dto.ts/page.dto.ts/page-result.dto.ts用@ApiProperty/@ApiPropertyOptional写 example / minimum / maximum / type / default,搞清它和@IsXxx校验装饰器要成对加的红线。 - 在文档页用 Authorize 填第十一章的 JWT,直接调带权限的接口,不切 Postman。
- 用
@ApiTags给 v1/v2 接口分组、用@ApiBody描述 multipart 上传,并搞清/api/doc-json这份 OpenAPI JSON 能驱动 codegen、Postman 导入、mock server。 - 思考过「文档和代码同源」是这套装饰器最大的价值——一个 DTO 改了,校验、路由、文档三处同步变,没有「文档忘了更新」这回事。
常见问题
- Authorize 按钮不出现:
main.ts里没调addBearerAuth()。它是「声明 Bearer 这个安全方案」,没声明就没按钮。 - 某个接口没出现在文档里:确认 controller 在某个 module 的
controllers里,且该模块在AppModule.imports。Swagger 只扫描已加载模块的路由。 - 请求体 schema 是空的:
@Body()参数要带 DTO 类型(@Body() dto: CreatePostDto),DTO 字段要标@ApiProperty。光写@Body() body不标类型,Swagger 推断不出结构。 - 文档显示的字段约束和实际校验不一致:
@ApiProperty和@IsXxx漂移了。校验装饰器和文档装饰器成对加。 - 数组字段在文档里显示成单个值:
@ApiProperty({ type: [Number] })这种数组写法必须显式type: [Number],否则推断成单个number。 PartialType继承来的字段文档没显示:@nestjs/mapped-types的PartialType/OmitType会自动转@ApiPropertyOptional,确保基类字段标了@ApiProperty,子类就不用重标。- 生产要不要暴露
/api/doc:内部项目可以留;公网项目用环境变量控制(if (process.env.NODE_ENV !== 'production') setup(...)),或在 Nginx 层加白名单(第十六章)。 - 想拿 OpenAPI JSON:访问
/api/doc-json(setup 路径加-json)。
下一章讲 Docker 部署——把 Nest 应用打包成镜像,连同 MySQL、Redis 一起用 Compose 编排,在任何装了 Docker 的机器上一键跑起来。Swagger 文档会跟着镜像一起走,不用担心环境差异。