跳转到内容

UnoAPI 工具篇:文档解析与代码生成

本篇文章将完成 OpenAPI 文档的解析与代码生成的功能,也意味着 UnoAPI 工具的开发阶段已经进入尾声。

代码重构

为了使逻辑更简单清晰,我决定放弃之前 core 子项目中的部分 API 设计,全部采用“函数式”的编码风格。并且将功能拆分成多个模块,“使用方”可自己根据模块中提供的函数来实现具体的功能。

比如,去掉了之前设计的UnoAPI类,拆成了configgeneratetransformwrite等多个模块。

  • config:提供配置相关的功能
  • generate:主要负责文档的解析
  • transform:提供代码生成功能
  • write:提供代码写入相关的功能

通常,核心的用法只需两步操作:1️⃣ 生成代码;2️⃣ 写入文件。

  1. 生成结果 = generateCode(待生成 api, 其他选项)
  2. 写入文件:writeToFile(生成结果, 其他选项)

核心代码大概长这样:

ts
// 1. 生成代码
const genApis = await generateCode(apis, options);

// 2. 写入文件
writeApiFile(genApi, options);

这样设计的主要目的是“灵活”,其次是个人更喜欢使用函数式的编程风格。

为什么更灵活?

作为使用方,很多时候并不需要提供方“全部包办”,你只需要给我提供核心的“代码生成”功能就行了,至于生成的代码要如何使用那是我的事情。当然你有能力给我提供更多常用功能更好,用不用、如何用那是我的事情。

所以,core 子项目不能依赖于配置文件unoapi.config.ts,它所需要的所有参数必须是使用方提供给它的。

如此,不管是对当前的 cli 项目,还是未来的 vscode-extension 扩展,都能够更加灵活的满足使用方的需求。

core 目录结构

文档解析

文档解析相关功能放在了generate模块,它也是对外提供的核心模块。其内部定义了 3 个函数:generateCodegenerateSingleApiCodegenerateModelCode

ts
/**
 * 生成代码
 * @param apis api 列表
 * @param options 生成选项
 */
export function generateCode(apis: ApiOperationObject[], options?: GenerateOptions);

/**
 * 生成单个 API 代码
 * @param parsedApi 解析后的 API 对象
 * @param options 生成选项
 */
export function generateSingleApiCode(parsedApi: ApiOperationObject, options?: GenerateSingleOptions);

/**
 * 生成模型代码
 * @param schemas schema 模型集合
 * @param refs 模型引用列表
 * @param options 选项
 */
export function generateModelCode(schemas: ModelSchemaCollection, refs: string[], options?: GenerateModelOptions);
  • generateCode:批量生成 api,内部调用generateSingleApiCode函数
  • generateSingleApiCode:单个 api 生成,内部调用generateModelCode函数
  • generateModelCode:生成模型,通常不需要手动调用

1️⃣ 如何生成 api 函数代码?

假如我们的目标是将下面的接口内容生成代码:

json
"/client/user": {
  "post": {
    "tags": [
      "user"
    ],
    "summary": "创建用户",
    "operationId": "create",
    "requestBody": {
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/UserUpdateDto"
          }
        }
      },
      "required": true
    },
    "responses": {
      "200": {
        "description": "创建成功",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/UserUpdateDto"
            }
          }
        }
      }
    }
  }
}
ts
/**
 * 创建用户
 */
export function create(data: UserUpdateDto) {
  return request<UserUpdateDto>({ url: '/client/user', data, method: 'POST' });
}

分析一下,生成的 api 函数代码的几个必要素和取值规则:

  • 函数名称:create,取文档中的operationId字段,此字段通常对应后端 Controller 类中的方法名称;
  • 入参:data: UserUpdateDto,对应requestBodyparameters字段,入参类型通常有 3 种:路径参数、query 参数、body 参数;
  • 出参:UserUpdateDto,对应responses字段,我们只需要关注200 <= status < 300的成功状态;
  • url:'/client/user'
  • 方法:'POST',一个 URL 可能对应多个方法;
  • 注释内容:取summarydescription字段;
  • 文件路径:这个可以根据 URL 路径来确定,比如/client/user,对应生成的文件路径就是/client/user.ts

