cover

使用 Nest-Swagger 生成 OAS 3.0 接口文档

本文介绍了如何使用 Nest Swagger 插件生成 Open API Specification,以及给予 OAS 3.0 生成 Markdown 格式的接口文档的方法。

2023-12-20

在 Nest 项目中初始化 Swagger

新建一个 Nest 项目:

npm install --global @nestjs/cli
nest new use-swagger
# 完成项目初始化

添加 nest-swagger 依赖:

yarn add @nestjs/swagger

更新 main.ts 文件,添加 swagger 配置:

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';

const env = process.env.NODE_ENV | "development";
const port = process.env.PORT | 3000;

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  /* ... 其他代码 ... */
  if (env == "development) {
    const config = new DocumentBuilder()
      .setTitle('Cats example')
      .setDescription('The cats API description')
      .setVersion('1.0')
      .addTag('cats')
      .build();
    const document = SwaggerModule.createDocument(app, config);
    SwaggerModule.setup('/api/__doc', app, document);
  }
  /* ... 其他代码 ... */
  await app.listen(port);
}

bootstrap();

调整后使用 yarn start 启动 Nest 服务,在浏览器中:

常用装饰器

首先创建一个 src/example.dto.ts 文件并添加一下内容:

class SomeParams {
  @ApiProperty({ 
    name: 'id', 
    description: 'id of Something', 
    example: "1" 
  })
  id: string;
}

class SomeQuery {
  @ApiProperty({ 
    name: 'page', 
    description: 'page of list', 
    example: 1 
  })
  page: number;

  @ApiPropertyOptional({
    name: 'page_size',
    description: 'page size of list',
    default: 10,
    example: 10,
  })
  page_size?: number;
}

class SomethingDto {
  @ApiProperty({ 
    name: 'id', 
    description: 'id of Something', 
    example: 1 
  })
  id: string;

  @ApiProperty({ 
    name: 'name', 
    description: 'name of Something', 
    example: 'abc' 
  })
  name: string;
}

在该文件中定义了接口的参数。

新建 src/example.controller.ts,并添加以下内容:

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Put,
  Query,
} from '@nestjs/common';
import {
  ApiBearerAuth,
  ApiCreatedResponse,
  ApiExcludeEndpoint,
  ApiNotFoundResponse,
  ApiOkResponse,
  ApiOperation,
  ApiProperty,
  ApiPropertyOptional,
  ApiTags,
} from '@nestjs/swagger';

@ApiTags('Example APIs')
@Controller('/api')
export class ExampleController {
  @ApiExcludeEndpoint()
  @Get('/something/hidden')
  getSomethingHidden() {
    return { status: 1 };
  }

  @ApiNotFoundResponse({ description: 'Something Not Found' })
  @ApiOkResponse({ description: 'Return Something' })
  @ApiOperation({
    summary: 'Get Something',
    description: 'call this API to get Something',
  })
  @Get('/something/:id')
  getHello(@Param() params: SomeParams, @Query() query: SomeQuery) {
    // 也可以通过 @ApiParam 和 @ApiQuery 进行定义,
    // 但是如果有多个 Param 或者 Query 参数很多的话还是使用 class 定义更简洁
    return { status: 1, data: { params, query } };
  }

  @ApiCreatedResponse({ description: 'Something Created' })
  @ApiBearerAuth()
  @ApiOperation({
    summary: 'Create Something',
    description: 'call this API to create Something',
  })
  @Post('/something')
  createSomething(@Body() dto: SomethingDto) {
    // 也可以通过 @ApiBody 进行定义,
    // 但是对于结构复杂的 Body 还是使用 class 定义更简洁
    return { status: 1, data: dto };
  }

  @ApiOkResponse({ description: 'Return Something' })
  @ApiBearerAuth()
  @ApiOperation({
    summary: 'Update Something',
    description: 'call this API to update Something',
  })
  @Put('/something')
  updateSomething(@Body() dto: SomethingDto) {
    return { status: 1, data: dto };
  }

  @ApiOkResponse({ description: 'Return Something' })
  @ApiBearerAuth()
  @ApiOperation({
    summary: 'Delete Something',
    description: 'call this API to delete Something',
  })
  @Delete('/something')
  deleteSomething() {
    return { status: 1 };
  }
}

在该文件中定义了五个用于展示装饰器的接口。

ExampleController 添加到 AppModule.controllers 中,并在 main.ts 中的 DocumentBuilder 部分添加 addBearerAuth 以添加 Bearer Token 的输入组件。

async function bootstrap() {
  /* 其他代码 */
    const config = new DocumentBuilder()
      .setTitle('Cats example')
      .addBearerAuth()
      .setDescription('The cats API description')
      .setVersion('1.0')
      .addTag('cats')
      .build();
  /* 其他代码 */
}

重新启动服务,然后访问文档链接,可以看到如下内容:

ui-1

查看特定路由的详情,可以看到以下内容:

ui-2

常用的 Swagger 装饰器简介如下:

