一个由 apifox 的接口文档生成 typescript 声明的小工具,记录一下开发过程。
仓库代码:https://github.com/JexLau/apifox-to-ts

背景

项目想要接入 typescript 开发,但维护接口的 typings 是一个繁琐且重复的过程,在观察后端提供的接口文档(基于 apifox 平台),发现它的数据构成有一定的规律,且后端的接口规范良好,得益于在前司学到的经验,决定写一个脚本工具来对接口文档自动生成接口 typings。

当然,apifox也提供了生成typings工具,但我觉得可能存在两个缺陷:

  1. 对于枚举类型的处理,在实际开发中,有些枚举书写不规范转换会报错,个人觉得还是把它处理成联合类型更好。
  2. apifox 生成的代码, schema 和接口层混杂在一起,项目庞大时比较混乱,按模块生成对应的 schema 和 path 会更清晰,更容易维护。

结果

为了更清晰地感知这个工具的作用,把结果放在前面。第一个文件是service.ts文件,也就是项目中可以直接引入使用的接口文件。第二个文件是schema文件,包含了项目所有的schema。第三个文件是path文件,包含了项目所有的请求。将这三个文件复制到项目中,可直接使用。后续接口有更新,执行脚本也可一键生成,不必费力维护接口的typings。
眼尖的小伙伴当然发现了,目前项目还没有根据模块生成对应的文件,原因是因为在开发过程中,发现模块名称是中文,我不愿意根据中文生成模块,所以暂时不做。
image.png

开发过程

打开一个 apifox 项目文档,通过观察控制台的请求,发现 apifox 网页端的数据主要来源于这三个接口:

  • /api/v1/shared-docs/${share_id}/data-schemas 用来请求schemas(抽象集合)
  • /api/v1/shared-docs/${share_id}/http-api-tree 用来请求接口所有模块
  • /api/v1/shared-docs/${share_id}/http-apis/${api_id} 用来请求某一接口的具体构成(api_id是通过上一个接口http-api-tree拿到的)

    share_id 为项目分享生成的 id

在日常开发中,我们知道一个请求主要有这两部分:请求参数Request+请求返回值Response,请求Path。第一部分可以抽象地看成是Schema(抽象集合),第二部分就是Path,工具函数需要做的就是解析这两部分。

1. 解析schema

上一步我们知道 data-schemas 这个接口可以拿到项目中所有 schemas,我放个具体的截图感受一下。左边是整体的截图,右边是某一项的截图。
image.pngimage.png
它是一个数组,每个数组项就是一个 schema,所谓的 schema 就是一个对象,解析 schema 就是解析这个对象里面的属性。解析属性,首先需要知道这个属性是什么类型,我们可以先把所有的 type 打印出来看一下。

  1. // 抽离schemas
  2. const schemasUrl = "https://www.apifox.cn/api/v1/shared-docs/xxxx/data-schemas"
  3. axios.get(schemasUrl).then(res => {
  4. const schemasData = res.data.data;
  5. console.log(`**************成功请求 schemas 数据**************`);
  6. // 处理schema
  7. // 先观察一下schema有多少种type
  8. const types = []
  9. schemasData.forEach(item => {
  10. const properties = item.jsonSchema.properties;
  11. if (properties) {
  12. for (let p in properties) {
  13. const type = properties[p].type
  14. if(types.indexOf(type) === -1) {
  15. types.push(type)
  16. }
  17. }
  18. }
  19. })
  20. console.log(types.join(",")) // string,array,integer,,object,boolean
  21. })

