一个由 apifox 的接口文档生成 typescript 声明的小工具,记录一下开发过程。
仓库代码:https://github.com/JexLau/apifox-to-ts
背景
项目想要接入 typescript 开发,但维护接口的 typings 是一个繁琐且重复的过程,在观察后端提供的接口文档(基于 apifox 平台),发现它的数据构成有一定的规律,且后端的接口规范良好,得益于在前司学到的经验,决定写一个脚本工具来对接口文档自动生成接口 typings。
当然,apifox也提供了生成typings工具,但我觉得可能存在两个缺陷:
- 对于枚举类型的处理,在实际开发中,有些枚举书写不规范转换会报错,个人觉得还是把它处理成联合类型更好。
- apifox 生成的代码, schema 和接口层混杂在一起,项目庞大时比较混乱,按模块生成对应的 schema 和 path 会更清晰,更容易维护。
结果
为了更清晰地感知这个工具的作用,把结果放在前面。第一个文件是service.ts文件,也就是项目中可以直接引入使用的接口文件。第二个文件是schema文件,包含了项目所有的schema。第三个文件是path文件,包含了项目所有的请求。将这三个文件复制到项目中,可直接使用。后续接口有更新,执行脚本也可一键生成,不必费力维护接口的typings。
眼尖的小伙伴当然发现了,目前项目还没有根据模块生成对应的文件,原因是因为在开发过程中,发现模块名称是中文,我不愿意根据中文生成模块,所以暂时不做。
开发过程
打开一个 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,我放个具体的截图感受一下。左边是整体的截图,右边是某一项的截图。
它是一个数组,每个数组项就是一个 schema,所谓的 schema 就是一个对象,解析 schema 就是解析这个对象里面的属性。解析属性,首先需要知道这个属性是什么类型,我们可以先把所有的 type 打印出来看一下。
// 抽离schemas
const schemasUrl = "https://www.apifox.cn/api/v1/shared-docs/xxxx/data-schemas"
axios.get(schemasUrl).then(res => {
const schemasData = res.data.data;
console.log(`**************成功请求 schemas 数据**************`);
// 处理schema
// 先观察一下schema有多少种type
const types = []
schemasData.forEach(item => {
const properties = item.jsonSchema.properties;
if (properties) {
for (let p in properties) {
const type = properties[p].type
if(types.indexOf(type) === -1) {
types.push(type)
}
}
}
})
console.log(types.join(",")) // string,array,integer,,object,boolean
})
打印出来的结果是 string,array,integer,,object,boolean。注意,里面有一个空值,待会我们可以打印一下空值的jsonSchema是什么情况。string, integer,boolean都是简单类型,可以直接转换为typescript里面对应的string, number,boolean类型,但对于object,array和空值,我们需要额外去处理它。那么我们先看一下这三种类型是什么情况:
这是它们的结构,可以看到这三者的结构是不一样的,可以根据这些数据大概抽象出它们的interface。而 { ‘$ref’: ‘#/definitions/5227172’ } 这种结构,意思是它的类型对应的是 id 为 5227172 的 schema。
interface SchemaArray {
type: string, // 目前发现有 'array' | 'object' 这两个类型的值
example?: string[],
description?: string,
items: {
type?: string, // 简单类型
$ref?: string, // 链接另外一个scheme,和type互斥
}
}
interface SchemaObject {
type: string,
example?: {},
description?: string,
additionalProperties: {
type?: string, // 'object'
}
}
interface SchemaNull {
$ref?: string, // 链接另外一个scheme
}
理清了这些类型关系,我们可以对这个schema对象数组简单做一个解析,解析的结果就是每一个 schema 数据对应的 interface/type。
解析类型
经过上面的过程,我们知道有 string,array,integer,,object,boolean 这几种类型。通过观察数据,发现type为string其中还有一种情况,就是枚举enum。所以我们首先要先把schema-data遍历一次,生成所有的枚举类型(方便后面引用)。
for (let key in properties) {
const property = properties[key]
if (property.enum) {
// schemaTitle充当一个前缀的作用,防止枚举重命名
const enumName = schemaTitle + firstToLocaleUpperCase(key)
const description = property.description || ""
result += `
/** ${description} */
type ${enumName} = ${handleEnumType(property.enum)}`
}
}
处理完枚举类型,然后再遍历一次,根据对应的type生成typescript的代码。
schemasData.forEach(item => {
const properties = item.jsonSchema.properties;
const required = item.jsonSchema.required;
const description = item.jsonSchema.description || "";
const schemaTitle = formatSchemaName(item.jsonSchema.title);
result += `
/** ${description} */
interface ${schemaTitle} {${handleAllType(properties, required, schemaTitle)}
}`
})
/** 转换类型 */
const convertType = function (property, key, schemaTitle = "") {
let type = "未知";
switch (property.type) {
case "string":
if (property.enum) {
const enumType = schemaTitle + firstToLocaleUpperCase(key)
type = enumType
} else {
type = "string"
};
break;
case "boolean":
type = "boolean";
break;
case "integer":
type = "number";
break;
case "number":
type = "number";
break;
case "array":
if (property.items.type) {
let itemType = property.items.type;
if (itemType === "integer") {
type = `Array<number>`;
} else {
type = `Array<${itemType}>`;
}
} else if (property.items.$ref) {
const refType = convertRefType(property.items.$ref);
if (refType) {
type = `Array<${refType}>`;
}
}
break;
case "object":
if (property.additionalProperties && property.additionalProperties.type) {
// 递归遍历
type = convertType(property.additionalProperties);
} else {
// 任意object
type = "{[key: string]: object}"
}
break;
default:
if (property.$ref) {
const refType = convertRefType(property.$ref);
if (refType) {
type = refType;
}
}
}
// formatSchemaName 作用是对命名格式化,去除一些特殊符号
return formatSchemaName(type);
}
就可以生成所有的schema了。然后使用writeFileSync()写入到目标文件中。
2. 解析path
apifox数据结构是,先拿到api-tree,然后轮询id获取请求的request和response。所以第一步是拿到api-tree的数据,然后取出模块id轮询获取api接口的数据。
拿到数据之后,就是转换path文件。一个请求,最重要的就是请求参数和请求返回值。所以需要生成对应的Request和Response。正常情况下,传参有三种位置,path,query,body,path和query只能传递字符串,body一般是一个请求体(可以看作是一个schema),body的schema在前面生成的schema中可以找得到,所以直接引用就可以。(解析就是纯力气活,根据数据格式解析就完事了)
/** 转换Path */
const convertPaths = (item) => {
let cacheApiName = [];
const getApiName = createApiName(item.path, item.method);
let pathsFileCotent = `
\/**
** 接口名称: ${item.name}
** 接口地址: ${item.path}
** 请求方式: ${item.method}
** 接口描述: ${item.description}
*\/
namespace ${getApiName(cacheApiName)} {
/** 请求 */
interface Request ${convertRequstBody(item.requestBody)}{
${convertParameters(item.parameters)}
}
/** 响应 */
interface Response ${convertResponse(item.responses)} {
}
}
`
return pathsFileCotent;
}
/** 转换body参数 */
function convertRequstBody(requestBody) {
if (!requestBody || requestBody.type === "none") {
return "";
}
if (requestBody.type === "application/json") {
const bodyRef = requestBody.jsonSchema.$ref;
const bodySchemaName = convertRefType(bodyRef)
if (bodySchemaName) {
return `extends Api.Schema.${bodySchemaName}`;
}
}
return ""
}
// convertParameters 略
解析response更简单了,一般返回值都是一个schema,直接把这个schema与前面的schema对应起来即可
function convertResponse(responses) {
const successRes = responses.find(item => item.name === "OK");
const resRef = successRes.jsonSchema.$ref || "";
const resSchemaName = convertRefType(resRef)
if (resSchemaName) {
return `extends Api.Schema.${resSchemaName} `;
}
return ""
}
此时可以生成所有接口的paths文件了。然后使用writeFileSync()写入到目标文件中。
3. 生成service文件
此时已经拥有了schema和paths文件,可以在项目里实际使用了。但经过实践发现,service文件也可以通过一定规律去生成,就不用那么麻烦去写接口代码了。
function convertServices(item) {
let cacheApiName = [];
const getApiName = createApiName(item.path, item.method);
const apiName = getApiName(cacheApiName);
const servicesFileCotent = `
\/**
** 接口名称: ${item.name}
** 接口地址: ${item.path}
** 请求方式: ${item.method}
** 接口描述: ${item.description}
*\/
export function ${apiName} (params: Api.Paths.${apiName}.Request) {
return request<Api.Paths.${apiName}.Response>(
\`${item.path.replace(/[{]/g, "${params.")}\`,
{
method: "${item.method.toUpperCase()}",
${["GET", "DELETE"].includes(item.method.toUpperCase()) ? "params," : "data: params,"}
}
);
}
`;
return servicesFileCotent;
}
生成的代码如前面展示的结果图。
收获
开发体验更友好(更好地摸鱼)。