UnoAPI 工具篇:下载并解析 OpenAPI 文档
“
本篇文章围绕 OpenAPI 文档展开。先用 nestjs + swagger 快速搭建一个后端项目,输出一个 OpenAPI JSON 文档,然后下载并解析文档内容。
截止上一篇文章,UnoAPI 还只是完成了一个空壳子,没有实际的功能。本次,我们将赋予它第一个功能,也是必须的前置功能:下载与解析 OpenAPI JSON 文档。
1️⃣ 这个 JSON 文档从哪里来?
本来是想通过 AI 帮我生成一份标准的 OpenAPI JSON 文档,虽然这样生成的文档看起来挺“标准”的,但总感觉不够真实。
所以,思来想去,还是决定自己快速搭建一个真实的后端项目,通过真实的接口输出一份 OpenAPI 3.0 标准的 JSON 文档。
这个项目主要用于测试,后期可能会逐渐覆盖更多情况,最好跟随此项目一起维护,所以就直接在 packages 目录下新增了一个子项目nest-serve。
pnpm i -g @nestjs/cli
cd packages/
nest new nest-serve然后需要安装 swagger 包:
pnpm add @nestjs/swagger此时一个 nestjs + swagger 项目就创建好了。
接口自然不会自己亲手去写,太花时间了。我自己先手动写了一个接口,然后让 AI 参照我写的接口,还原出之前 JSON 文档中所有的接口。
一次通过!本地跑起来之后,使用http://localhost:3000/api-json即可访问。
“
为了让大家也能看到这个神秘的文档到底长啥样,我便将此项目扔到了 vercel 上,需要的同学可自行查看:https://unoapi.codingmo.com/api-json
这个 JSON 文档非常重要,它是 UnoAPI 的理论基础。真实的后端项目,无论 Java 还是其他,大多也都支持输出此文档。
2️⃣ 下载 JSON 文档
我的想法是:这个文档需要在本地缓存一份。
这样,每次生成“API 代码”的时候,就不再需要依赖网络环境,直接使用本地缓存的 JSON 文档。后端接口有更新时,需要用户自己主动去更新。
因此,我们需要增加一个 cli 命令:更新 JSON 文档。
uno update一样的思路,我们先完成 cli 部分的代码:
// update 更新 OpenAPI JSON 文档
program
.command('update')
.description('更新 OpenAPI 文档')
.action(async () => {
try {
await updateOpenAPIDoc();
} catch (error) {
console.error('操作失败:', error);
process.exit(1);
}
});现在,我们需要去到 core 子项目完成 updateOpenAPIDoc 函数。
import { writeFile } from 'fs/promises';
import { getDefaultCacheFile, loadConfig } from '../config';
/**
* 更新 OpenAPI 文档
*/
export async function updateOpenAPIDoc() {
const config = await loadConfig();
const jsonDoc = await (typeof config.openapiUrl === 'function' ? config.openapiUrl() : fetchDoc(config.openapiUrl));
const cacheFile = config.cacheFile || getDefaultCacheFile(typeof config.output === 'string' ? config.output : config.output?.[0]);
await writeFile(cacheFile, JSON.stringify(jsonDoc, null, 2));
}
async function fetchDoc(url: string) {
const res = await fetch(url);
return await res.json();
}代码也很简单,根据配置文件unoapi.config.ts的配置项,来下载 JSON 文档,然后保存在缓存文件中。
这里有一个问题:
❓如何加载并执行用户的配置文件?
还记得unoapi.config.ts是我们通过uno init命令在用户项目的根目录下创建的吗?要想在 UnoAPI 中直接require('unoapi.config.ts')是不可能的,因为它是一个 ts 文件,而 Nodejs 本身没法直接执行 ts 文件。
我们必须使用一个ts-node库,这个库就是为了让 Nodejs 执行 ts 文件。
如果不想使用这个库,那就将配置文件换成 js,但是 js 又会失去 ts 的类型检查能力,看如何取舍了。
所以,我们的loadConfig函数是这样的:
/**
* 加载配置文件(支持 ts-node)
* @returns
*/
export async function loadConfig(): Promise<UnoAPIConfig> {
require('ts-node').register({
transpileOnly: true,
compilerOptions: {
module: 'commonjs'
}
});
const mod = require(CONFIG_PATH);
return mod.default || mod;
}另外,还有一个小问题需要注意:
❗️用户配置项的路径需要处理,比如:
export default defineUnoAPIConfig({
openapiUrl: 'https://unoapi.codingmo.com/api-json', // 支持返回 Promise 的回调函数
output: 'src/api', // 如需单独指定模型输出目录:['src/api', 'src/models'],
cacheFile: 'src/api/openapi.cache.json', // 缓存目录
// typeMapping: [['DateTime', 'string'], [...]], // 自定义类型映射优先
// funcTpl: (context) => { // 自定义 API 函数
// // 返回自定义 API 函数的字符串
// return `export function...`;
// },
});其中output和cacheFile可能是相对路径,也可能是绝对路径;这里我就统一处理成绝对路径。
/**
* 定义 UnoAPI 配置
* @param config
* @returns
*/
export async function defineUnoAPIConfig(config: UnoAPIConfig | (() => UnoAPIConfig | Promise<UnoAPIConfig>)) {
if (typeof config === 'function') {
config = await config();
}
return checkConfig(config);
}修改一下我们之前实现的defineUnoAPIConfig函数,使用checkConfig对配置项进行检查和处理。
/**
* 检查并处理配置项
* @param config 配置项
* @returns
*/
function checkConfig(config: UnoAPIConfig) {
if (!config.openapiUrl) {
throw new Error('openapiUrl is required');
}
if (typeof config.output === 'string') {
if (!path.isAbsolute(config.output)) {
config.output = path.join(process.cwd(), config.output);
}
} else if (Array.isArray(config.output)) {
config.output = config.output.map(dir => {
if (!path.isAbsolute(dir)) {
dir = path.join(process.cwd(), dir);
}
return dir;
}) as [string, string];
}
if (config.cacheFile) {
if (!path.isAbsolute(config.cacheFile)) {
config.cacheFile = path.join(process.cwd(), config.cacheFile);
}
}
return config;
}此时,我们的更新 JSON 文档的功能就算完成了。
在项目中执行uno update命令,正常下载并保存 JSON 文档。