装饰器作用域作用
@ApiTagsController定义接口组名称
@ApiOperationMethod定义特定接口信息,包含摘要 summary 和描述 description
@ApiOkResponseMethod定义接口响应状态说明(Ok/NotFound/BadRequest 等)
@ApiBearerAuthController/Method为接口组/接口启用 BearerAuth,使用前需要调用 DocumentBuilder.addBearerAuth,这样会在 swagger ui 中展示输入 Token 的组件
@ApiExcludedEndpointMethod忽略该接口,不会再 Swagger 文档中显示
@ApiParamMethod定义接口的路径参数对象,也可以通过 class 并对类中的字段使用 @ApiProperty 进行定义
@ApiQueryMethod定义接口的请求参数对象,也可以通过 class 并对类中的字段使用 @ApiProperty 进行定义
@ApiBodyMethod定义接口的 Body 对象,也可以通过 class 并对类中的字段使用 @ApiProperty 进行定义
@ApiPropertyParam/Query/Body定义必填参数信息
@ApiPropertyOptionalParam/Query/Body定义选填参数信息

使用 cli plugins 自动生成 schema

在 Nest.js@10 中支持使用 cli-plugin 简化 @ApiProperty 的使用。

配置 nest-swaggercli-plugin 配置

更新项目的 nest-cli.json 文件,添加 @nestjs/swagger 的插件配置:

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "plugins": [
      {
        "name": "@nestjs/swagger",
        "options": {
          "classValidatorShim": true,
          "introspectComments": true,
          "controllerFileNameSuffix": ".controller.ts",
          "dtoFileNameSuffix": ".dto.ts"
        }
      }
    ]
  }
}

其中:

  • introspectComments 指定是否从注释中提取
  • dotFileNameSuffix 指定 dto 类文件名的后缀
  • controllerFileNameSuffix 指定 controller 类文件名的后缀

启用 swc 并自动生成 metadata

对于非 monorepo 的 Nest.js 应用,可以通过使用 swc 编译器并开启 --type-check 选项开启自动生成 metadata。首先安装 swc 编译器:

yarn add --save-dev @swc/cli @swc/core

然后在 nest-cli.json 中添加 builder 配置:

{
  "compilerOptions": {
    /* 其他内容 */
    "builder": "swc",
    "typeCheck": true
  }
}

删除 dist 文件夹并重新启动服务:

rm -rf dist
yarn start

执行完成后会自动生成 src/metadata.ts 文件,该文件中会包含自动生成的 Swagger Schema 定义。

针对 monorepo 的配置

如果项目使用了 monorepo(lerna)需要添加 src/generate-metadata.ts 文件,并在其中填写如下内容:

import { PluginMetadataGenerator } from '@nestjs/cli/lib/compiler/plugins';
import { ReadonlyVisitor } from '@nestjs/swagger/dist/plugin';

const generator = new PluginMetadataGenerator();
generator.generate({
  visitors: [
    new ReadonlyVisitor({ 
      introspectComments: true, 
      pathToSource: __dirname 
    })
  ],
  outputDir: __dirname,
  watch: true,
  tsconfigPath: 'path/to/api/tsconfig.json', // 后端应用在 monorepo 中的名称
});

然后使用下面的指令生成 metadata.ts,该文件中会包含自动生成的 Swagger Schema 定义:

npx ts-node path/to/api/src/generate-metadata.ts

使用自动生成的 metadata

首先更新 src/example.dto.ts,将原先的内容替换为:

export class SomeParams {
  /**
   * id of Something
   * @example 1
   */
  id: string;
}

export class SomeQuery {
  /**
   * page of the list
   * @example 1
   */
  page: number;

  /**
   * page size of the list
   * @example 10
   */
  page_size?: number = 10;
}

export class SomethingDto {
  /**
   * id of Something
   * @example 1
   */
  id: string;

  /**
   * name of Something
   * @example abc
   */
  name: string;
}

在更新后的文件中,移除了 ApiPropertyApiPropertyOptional 装饰器的使用,在 cli-plugin 生成的 src/metadata.ts 文件中,能够通过 Typescript 的类型定义推断出部分 Swagger Schema 的字段,至于其他内容可以通过注释的方式给出。

通过移除 ApiPropertyApiPropertyOptional 装饰器的使用,使得 DTO 定义文件的复用性得到了提升。

在完成 src/example.dto.ts 的编辑后,更新 src/main.ts 文件,添加加载自动生成的 src/metadata.ts 的代码:

async function main()
    // ...
    const metadata = await import("./metadata").default;
    await SwaggerModule.loadPluginMetadata(metadata);
    const document = SwaggerModule.createDocument(app, config);
    SwaggerModule.setup("/api/__doc", app, document);
    // ...
}
main();

修改完成后使用 yarn start 重启服务,前往浏览器查看最终效果

通过 OAS3.0 数据生成 Markdown 文档

如果需要 Markdown 格式的 API 接口文档,可以使用下面的脚本生成:

# pip install jsonpointer requests pandas
from copy import copy
import jsonpointer
import pandas as pd
import requests


