跳转到内容

UnoAPI 工具篇:下载并解析 OpenAPI 文档

本篇文章围绕 OpenAPI 文档展开。先用 nestjs + swagger 快速搭建一个后端项目,输出一个 OpenAPI JSON 文档,然后下载并解析文档内容。

截止上一篇文章,UnoAPI 还只是完成了一个空壳子,没有实际的功能。本次,我们将赋予它第一个功能,也是必须的前置功能:下载与解析 OpenAPI JSON 文档

1️⃣ 这个 JSON 文档从哪里来?

本来是想通过 AI 帮我生成一份标准的 OpenAPI JSON 文档,虽然这样生成的文档看起来挺“标准”的,但总感觉不够真实。

所以,思来想去,还是决定自己快速搭建一个真实的后端项目,通过真实的接口输出一份 OpenAPI 3.0 标准的 JSON 文档。

这个项目主要用于测试,后期可能会逐渐覆盖更多情况,最好跟随此项目一起维护,所以就直接在 packages 目录下新增了一个子项目nest-serve

bash
pnpm i -g @nestjs/cli
cd packages/
nest new nest-serve

然后需要安装 swagger 包:

bash
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 文档

bash
uno update

一样的思路,我们先完成 cli 部分的代码:

ts
// update 更新 OpenAPI JSON 文档
program
  .command('update')
  .description('更新 OpenAPI 文档')
  .action(async () => {
    try {
      await updateOpenAPIDoc();
    } catch (error) {
      console.error('操作失败:', error);
      process.exit(1);
    }
  });

现在,我们需要去到 core 子项目完成 updateOpenAPIDoc 函数。

ts
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
/**
 * 加载配置文件(支持 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;
}

另外,还有一个小问题需要注意:

❗️用户配置项的路径需要处理,比如:

ts
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...`;
  // },
});

其中outputcacheFile可能是相对路径,也可能是绝对路径;这里我就统一处理成绝对路径。

ts
/**
 * 定义 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对配置项进行检查和处理。

ts
/**
 * 检查并处理配置项
 * @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这个命令。

这个命令有多种使用情况:

  1. 正常使用:uno api url1 url2 url3
  2. 省略 URL:uno api,在用户不知道接口 URL 的情况下,我们需要提供“关键字”搜索功能;
  3. 全量生成:uno api --all,适用于新项目,一次性生成文档中所有的接口代码。

其次,我们需要考虑 core 子项目如何提供“生成代码”的核心 API:

📌

  1. 它应该如何提供 API?纯函数?还是 class?
  2. 要提供哪些 API?要支持哪些功能?
  3. ...

解析 JSON 文档本质上是“内部的功能”,在用户层面来说,只有“生成代码”的功能。所以它内部必然是比较复杂的代码,纯函数肯定是不好实现的。

在多次反复修改之后,最终我决定采用“链式调用”风格来组织代码。

还是直接看 cli 部分的编码吧。

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, 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);
    }
  });

核心部分:

ts
// 生成 API 代码
createUnoAPI(config)
  .api(urls)
  .on(...)
  .start(args)

// 只生成 model 代码
createUnoAPI(config)
  .model(urls)
  .on(...)
  .start(args)

❓ 为什么要这样设计?

从 URL 开始解析到最后输出代码,这一过程会产生很多“中间变量”,这些中间变量,可以直接保存在 class 的属性中。所以内部应该是一个 UnoAPI 的 class 类。

“生成代码”这个过程其实需要分成 3 步:

  1. 解析 JSON 文档
  2. 转成“代码字符串”
  3. 写入对应的文件

“写入文件”的动作应该由“使用方”来完成,UnoAPI 核心只负责解析并输出“代码”。所以,需要注册一个回调(.on),每次输出一份“代码”时,告知一下“使用方”就可以了。

当然,方案千万种,这种设计方案可能也并不完美。但不管怎样,我们都需要确认一种方案才能进行下去。

UnoAPI 类大概就是这样的(部分代码):

ts
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 接口”的数组,方便搜索以及后续的使用;真正的“解析动作”可能存在于apimodel方法中,然后在start方法中来生成“代码”并触发回调。

🔍 测试一下搜索功能

个人感觉这个搜索功能很有必要,因为大多数时候完整的 URL 路径并不好记忆,有了这个功能,我们只需要根据关键字搜索就能找到需要的接口 URL 了。

到目前为止,uno api命令已经梳理完成。UnoAPI 的核心功能的架子也基本搭好,后续,我们只需要实现UnoAPI类中方法就可以了。

总结

本次,我们已经完成了 OpenAPI JSON 文档的下载与更新,也对如何“生成代码”进行了梳理。下次,我们正式跑通uno api命令,生成一个“API 代码”出来吧。

感谢阅读

码字不易,点个“关注”再走吧~

👇️👇️👇️