UnoAPI 工具篇:文档解析与代码生成
本篇文章将完成 OpenAPI 文档的解析与代码生成的功能,也意味着 UnoAPI 工具的开发阶段已经进入尾声。
代码重构
为了使逻辑更简单清晰,我决定放弃之前 core 子项目中的部分 API 设计,全部采用“函数式”的编码风格。并且将功能拆分成多个模块,“使用方”可自己根据模块中提供的函数来实现具体的功能。
比如,去掉了之前设计的UnoAPI类,拆成了config、generate、transform、write等多个模块。
config:提供配置相关的功能generate:主要负责文档的解析transform:提供代码生成功能write:提供代码写入相关的功能
通常,核心的用法只需两步操作:1️⃣ 生成代码;2️⃣ 写入文件。
“
- 生成结果 = generateCode(待生成 api, 其他选项)
- 写入文件:writeToFile(生成结果, 其他选项)
核心代码大概长这样:
// 1. 生成代码
const genApis = await generateCode(apis, options);
// 2. 写入文件
writeApiFile(genApi, options);这样设计的主要目的是“灵活”,其次是个人更喜欢使用函数式的编程风格。
为什么更灵活?
作为使用方,很多时候并不需要提供方“全部包办”,你只需要给我提供核心的“代码生成”功能就行了,至于生成的代码要如何使用那是我的事情。当然你有能力给我提供更多常用功能更好,用不用、如何用那是我的事情。
所以,core 子项目不能依赖于配置文件unoapi.config.ts,它所需要的所有参数必须是使用方提供给它的。
如此,不管是对当前的 cli 项目,还是未来的 vscode-extension 扩展,都能够更加灵活的满足使用方的需求。

文档解析
文档解析相关功能放在了generate模块,它也是对外提供的核心模块。其内部定义了 3 个函数:generateCode、generateSingleApiCode和generateModelCode。
/**
* 生成代码
* @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 函数代码?
假如我们的目标是将下面的接口内容生成代码:
"/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"
}
}
}
}
}
}
}/**
* 创建用户
*/
export function create(data: UserUpdateDto) {
return request<UserUpdateDto>({ url: '/client/user', data, method: 'POST' });
}分析一下,生成的 api 函数代码的几个必要素和取值规则:
- 函数名称:
create,取文档中的operationId字段,此字段通常对应后端 Controller 类中的方法名称; - 入参:
data: UserUpdateDto,对应requestBody和parameters字段,入参类型通常有 3 种:路径参数、query 参数、body 参数; - 出参:
UserUpdateDto,对应responses字段,我们只需要关注200 <= status < 300的成功状态; - url:
'/client/user'; - 方法:
'POST',一个 URL 可能对应多个方法; - 注释内容:取
summary或description字段; - 文件路径:这个可以根据 URL 路径来确定,比如
/client/user,对应生成的文件路径就是/client/user.ts。
现在将上面的必要字段定义成一个类型ApiContext:
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 应用模型
}从文档中解析出了这些必要字段后,就可以生成具体的代码了。
还记得我们配置文件中自定义的模板函数吗?
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 文档中拿到对应的模型内容。
"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 代码:
/** 更新用户信息的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类型:
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 代码?
主要有两点原因:
generateSingleApiCode函数应该返回的是当前 api 的所有代码(包括 model 代码),而不是让用户再去调一个生成 model 的函数,这样功能就会比较割裂;而挂载的getModels方法从语义上表示“获取 model”,并且可以与函数返回的结果形成一个整体。- 生成 model 代码需要传递一个模型集合
schemas参数,这个参数如果作为generateSingleApiCode函数的入参会比较奇怪(我生成 api 代码为什么要传递一个不相关的 schemas 参数?)
在 cli 中使用
说了这么多,可能也不太清楚 core 中的代码到底是如何设计的。现在,站在使用者的角度,或许会清晰很多。
// 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️⃣ 首先是加载配置与文档
const config = await loadConfig();
let doc: OpenAPIObject
try {
doc = loadDoc(config.cacheFile);
} catch {
console.error('请先运行 uno update 下载文档');
process.exit(1);
}然后处理几种不同的情况:
- 生成所有接口:命令行带
--all时,url 入参传一个空数组; - 用户没有输入 url:触发 api 选择功能,让用户去搜索并选择一个 url;
- 正常输入一个或多个 url:将 url 字符串转成 api 对象;
2️⃣ 生成 api 代码
判断 url 是单个还是多个,选择性调用对应的函数:
// 单个
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️⃣ 写入文件
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 类型在全局声明。
import _UserUpdateDto from './UserUpdateDto';
import _ApiResultUserUpdateDto from './ApiResultUserUpdateDto';
declare global {
type UserUpdateDto = _UserUpdateDto;
type ApiResultUserUpdateDto = _ApiResultUserUpdateDto;
}这样做的目的,主要是方便全局使用这些数据模型,否则就需要用户手动导入对应的数据模型。
运行 cli 命令
至此,UnoAPI 的功能已经全部完成,我们从头开始,进行一遍完整的测试。
1️⃣ 生成配置文件
uno init -u https://unoapi.codingmo.com/api-json
# 配置文件创建成功:unoapi.config.ts修改配置文件的输出目录output: 'examples/api'。
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 文档
uno update
3️⃣ 生成 api 代码
👉 情况一:搜索并选择 url 生成代码



👉 情况二:指定多个 url

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

将文档中的全部 12 个 api 接口全部生成 api 函数代码。
然后再看一下examples/api目录和部分代码。

总结
到现在,UnoAPI 的功能已经基本完成,当初的期望和设想都已经全部实现。剩下的就是细节的改进与优化,毕竟不可能一次性做到完美,肯定还会存在许多未考虑到的各种意外情况。
对于vscode 扩展部分,后续如果有时间再说吧。目前设计的 core 子包基本上都已经考虑到了对 vscode 扩展的支持,要实现起来应该也不会太难。
关于源码
有需要的同学可以通过此公众号进行获取:
- 回复“源码”
- 点击底部菜单栏“获取源码”
关于发布
目前已经将@unoapi/core与@unoapi/cli两个包发布到 npm 仓库,有时间再整理一篇关于发布相关的文章吧。