3️⃣ 解析 JSON 文档
接下来就是 UnoAPI 最最核心的功能了,我们重新定义一下uno api这个命令。
这个命令有多种使用情况:
- 正常使用:
uno api url1 url2 url3; - 省略 URL:
uno api,在用户不知道接口 URL 的情况下,我们需要提供“关键字”搜索功能; - 全量生成:
uno api --all,适用于新项目,一次性生成文档中所有的接口代码。
其次,我们需要考虑 core 子项目如何提供“生成代码”的核心 API:
“
📌
- 它应该如何提供 API?纯函数?还是 class?
- 要提供哪些 API?要支持哪些功能?
- ...
解析 JSON 文档本质上是“内部的功能”,在用户层面来说,只有“生成代码”的功能。所以它内部必然是比较复杂的代码,纯函数肯定是不好实现的。
在多次反复修改之后,最终我决定采用“链式调用”风格来组织代码。
还是直接看 cli 部分的编码吧。
// 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, options) => {
if (!await existsConfig()) {
console.error('配置文件不存在,请先运行 unoapi init 命令生成配置文件');
process.exit(1);
}
const config = await loadConfig();
const unoapi = createUnoAPI(config);
if (options.all) {
// 生成所有接口的代码
urls = [];
} else if (urls?.length === 0) {
// 让用户选择
const selectedUrl = await inquirer.search<string>({
message: '使用关键字搜索接口:',
source: async (term) => unoapi.getUrlList(term || ''),
pageSize: 10,
});
urls = [selectedUrl];
}
console.log('urls:', urls);
try {
await unoapi
.api(urls)
.on(() => {
console.log('开始写入代码...');
})
.start({ funcName: options.funcName, output: options.output });
process.exit(0);
} catch (error) {
console.error('操作失败:', error);
process.exit(1);
}
});核心部分:
// 生成 API 代码
createUnoAPI(config)
.api(urls)
.on(...)
.start(args)
// 只生成 model 代码
createUnoAPI(config)
.model(urls)
.on(...)
.start(args)❓ 为什么要这样设计?
“
从 URL 开始解析到最后输出代码,这一过程会产生很多“中间变量”,这些中间变量,可以直接保存在 class 的属性中。所以内部应该是一个 UnoAPI 的 class 类。
“生成代码”这个过程其实需要分成 3 步:
- 解析 JSON 文档
- 转成“代码字符串”
- 写入对应的文件
“写入文件”的动作应该由“使用方”来完成,UnoAPI 核心只负责解析并输出“代码”。所以,需要注册一个回调(.on),每次输出一份“代码”时,告知一下“使用方”就可以了。
当然,方案千万种,这种设计方案可能也并不完美。但不管怎样,我们都需要确认一种方案才能进行下去。
UnoAPI 类大概就是这样的(部分代码):
class UnoAPI {
private config: UnoAPIConfig; // 配置项
private rawData: any[] = []; // 文档原始数据
private parsedApis: ParsedApi[] = []; // 解析后的数据
constructor(config: UnoAPIConfig) {
this.config = config;
// 加载并解析文档数据
this.parseData();
}
private async parseData() {
let { cacheFile } = this.config;
cacheFile = cacheFile || getDefaultCacheFile(this.apiOutput);
console.log('加载文档数据,从缓存文件读取:', cacheFile);
try {
// 预留多JSON文档支持
this.rawData = [require(cacheFile)];
console.log('加载文档数据成功', this.rawData);
} catch (error) {
console.error('加载文档数据失败,请先运行 unoapi update 命令生成文档数据', error);
this.rawData = [];
this.parsedApis = [];
}
this.rawData.forEach(doc => {
if (doc?.paths) {
Object.keys(doc.paths).forEach((url) => {
Object.keys(doc.paths[url]).forEach(method => {
const apiObj = doc.paths[url][method];
this.parsedApis.push({
url,
method,
...apiObj,
});
});
});
}
});
}
getUrlList(keywords?: string) {
if (!keywords) {
return this.parsedApis.map(item => `[${item.method.toUpperCase()}] ${item.url} ${item.summary}`);
}
return this.parsedApis.filter(item => {
return item.url.includes(keywords) || (item.summary && item.summary.includes(keywords)) || (item.description && item.description.includes(keywords));
}).map(item => `[${item.method.toUpperCase()}] ${item.url} ${item.summary}`);
}
api(urls: string[]) {
// ...
return this;
}
model(urls: string[]) {
// ...
return this;
}
on(handler: (codeContext: GenerateCodeContext) => void | Promise<void>) {
// ...
return this;
}
start(args: GenerateArgs) {
//...
}
}
export function createUnoAPI(config: UnoAPIConfig) {
return new UnoAPI(config);
}parseData方法主要是将原始的 JSON 数据,转成“单个 API 接口”的数组,方便搜索以及后续的使用;真正的“解析动作”可能存在于api和model方法中,然后在start方法中来生成“代码”并触发回调。
🔍 测试一下搜索功能

个人感觉这个搜索功能很有必要,因为大多数时候完整的 URL 路径并不好记忆,有了这个功能,我们只需要根据关键字搜索就能找到需要的接口 URL 了。
到目前为止,uno api命令已经梳理完成。UnoAPI 的核心功能的架子也基本搭好,后续,我们只需要实现UnoAPI类中方法就可以了。
总结
本次,我们已经完成了 OpenAPI JSON 文档的下载与更新,也对如何“生成代码”进行了梳理。下次,我们正式跑通uno api命令,生成一个“API 代码”出来吧。
感谢阅读
码字不易,点个“关注”再走吧~
👇️👇️👇️