现在将上面的必要字段定义成一个类型ApiContext

ts
interface ApiContext {
  api: ApiOperationObject; // 文档内容
  name: string; // 函数名称
  url: string;
  method: HTTPMethod;
  comment?: string;
  pathParams?: TypeFieldOption[]; // URL路径参数
  queryType?: string; // query参数类型
  bodyType?: string; // body参数类型
  responseType?: string; // 响应类型
  refs?: string[]; // ref 应用模型
}

从文档中解析出了这些必要字段后,就可以生成具体的代码了。

还记得我们配置文件中自定义的模板函数吗?

ts
import { defineUnoConfig } from '@unoapi/core';

export default defineUnoConfig({
  // ...
  funcTpl: (context: ApiContext) => {
    // 返回自定义 API 函数的字符串
    return `export function...`;
  },
});

我们只需要将解析后的ApiContext对象传给这个函数,让用户来组装拼接成代码。当然,我们内部肯定也需要提供一个默认生成代码的函数用来兜底。

2️⃣ 如何生成 model 代码?

这里就需要用到ApiContext对象中的refs字段了,这个字段是解析文档时需要保存下来的字段,一个 api 可能对应多个 ref 引用。

通过ref引用#/components/schemas/UserUpdateDto,就可以从 OpenAPI 文档中拿到对应的模型内容。

json
"components": {
  "schemas": {
    "UserUpdateDto": {
      "type": "object",
      "description": "更新用户信息的DTO",
      "properties": {
        "name": {
          "type": "string",
          "description": "用户名"
        },
        "age": {
          "type": "integer",
          "format": "int32",
          "description": "年龄"
        },
        "email": {
          "type": "string",
          "description": "邮箱"
        }
      },
      "required": [
        "age",
        "name"
      ]
    },
  },
}

同样的,假如我们需要生成这样的 model 代码:

ts
/** 更新用户信息的DTO */
export default interface UserUpdateDto {
  /** 用户名 */
  name: string;
  /** 年龄 */
  age: number;
  /** 邮箱 */
  email?: string;
}

分析必要素和生成规则:

  • model 名称:UserUpdateDto
  • model 注释内容:对应description
  • 字段名称:对应properties属性的key
  • 字段类型:通过type进行映射转换;
  • 字段注释:对应字段的description
  • 字段是否可选:根据required来判断;
  • 文件路径:api 的文件路径 + model 名称,比如client/model/UserUpdateDto.ts

模型通常都比较统一,可以不需要支持自定义代码的功能,内部直接生成代码就好了。

3️⃣ 如何对外提供 API?

核心函数是generateSingleApiCode,此函数每次只处理一个 api,并输出一个API 结果。这个结果需要包含最终生成的 api 代码,以及文件路径等信息。

那 model 部分的代码如何返回呢?思来想去,最后还是决定在API 结果中挂载一个生成 model 代码的方法。由“使用方”主动去获取 model 部分的代码。

所以,函数generateSingleApiCode返回的这个API 结果定义成GenerateApi类型:

ts
interface GenerateCode {
  sourceCode: string;
  /** 文件名称 abc */
  fileName: string;
  /** 文件全名称 abc.ts */
  fileFullName: string;
  /** 文件相对目录 users */
  fileDir: string;
  /** 文件相对路径 users/abc.ts */
  filePath: string;
}

export interface GenerateModel extends GenerateCode {
  /** 类型名称 */
  typeName: string;
}

export interface GenerateApi extends GenerateCode {
  /** api 模型引用 */
  refs?: string[];
  /** 生成模型 */
  getModels: (schemas: ModelSchemaCollection) => GenerateModel[];
}

❓ 为什么要挂载一个getModels方法来生成 model 代码?