def convert_schema_shape(input):
    result_list = []
    if 'type' in input and input['type'] == 'object' and 'properties' in input:
        properties = input['properties']
        required = input.get('required', [])

        for prop_name, prop_data in properties.items():
            schema = copy(prop_data)
            if "description" in schema:
                schema.pop("description")
            prop_info = {
                'name': prop_name,
                'type': prop_data.get('type', 'unknown'),
                'description': prop_data.get('description', ''),
                'required': prop_name in required,
                'schema': schema
            }
            result_list.append(prop_info)

    return result_list

def resolve_reference(document, reference):
    return jsonpointer.resolve_pointer(document, reference)

def append_title(method_data, markdown_content: str):
    if method_data.get('summary', None) is not None:
        markdown_content += f"## {method_data.get('summary', '')}接口\n\n"
    else:
        markdown_content += f"## 接口\n\n"
    return markdown_content

def append_description(method_data, markdown_content: str):
    markdown_content += f"### 功能说明\n\n"
    if method_data.get('description', None) is not None:
        markdown_content += f"{method_data.get('description', '')}\n\n"
    return markdown_content

def append_path(path, markdown_content: str):
    markdown_content += f"### 调用地址\n\n" 
    markdown_content += f"`{path}`\n\n"
    return markdown_content

def append_method(method, markdown_content: str):
    markdown_content += f"### 请求方式\n\n"
    markdown_content += f"{method.upper()}\n\n"
    return markdown_content

def append_content_type(method_data, markdown_content: str):
    markdown_content += f"### 数据格式\n\n"
    markdown_content += f"`application/json; encoding=utf-8`\n\n"
    return markdown_content;

def append_parameters(method_data, markdown_content: str):
    parameters = method_data.get('parameters', [])
    if parameters:
        markdown_content += f"### 请求参数\n\n"
        parameter_table = pd.DataFrame([
            (
                data.get("name"), 
                data.get("description"), 
                data.get("in"), 
                "是" if data.get("required", "False") else "否",
                data.get("schema")
            ) for data in parameters
        ], columns=["参数名称", "参数说明", "请求类型","是否必须", "schema"])
        markdown_content += parameter_table.to_markdown(index=False) + '\n\n'
    return markdown_content

def append_body(openapi_json, method_data, markdown_content: str):
    request_body = method_data.get('requestBody', {})
    if request_body:
        markdown_content += f"### 请求参数\n\n"
        content_type = list(request_body.get('content', {}).keys())[0]
        content_schema = request_body.get('content', {}).get(content_type, {}).get('schema', {})
        request_body_ref = content_schema.get('$ref')
        resolved_schema = resolve_reference(openapi_json, request_body_ref[1:])
        body = convert_schema_shape(resolved_schema)
        body_table = pd.DataFrame([
            (
                data.get("name"), 
                data.get("description"), 
                "body",
                "是" if data.get("required", "False") else "否",
                data.get("schema")
            ) for data in body
        ], columns=["参数名称", "参数说明", "请求类型","是否必须", "schema"])
        markdown_content += body_table.to_markdown(index=False) + "\n\n"
    return markdown_content

def append_responses(method_data, markdown_content: str):
    responses = method_data.get('responses', {})
    if responses:
        markdown_content += f"### 响应状态\n\n"
        response_table = pd.DataFrame([
            ( code, data.get('description', ''), "") 
            for code, data in responses.items()
        ], columns=['状态码', '说明', "备注"])
        markdown_content += response_table.to_markdown(index=False) + '\n\n'
    return markdown_content

def append_placeholder(markdown_content: str):
    markdown_content += f"### 请求样例\n\n"
    markdown_content += f"### 响应样例\n\n\n\n"
    return markdown_content

def read_and_convert(url, output_file):
    response = requests.get(url)
    if response.status_code != 200:
        print(f"[ERROR] 无法获取 OpenAPI 数据: {response.status_code}")
        return

    openapi_json = response.json()

    if 'paths' not in openapi_json:
        print("[ERROR] JSON 格式错误:缺少 'paths'")
        return

    markdown_content = "# 接口文档\n\n"

    for path, path_data in openapi_json['paths'].items():
        for method, method_data in path_data.items():
            markdown_content = append_title(method_data, markdown_content)
            markdown_content = append_description(method_data, markdown_content)
            markdown_content = append_path(path, markdown_content)
            markdown_content = append_method(method, markdown_content)
            markdown_content = append_content_type(method_data, markdown_content)
            markdown_content = append_parameters(method_data, markdown_content)
            markdown_content = append_body(openapi_json, method_data, markdown_content)
            markdown_content = append_responses(method_data, markdown_content)
            markdown_content = append_placeholder(markdown_content)

    # Write to Markdown file
    with open(output_file, 'w', encoding='utf-8') as file:
        file.write(markdown_content)

    print(f"[INFO] 文件 '{output_file}' 生成成功")

if __name__ == "__main__":
    import sys
    url=sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:3000/api/__doc-json"
    output=sys.argv[2] if len(sys.argv) > 2 else "doc.md"
    read_and_convert(url, output)

# python to_md.py "http://127.0.0.1:3000/api/__doc-json" "APIs.md"