前言
在 ts 成为前端的日常的情况下,带来的好处自不必多说。但随之而来需要编写各种类型定义,尤其是每次当接口变更,我们不得不手动更改对应的 ts 类型。
也许你已经使用 VSCode 的插件 json2ts,来减少手动编写 ts,但总体依旧是开发效率的降低。
这种简单机械的重复劳动,与业务无关的重复行为。这类工程化的问题,要如何解决呢?
swagger-typescript-api 提供了通过 swagger 生成 api 的方案,支持 OpenAPI 3.0 和 2.0(Swagger) 以及文件格式json,yaml
swagger-typescript-api 的代码是使用模板生成,模板引擎可以选择 EJS 或 Eta。(下面选择 EJS)
EJS 是一套简单的模板语言,没有如何组织内容的教条,也没有再造一套迭代和控制流语法,有的只是普通的 JavaScript 代码而已。刚接触的话,也没有关系,因为很快就能上手。
文末会有 npm 包可用!
swagger-typescript-api
实际使用中,swagger-typescript-api 默认的输出结构,很少能直接满足我们的需求。
所以我们会用到自定义模板,需要复制一些 swagger-typescript-api 的模板 到项目内:
-
/templates/default 内的模板,会把所有内容写入到一个文件内。
-
/templates/modular 内的模板,会把 http client, data contracts, routes 分别单独生成文件。(我们自然是选择这个👈)
模板文件的介绍
-
api.ejs 是用来生成 API 类模块的文件。
-
procedure-call.ejs 作为 api.ejs 的子模板,用来生成类中的方法(也就是对应后端的路由)被 api.ejs 引用。
-
route-docs.ejs 主要是生成 API 类中路由的注释文档,在 procedure-call.ejs 内被引用。
-
route-name.ejs Api 类中路由的路由名。
-
data-contracts.ejs 生成来自 Swagger 架构的所有类型(也就是接口请求参数和返回参数的 ts 类型)。
-
http-client.ejs 根据 axios 还是 fetch,选择对应的客户端 Http 请求 ts 的模板。
自定义模板
下面是我对 umi4 内置的请求模板。(只使用到 2 个自定义模板,放到项目内的 swaggerTemplate 文件夹内)
api.ejs
<%
const { utils, route, config, modelTypes } = it;
const { _, pascalCase, require } = utils;
const apiClassName = pascalCase(route.moduleName);
const routes = route.routes;
const dataContracts = _.map(modelTypes, "name");
%>
<% if (dataContracts.length) { %>
import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>"
<% } %>
import { HttpClient, RequestParams, ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>";
import { request, RequestConfig } from "./request";
class <%= apiClassName %>Service{
<% for (const route of routes) { %>
<%~ includeFile('./procedure-call.ejs', { ...it, route }) %>
<% } %>
}
export default <%= apiClassName %>Service;
procedure-call.ejs
<%
const { utils, route, config } = it;
const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route;
const { _, getInlineParseContent, getParseContent, parseSchema, getComponentByRef, require } = utils;
const { parameters, path, method, payload, query, formData, security, requestParams } = route.request;
const { type, errorType, contentTypes } = route.response;
const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants;
const routeDocs = includeFile("@base/route-docs", { config, route, utils });
const queryName = (query && query.name) || "query";
const pathParams = _.values(parameters);
const pathParamsNames = _.map(pathParams, "name");
const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH;
const requestConfigParam = {
name: "options",
optional: true,
type: "RequestConfig",
defaultValue: "{}",
}
const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`;
const rawWrapperArgs = config.extractRequestParams ?
_.compact([
requestParams && {
name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName,
optional: false,
type: getInlineParseContent(requestParams),
},
...(!requestParams ? pathParams : []),
payload,
requestConfigParam,
]) :
_.compact([
...pathParams,
query,
payload,
requestConfigParam,
])
const wrapperArgs = _
// Sort by optionality
.sortBy(rawWrapperArgs, [o => o.optional])
.map(argToTmpl)
.join(', ')
// RequestParams["type"]
const requestContentKind = {
"JSON": '"application/json"',
"URL_ENCODED": '"application/x-www-form-urlencoded"',
"FORM_DATA": '"multipart/form-data"',
"TEXT": '"text/plain"',
}
// RequestParams["format"]
const responseContentKind = {
"JSON": '"json"',
"IMAGE": '"blob"',
"FORM_DATA": isFetchTemplate ? '"formData"' : '"document"'
}
const bodyTmpl = _.get(payload, "name") || null;
const queryTmpl = (query != null && queryName) || null;
const bodyContentKindTmpl = requestContentKind[requestBodyInfo.contentKind] || null;
const responseFormatTmpl = responseContentKind[responseBodyInfo.success && responseBodyInfo.success.schema && responseBodyInfo.success.schema.contentKind] || null;
const securityTmpl = security ? 'true' : null;
%>
/**
<%~ routeDocs.description %>
*<% /* Here you can add some other JSDoc tags */ %>
<%~ routeDocs.lines %>
*/
static <%~ route.routeName.usage %> = (<%~ wrapperArgs %>) =>
request<<%~ type %>>(`<%~ path %>`,{
method: '<%~ _.upperCase(method) %>',
<%~ queryTmpl ? `params: ${queryTmpl},` : '' %>
<%~ bodyTmpl ? `data: ${bodyTmpl},` : '' %>
<%~ securityTmpl ? `secure: ${securityTmpl},` : '' %>
<%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %>
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
...<%~ _.get(requestConfigParam, "name") %>,
})
生成 API
generateApi.js
const { generateApi } = require('swagger-typescript-api');
const path = require('path');
generateApi({
output: path.resolve(process.cwd(), './src/services'),
url: "http://11.168.x.xxxx:6666/v2/api-docs",
httpClientType: false,
modular: true,
templates: path.resolve(process.cwd(),'./swaggerTemplate'),
hooks: {
onCreateRoute: (routeData) => {
//增加接口请求前缀
routeData.request.path = `/api${routeData.request.path}`;
},
onFormatRouteName: (routeInfo, templateRouteName) => {
//自定义路由名称
return templateRouteName.replace(/Using\w*/, '').replace(/[{}]/, '');
},
},
});
只要执行以下命令:
node generateApi.js
就会生成对应的 API 模块。
CLI 工具
为了避免新项目需要重新配置,甚至是复制模板,为了解决这个场景,我们下面写个 CLI。
关于如何写 CLI,我之前写过 前端工程化之—— CLI 脚手架(两步掌握核心)这一篇,关键点是一样的,所以下面直接给出源码。
目录结构
main.js
#! /usr/bin/env node
import { Command } from "commander";
import swaggerTsApi from "../lib/swaggerTsApi.js";
const program = new Command();
program
.command("swaggerTsApi")
.description("通过 swagger-typescript-api 生成 api")
.requiredOption("-u, --url <http>", "获取 swagger.json 的 url")
.option("-p, --proxy <string>", "proxy 前缀", "/api")
.option("-o, --output <string>", "api 生成路径", "./src/services")
.option("-r, --reserve", "swagger 自动生成的模板是否保留", false)
.option("-t, --templatePath <string>", "使用指定的 swagger 模板路径")
.action(({ url, proxy, output, reserve, templatePath }) => {
swaggerTsApi({ url, proxy, output, reserve, templatePath });
});
program.parse();
swaggerTsApi.js
import path from "path";
import fs from "fs-extra";
import { fileURLToPath } from "url";
import { dirname } from "path";
import pkg from "swagger-typescript-api";
const { generateApi } = pkg;
export default async function swaggerTsApi({
url,
proxy,
output,
reserve,
templatePath,
}) {
try {
const __dirname = dirname(fileURLToPath(import.meta.url));
if (!templatePath) {
fs.copySync(
path.resolve(__dirname, "./swaggerTemplate"),
path.resolve(process.cwd(), "./swaggerTemplate")
);
}
generateApi({
output: path.resolve(process.cwd(), output),
url: url,
httpClientType: false,
modular: true,
templates: path.resolve(
process.cwd(),
templatePath ?? "./swaggerTemplate"
),
hooks: {
onCreateRoute: (routeData) => {
//增加接口请求前缀
routeData.request.path = `${proxy}${routeData.request.path}`;
},
onFormatRouteName: (routeInfo, templateRouteName) => {
//自定义路由名称
return templateRouteName.replace(/Using\w*/, "").replace(/[{}]/, "");
},
},
}).finally(() => {
if (!reserve && !templatePath) {
fs.remove("swaggerTemplate");
}
});
} catch (e) {
if (!reserve && !templatePath) {
fs.remove("swaggerTemplate");
}
console.error(e);
}
}
你可能会遇到的问题
- 生成的类型名重复
export interface ChestnutVO {
list?: ChestnutVO[];
}
export interface ChestnutVO {
id: number;
}
当一个接口出现类型重复,就像上面 ChestnutVO 重复这样子。
问题在于后端在@ApiModel的value属性指定了模型的名称,用了中文。
@ApiModel("栗子")
/** 改写成如下就可以了**/
@ApiModel(description = "栗子")
导致解析的时候,类型名字被替换成了中文去拼接,而中文又不对,被过滤掉了,所以导致出现了多个类型定义重复。
下面就是修复后的结果例子:
export interface PageResultChestnutVO {
list?: ChestnutVO[];
}
export interface ChestnutVO {
id: number;
}
- 当你在 mac 系统上用 yarn 安装后,使用时会出现以下这个报错:
env: node\r: No such file or directory
导致这个问题出现的原因,是不同平台系统之间标准的不同。在 vs code 右下角你可以看到 LF 或 CRLF ,而这个就是不同标准的行尾。
当然,如果你使用的是 npm 安装,不会出现这个错误,因为 npm 会自动处理行尾。
说回目前如何暂时解决,可以执行以下这个命令,去修复目前安装到本地的包:
npx crlf --set=LF node_modules/.bin/swagger
当然,后续我会增加 .gitattributes 对行尾进行规范。
* text=auto eol=lf
最后
swagger-typescript-api 的文档写的确实不怎么样,好在花点精力,还是能搞出来的,然后实现效果还算不错。
然后发布到 npm 上了,叫 swagger-ts-cli(如需加功能,可留言)
可直接执行下面的命令,不用安装尝试自动生成接口。
npx swagger-ts-cli -u http://11.168.3.186:6060/v2/api-docs
-u 后面的地址是 swagger 文档获取数据的 url。
生成的接口如何,取决于后端代码是否写的规范,这个工程规范化的事情,有时候会需要领导的支持。
最后,在此提供另一个方案的选择: pont —— 阿里生成前端接口层代码的方案,还有专门的 vscode 插件支持。(目前只支持 Swagger V2、V3)