主要有两点原因:

  1. generateSingleApiCode函数应该返回的是当前 api 的所有代码(包括 model 代码),而不是让用户再去调一个生成 model 的函数,这样功能就会比较割裂;而挂载的getModels方法从语义上表示“获取 model”,并且可以与函数返回的结果形成一个整体。
  2. 生成 model 代码需要传递一个模型集合schemas参数,这个参数如果作为generateSingleApiCode函数的入参会比较奇怪(我生成 api 代码为什么要传递一个不相关的 schemas 参数?)

在 cli 中使用

说了这么多,可能也不太清楚 core 中的代码到底是如何设计的。现在,站在使用者的角度,或许会清晰很多。

ts
// api 生成API代码
program
  .command('api', { isDefault: true })
  .argument('[urls...]', '接口 URL,可以是多个,用空格分隔')
  .description('生成 API 代码')
  .option('-o, --output <output>', '输出目录')
  .option('--func <funcName>', '自定义 API 函数名称')
  .option('--all', '生成所有接口的代码')
  .action(async (urls: (string | ApiOperationObject)[], options) => {
    console.log('开始生成 API 代码...');
    if (!await existsConfig()) {
      console.error('配置文件不存在,请先运行 unoapi init 命令生成配置文件');
      process.exit(1);
    }

    const config = await loadConfig();
    let doc: OpenAPIObject
    try {
      doc = loadDoc(config.cacheFile);
    } catch {
      console.error('请先运行 uno update 下载文档');
      process.exit(1);
    }

    if (options.all) {
      // 生成所有接口的代码
      urls = [];
    } else if (urls?.length === 0) {
      // 让用户选择
      const selectedUrl = await inquirer.search<ApiOperationObject>({
        message: '使用关键字搜索接口:',
        source: async (term) => {
          const apis = searchApi(doc, term);
          return apis.map(api => {
            const methodStr = `[${api.method.toUpperCase()}]`;
            return {
              value: api,
              name: `${methodStr.padEnd(9)}${api.path} ${[api.summary, api.description].filter(Boolean).join(' - ')}`,
            };
          }); // 显示方法和路径
        },
        pageSize: 10,
      });

      urls = [selectedUrl];
    }

    if (typeof urls[0] === 'string') {
      urls = filterApi(doc, urls as string[]);
    }

    let genApis: GenerateApi[] = [];
    if (urls.length === 1) {
      if (!options.func) {
        // 让用户输入一个函数名称
        const funcName = await inquirer.input({
          message: '请输入自定义函数名称(可选):',
        });
        options.func = funcName;
      }
      genApis.push(generateSingleApiCode(urls[0] as ApiOperationObject, {
        funcName: options.func,
        funcTpl: config.funcTpl,
        typeMapping: config.typeMapping,
      }));
    } else {
      genApis = generateCode(urls as ApiOperationObject[], {
        funcTpl: config.funcTpl,
        typeMapping: config.typeMapping,
      });
    }

    try {
      for (const genApi of genApis) {
        await writeApiFile(genApi, { base: options.output || config.output, imports: config.imports });

        if (doc.components?.schemas) {
          const genModels = genApi.getModels(doc.components?.schemas);

          await writeModelFile(genModels, {
            base: options.output || config.modelOutput,
            asGlobalModel: config.asGlobalModel,
          });
        }
      }

      process.exit(0);
    } catch (error) {
      console.error('写入文件失败:', error);
      process.exit(1);
    }
  });

代码有点多,大部分都是处理不同参数相关的代码,真正的核心代码主要是最后面两段。

1️⃣ 首先是加载配置与文档

ts
const config = await loadConfig();
let doc: OpenAPIObject
try {
  doc = loadDoc(config.cacheFile);
} catch {
  console.error('请先运行 uno update 下载文档');
  process.exit(1);
}

然后处理几种不同的情况:

  1. 生成所有接口:命令行带--all时,url 入参传一个空数组;
  2. 用户没有输入 url:触发 api 选择功能,让用户去搜索并选择一个 url;
  3. 正常输入一个或多个 url:将 url 字符串转成 api 对象;