打印出来的结果是 string,array,integer,,object,boolean。注意,里面有一个空值,待会我们可以打印一下空值的jsonSchema是什么情况。string, integer,boolean都是简单类型,可以直接转换为typescript里面对应的string, number,boolean类型,但对于object,array和空值,我们需要额外去处理它。那么我们先看一下这三种类型是什么情况:
image.png
这是它们的结构,可以看到这三者的结构是不一样的,可以根据这些数据大概抽象出它们的interface。而 { ‘$ref’: ‘#/definitions/5227172’ } 这种结构,意思是它的类型对应的是 id 为 5227172 的 schema。

  1. interface SchemaArray {
  2. type: string, // 目前发现有 'array' | 'object' 这两个类型的值
  3. example?: string[],
  4. description?: string,
  5. items: {
  6. type?: string, // 简单类型
  7. $ref?: string, // 链接另外一个scheme,和type互斥
  8. }
  9. }
  10. interface SchemaObject {
  11. type: string,
  12. example?: {},
  13. description?: string,
  14. additionalProperties: {
  15. type?: string, // 'object'
  16. }
  17. }
  18. interface SchemaNull {
  19. $ref?: string, // 链接另外一个scheme
  20. }

理清了这些类型关系,我们可以对这个schema对象数组简单做一个解析,解析的结果就是每一个 schema 数据对应的 interface/type。

解析类型

经过上面的过程,我们知道有 string,array,integer,,object,boolean 这几种类型。通过观察数据,发现type为string其中还有一种情况,就是枚举enum。所以我们首先要先把schema-data遍历一次,生成所有的枚举类型(方便后面引用)。

  1. for (let key in properties) {
  2. const property = properties[key]
  3. if (property.enum) {
  4. // schemaTitle充当一个前缀的作用,防止枚举重命名
  5. const enumName = schemaTitle + firstToLocaleUpperCase(key)
  6. const description = property.description || ""
  7. result += `
  8. /** ${description} */
  9. type ${enumName} = ${handleEnumType(property.enum)}`
  10. }
  11. }

处理完枚举类型,然后再遍历一次,根据对应的type生成typescript的代码。

  1. schemasData.forEach(item => {
  2. const properties = item.jsonSchema.properties;
  3. const required = item.jsonSchema.required;
  4. const description = item.jsonSchema.description || "";
  5. const schemaTitle = formatSchemaName(item.jsonSchema.title);
  6. result += `
  7. /** ${description} */
  8. interface ${schemaTitle} {${handleAllType(properties, required, schemaTitle)}
  9. }`
  10. })
  11. /** 转换类型 */
  12. const convertType = function (property, key, schemaTitle = "") {
  13. let type = "未知";
  14. switch (property.type) {
  15. case "string":
  16. if (property.enum) {
  17. const enumType = schemaTitle + firstToLocaleUpperCase(key)
  18. type = enumType
  19. } else {
  20. type = "string"
  21. };
  22. break;
  23. case "boolean":
  24. type = "boolean";
  25. break;
  26. case "integer":
  27. type = "number";
  28. break;
  29. case "number":
  30. type = "number";
  31. break;
  32. case "array":
  33. if (property.items.type) {
  34. let itemType = property.items.type;
  35. if (itemType === "integer") {
  36. type = `Array<number>`;
  37. } else {
  38. type = `Array<${itemType}>`;
  39. }
  40. } else if (property.items.$ref) {
  41. const refType = convertRefType(property.items.$ref);
  42. if (refType) {
  43. type = `Array<${refType}>`;
  44. }
  45. }
  46. break;
  47. case "object":
  48. if (property.additionalProperties && property.additionalProperties.type) {
  49. // 递归遍历
  50. type = convertType(property.additionalProperties);
  51. } else {
  52. // 任意object
  53. type = "{[key: string]: object}"
  54. }
  55. break;
  56. default:
  57. if (property.$ref) {
  58. const refType = convertRefType(property.$ref);
  59. if (refType) {
  60. type = refType;
  61. }
  62. }
  63. }
  64. // formatSchemaName 作用是对命名格式化,去除一些特殊符号
  65. return formatSchemaName(type);
  66. }

就可以生成所有的schema了。然后使用writeFileSync()写入到目标文件中。

2. 解析path

apifox数据结构是,先拿到api-tree,然后轮询id获取请求的request和response。所以第一步是拿到api-tree的数据,然后取出模块id轮询获取api接口的数据。
image.png
拿到数据之后,就是转换path文件。一个请求,最重要的就是请求参数和请求返回值。所以需要生成对应的Request和Response。正常情况下,传参有三种位置,path,query,body,path和query只能传递字符串,body一般是一个请求体(可以看作是一个schema),body的schema在前面生成的schema中可以找得到,所以直接引用就可以。(解析就是纯力气活,根据数据格式解析就完事了)

  1. /** 转换Path */
  2. const convertPaths = (item) => {
  3. let cacheApiName = [];
  4. const getApiName = createApiName(item.path, item.method);
  5. let pathsFileCotent = `
  6. \/**
  7. ** 接口名称: ${item.name}
  8. ** 接口地址: ${item.path}
  9. ** 请求方式: ${item.method}
  10. ** 接口描述: ${item.description}
  11. *\/
  12. namespace ${getApiName(cacheApiName)} {
  13. /** 请求 */
  14. interface Request ${convertRequstBody(item.requestBody)}{
  15. ${convertParameters(item.parameters)}
  16. }
  17. /** 响应 */
  18. interface Response ${convertResponse(item.responses)} {
  19. }
  20. }
  21. `
  22. return pathsFileCotent;
  23. }
  24. /** 转换body参数 */
  25. function convertRequstBody(requestBody) {
  26. if (!requestBody || requestBody.type === "none") {
  27. return "";
  28. }
  29. if (requestBody.type === "application/json") {
  30. const bodyRef = requestBody.jsonSchema.$ref;
  31. const bodySchemaName = convertRefType(bodyRef)
  32. if (bodySchemaName) {
  33. return `extends Api.Schema.${bodySchemaName}`;
  34. }
  35. }
  36. return ""
  37. }
  38. // convertParameters 略

解析response更简单了,一般返回值都是一个schema,直接把这个schema与前面的schema对应起来即可

  1. function convertResponse(responses) {
  2. const successRes = responses.find(item => item.name === "OK");
  3. const resRef = successRes.jsonSchema.$ref || "";
  4. const resSchemaName = convertRefType(resRef)
  5. if (resSchemaName) {
  6. return `extends Api.Schema.${resSchemaName} `;
  7. }
  8. return ""
  9. }

此时可以生成所有接口的paths文件了。然后使用writeFileSync()写入到目标文件中。

3. 生成service文件

此时已经拥有了schema和paths文件,可以在项目里实际使用了。但经过实践发现,service文件也可以通过一定规律去生成,就不用那么麻烦去写接口代码了。

  1. function convertServices(item) {
  2. let cacheApiName = [];
  3. const getApiName = createApiName(item.path, item.method);
  4. const apiName = getApiName(cacheApiName);
  5. const servicesFileCotent = `
  6. \/**
  7. ** 接口名称: ${item.name}
  8. ** 接口地址: ${item.path}
  9. ** 请求方式: ${item.method}
  10. ** 接口描述: ${item.description}
  11. *\/
  12. export function ${apiName} (params: Api.Paths.${apiName}.Request) {
  13. return request<Api.Paths.${apiName}.Response>(
  14. \`${item.path.replace(/[{]/g, "${params.")}\`,
  15. {
  16. method: "${item.method.toUpperCase()}",
  17. ${["GET", "DELETE"].includes(item.method.toUpperCase()) ? "params," : "data: params,"}
  18. }
  19. );
  20. }
  21. `;
  22. return servicesFileCotent;
  23. }

生成的代码如前面展示的结果图。

收获

开发体验更友好(更好地摸鱼)。