2️⃣ 生成 api 代码

判断 url 是单个还是多个,选择性调用对应的函数:

ts
// 单个
genApis.push(generateSingleApiCode(urls[0] as ApiOperationObject, {
  funcName: options.func,
  funcTpl: config.funcTpl,
  typeMapping: config.typeMapping,
}));

// 多个
const genApis = generateCode(urls as ApiOperationObject[], {
  funcTpl: config.funcTpl,
  typeMapping: config.typeMapping,
});

generateCode函数内部,其实也是循环调用generateSingleApiCode函数。

3️⃣ 写入文件

ts
for (const genApi of genApis) {
  await writeApiFile(genApi, { base: options.output || config.output, imports: config.imports });

  if (doc.components?.schemas) {
    const genModels = genApi.getModels(doc.components.schemas);

    await writeModelFile(genModels, {
      base: options.output || config.modelOutput,
      asGlobalModel: config.asGlobalModel,
    });
  }
}

api 函数代码使用了writeApiFile进行写入,model 代码使用writeModelFile写入。

在写入 model 之前,需要调用genApi.getModels方法生成 model 代码。

asGlobalModel参数用来设置是否将 model 类型在全局声明。

ts
import _UserUpdateDto from './UserUpdateDto';
import _ApiResultUserUpdateDto from './ApiResultUserUpdateDto';

declare global {
  type UserUpdateDto = _UserUpdateDto;
  type ApiResultUserUpdateDto = _ApiResultUserUpdateDto;
}

这样做的目的,主要是方便全局使用这些数据模型,否则就需要用户手动导入对应的数据模型。

运行 cli 命令

至此,UnoAPI 的功能已经全部完成,我们从头开始,进行一遍完整的测试。

1️⃣ 生成配置文件

bash
uno init -u https://unoapi.codingmo.com/api-json
# 配置文件创建成功:unoapi.config.ts

修改配置文件的输出目录output: 'examples/api'

ts
import { defineUnoConfig } from '@unoapi/core';

export default defineUnoConfig({
  openapiUrl: 'https://unoapi.codingmo.com/api-json', // 支持返回 Promise 的回调函数
  output: 'examples/api', // 如需单独指定模型输出目录:['src/api', 'src/models'],
  // cacheFile: 'src/api/.openapi-cache.json', // 缓存目录
  // typeMapping: { float: number }, // 自定义类型映射优先

  // funcTpl: (context) => { // 自定义 API 函数
  //   // 返回自定义 API 函数的字符串
  //   return `export function...`;
  // },
  // asGlobalModel: false,
  imports: ['import request from \'@utils/request\';'],
});

2️⃣ 下载/更新 OpenAPI 文档

bash
uno update

更新文档

3️⃣ 生成 api 代码

👉 情况一:搜索并选择 url 生成代码

选择 url

输入 api 函数名称

生成结果

👉 情况二:指定多个 url

批量生产

命令指定了两个 url,但是/users/{id}实际包含了3个 api,所以最终一共生成了4个 api 函数。

👉 情况三:生成全部 api

全量生成

将文档中的全部 12 个 api 接口全部生成 api 函数代码。

然后再看一下examples/api目录和部分代码。

生成的代码

总结

到现在,UnoAPI 的功能已经基本完成,当初的期望和设想都已经全部实现。剩下的就是细节的改进与优化,毕竟不可能一次性做到完美,肯定还会存在许多未考虑到的各种意外情况。

对于vscode 扩展部分,后续如果有时间再说吧。目前设计的 core 子包基本上都已经考虑到了对 vscode 扩展的支持,要实现起来应该也不会太难。

关于源码

有需要的同学可以通过此公众号进行获取:

  1. 回复“源码”
  2. 点击底部菜单栏“获取源码”

关于发布

目前已经将@unoapi/core@unoapi/cli两个包发布到 npm 仓库,有时间再整理一篇关于发布相关的文章吧。

感谢阅读