前端小工具:脚本拉取swagger文档
前端小工具:脚本拉取swagger文档
前后端分离,后端把接口API使用swagger文档展示给前端,前端又需要手动把swagger文档拷贝修改成前端可以调用的接口,几个接口都还好,一下子来个几十个接口,复制粘贴都成了问题。
总结一下问题:
1. 前端需要手动定义接口函数,配置文档,增加开发时间。
2. 拷贝文档接口,参数容易错乱异常,增加联调时间。
3. 前端文档不统一,不同项目,不同开发者,手动配置文档不一致,增加项目使用的复杂性。
现在项目的开发,很多时候后端都是会把一个需求的接口开发完成后,全部丢给前端,这样联调对于前端来说,时间非常紧凑。
swagger文档支持json结构的接口,一般都再文档的头部,没有得找后台去配置了。
基本思路就有了,通过swagger的json生成接口文件,通过node读取json文件生成API请求函数,页面直接调用就可以了。
需要支持的功能
1. 自动拉取swagger文档,生成配置文件
2. 支持多个swagger文档拉取。
3. 支持老版本,没有swagger文档的手动输入。
4. 构建生成前端请求接口函数
完成脚本拉取swagger文档,接口联调可以在后端释放接口文档的同时,直接进行接口联调了,为摸鱼又争取了一波时间。
上手代码构建:
新建swagger.lib目录,添加拉取文件generate.js,配置文件config.js
config.js :
// 默认server exports.SERVER = 'https://baidu.com/api'; exports.CONFIG = [ { swaggerUrl: 'http://baidu.com/v2/api-docs', // swagger文档 json格式路径, 为空代表本地自定义接口文档。 baseURL: 'https://baidu.com/api', // 接口api base路径 directory: 'record', // 区分文件目录名称,目录为空代表当前路径,对已经拉取的接口文档进行兼容。 FilePrefix: 'rd', // 支持拉取曾经带FilePrefix拉取的文档,新拉取建议添加,增加命名唯一性,但避免过长文件名。 apiStyle: '', // 区分当前文档的response返回头处理。 forceOverwrite: false // 强制覆盖,是否覆盖已经拉取的文件。 } ];
简单描述一下:
构建generate.js:
生成文件使用nunjucks去动态设置文件内容
// 文件头部内容 function getFileHeaderTmpl() { let fileHeaderTmpl = `// 该文档由脚本自动生成,请勿修改 // {{ description }} `; return fileHeaderTmpl; } // 文件接口描述 // ^ 减少eslint function getTmpl() { const tmpl = ` /** * {{ TagName }} * {{ description }} * {% for param in params %}@param (Request {{param.in}}) {% if param.required %}(Optional) {% endif %}{ {{param.type|default('object')}} } {{param.name}}{% if param.description %} {{param.description}}{% endif %} * {% if param.raw %}raw {^{% for raw in param.raw %} * {{raw.key}}: {{raw.type}}{% if raw.description %}// {{raw.description}}{% endif %}{% if raw.rawitems %} * [{^{% for rawitems in raw.rawitems %} * {{rawitems.key}}: {{rawitems.type}}{% if rawitems.description %}// {{rawitems.description}}{% endif %}{% endfor %} * }]{% endif %}{% endfor %} * }^{% endif %}{% endfor %}{@link {{docPath}}/{{ operationId }}}. */ exports.{{ functionName }} = { apiStyle: '{{apiStyle}}', server: '{{server}}', method: '{{ method }}', url: '{{ url }}', msg: '请求[{{description}}]出错', consumes: '{{consumes}}' }; `; return tmpl; }
先构建一下文件的结构,其中{%%}对等的,就是nunjucks的模板语法了,不懂得自行去了解一下。
构建generate函数,拉取和处理文件生成输入
通过配置的swagger文档路径拉取api的对象结构,通过转换成js,导出函数的形式,区分目录文件,通过nunjucks和node对文件的生成出来,完成在线swagger转换成本地js文件。
// 通过whistle控制流量到k8s开发环境 function generate(option) { const { swaggerUrl, directory, FilePrefix, apiStyle, forceOverwrite, baseURL: server } = option; const dirPath = path.resolve(__dirname, directory); const filePath = fs.existsSync(dirPath); if (!filePath) { fs.mkdirSync(dirPath); } if (!option.swaggerUrl) { return; } axios .get(swaggerUrl, { proxy: { host: '127.0.0.1', port: 8899 } }) .then(result => { if (result.data.swagger !== '2.0') { throw new Error('unknow support swagger version'); } const Specification = result.data; const BasePath = Specification.basePath; const DocPath = `http://${Specification.host}${BasePath}swagger-ui.html`; const ApiByTag = {}; for (let i = 0; i < Specification.tags.length; i++) { const CurrentTag = Specification.tags[i]; const TagName = CurrentTag.description .split(' Controller')[0] .replace(/\s+/g, ''); ApiByTag[TagName] = { fileName: `${FilePrefix}${uppperFirstChar(TagName)}.spec.js`, content: nunjucks.renderString(getFileHeaderTmpl(), { description: CurrentTag.name }) }; for (let [keyOfPaths, valueOfPaths] of Object.entries( Specification.paths )) { let isMatched = false; for (let [keyOfMethod, valueOfMethod] of Object.entries( valueOfPaths )) { if (valueOfMethod.tags[0] === CurrentTag.name) { isMatched = true; const operationId = valueOfMethod.operationId; const functionName = handleFuntionName(keyOfPaths); const renderString = nunjucks.renderString(getTmpl(), { server, apiStyle, functionName, operationId, TagName: `${FilePrefix}${uppperFirstChar(TagName)}`, description: valueOfMethod.summary, method: keyOfMethod, url: (BasePath + keyOfPaths).replace(/\/\//g, '/'), params: handleParameters( keyOfMethod, valueOfMethod.parameters, Specification.definitions ), docPath: DocPath + '#/' + CurrentTag.name, consumes: valueOfMethod.consumes }); ApiByTag[TagName].content += renderString; } } if (isMatched) { delete Specification.paths[keyOfPaths]; } } const pathOfFile = path.resolve(dirPath, ApiByTag[TagName].fileName); const fileExist = fs.existsSync(pathOfFile); if (fileExist && forceOverwrite === false) { console.warn(`file ${pathOfFile} exist, skiping`); } else { console.warn(`generating file: ${pathOfFile}`); fs.writeFileSync(pathOfFile, ApiByTag[TagName].content); } } }) .catch(e => { console.error(e); }); } CONFIG.forEach(i => generate(i));
其中axios中的proxy就是whistle的配置了,不了解的可以去看看,whistle本地代理。
对于唯一的函数名,是已路径匹配的,同一个文件下,函数名唯一,同时需要对于raw结构的参数需要单独处理,一次拉取就不需要对swagger文档依赖,之后看本地文件就可以了。
function uppperFirstChar(string) { return string.charAt(0).toUpperCase() + string.slice(1); } // 处理具体参数描述 function handleRefItems(items, option) { const { $ref: refItems } = items; const raw = []; if (refItems) { const definitions = refItems.split('#/definitions/')[1].trim(); const { properties } = option[definitions]; for (let [keyParam, valueParam] of Object.entries(properties)) { if (valueParam.type === 'array' && valueParam.items) { valueParam['rawitems'] = handleRefItems(valueParam.items, option); } raw.push({ ...valueParam, key: keyParam }); } return raw; } raw.push({ ...items, key: 0 }); return raw; } // 仅是post请求,带raw数据的参数进行处理 function handleParameters(method, params, option) { if (method !== 'post' || !params) { return params; } const newParams = []; params.forEach(item => { if (item.schema && Object.keys(item.schema).includes('$ref')) { item['raw'] = handleRefItems(item.schema, option); } newParams.push(item); }); return newParams; } // 采用访问路径定义前端函数名 function handleFuntionName(url) { let newUrl = url.replace(/\/\//g, '/').replace(/({[^}]+})|(_)|(-)/g, ''); let a = newUrl.split('/'); let result = a[0]; for (let i = 1; i < a.length; i++) { result = result + a[i].slice(0, 1).toUpperCase() + a[i].slice(1); } return result; }
需要导入对于的依赖包,如axios, nunjucks, path, fs, config等
配置完文件后,直接通过node进行调试,也可以配置到package里面通过npm去启动。
这样,拉取swagger文档就已经完成了。
拉取的文件大概就是长成这个样:
/** * rdAuth * 判断当前用户是否拥有权限key * @param (Request path) (Optional) { string } key key * {@link http://baidu.com/swagger-ui.html#/权限相关接口/menusUsingGET_1}. */ exports.AuthPermission = { apiStyle: '', server: 'https://baidu.com/api', method: 'get', url: '/auth/permission/{key}', msg: '请求[判断当前用户是否拥有权限key]出错', consumes: '' };
接口的描述,文件的划分是比较清晰明了的,一般需要的参数,调用的的方法也是全面的,拉取完成后基本不依赖后端的swagger文档了。
文件有了,当然到现在还不能通过前端直接调用,文件导出来生成request函数,封装起来构建成前端请求的API对象就可以了。
先构建个request文件,跟普通的请求封装没有太多区别,直接上代码了:
const { Toast } = require('antd-mobile');
const axios = require('axios').default;
const isServer = typeof window === 'undefined';
const requestInterceptor = config => {
config.params = config.params || {};
if (config.method.toUpperCase() === 'GET') {
config.params = {
...config.params,
...config.data
};
}
config.params.md = Math.random();
return config;
};
const requestInterceptorError = error => {
return Promise.reject(error);
};
const responseInterceptor = response => {
if (!response.data || (response.data && !response.data.success)) {
log.warn(
`request ${response.config.url} faild: ${JSON.stringify(response.data)}`
);
}
if (typeof response.data !== 'object') {
return {
success: false,
msg: 'ERROR_EMPTY_BODY'
};
}
if (!response.data.success && typeof response.data.msg !== 'string') {
response.data.msg = 'ERROR_UNKNOWN_REASON';
return response;
}
return response;
};
const responseInterceptorError = (error = {}) => {
if (error.request) {
log.warn(`request ${error.request.path} faild: ${error.toString()}`);
} else {
log.error(error);
}
if (
error.message.includes('timeout') ||
error.message.includes('Network Error')
) {
error.data = {
success: false,
msg: 'ERROR_REQUEST_TIMEOUT'
};
}
error.data = {
success: false,
msg: 'ERROR_REQUEST_FAILD'
};
return error;
};
const emptyFunction = () => {};
module.exports = class Request {
constructor({
apiStyle= 'cps',
requestHandle,
requestErrorHandle,
responseHandle,
responseErrorHandle
}) {
const instance = axios.create({
timeout: 90000,
withCredentials: true,
headers: {
'content-type': 'application/json;charset=utf-8',
'X-Requested-With': 'XMLHttpRequest'
},
// 覆盖掉外面的全局axios配置
baseURL: ''
});
instance.interceptors.request.use(
requestInterceptor,
requestInterceptorError
);
if (
typeof requestHandle === 'function' ||
typeof requestErrorHandle === 'function'
) {
instance.interceptors.request.use(
requestHandle || emptyFunction,
requestErrorHandle || emptyFunction
);
}
instance.interceptors.response.use(
responseInterceptor,
responseInterceptorError
);
if (
typeof responseHandle === 'function' ||
typeof responseErrorHandle === 'function'
) {
instance.interceptors.response.use(
responseHandle || emptyFunction,
responseErrorHandle || emptyFunction
);
}
return instance;
}
};
有特别的请求头,响应头处理,这里也可以稍微了解,添加一下特定的处理方式就可以了。
接下来就是主要导出的文件:新建index.js
const Request = require('./request.util');
const { CONFIG, SERVER } = require('./const');
const { isVerbose } = require('../../utils/env.util');
const ServiceSpecsMap = new Map();
const isServer = typeof window === 'undefined';
// 允许客户端透传的header
const AllowRequestHeaderKeys = ['cookie'];
// 允许透传回客户端的header
const AllowResponseHeaderKeys = ['set-cookie'];
// 单例
let ServiceSingleton = null;
function getServiceSpecsMap(option) {
const directory = option.directory ? option.directory + '/' : '';
if (isServer) {
// 扫描文件夹下的所有定义文件
require('fs')
.readdirSync('./libs/service.lib/' + directory)
.forEach(file => {
if (file.endsWith('.spec.js')) {
const specName = file.split('.spec.js')[0];
try {
const specObj = require('./' + directory + specName + '.spec.js');
const keysOfSpec = Object.keys(specObj);
if (keysOfSpec.length === 0) {
throw new Error('spec file not found any api definition');
}
// 缓存到map中
ServiceSpecsMap.set(
specName,
Object.assign({}, ServiceSpecsMap.get(specName), specObj)
);
} catch (e) {
log.warn(`load spec ${file} faild.`);
}
}
});
} else {
// 使用Webpack require.context 动态引入所有符合后缀的服务描述文件
const path = './' + directory;
const ServicesSpecModules = require.context('./', true, /\.spec\.js$/);
let reg = new RegExp(path);
ServicesSpecModules.keys().forEach(key => {
// 名字转key
if (reg.test(key)) {
const specName = key.replace(path, '').replace('.spec.js', '');
// 所有目录下,specName和文件中的exports Name不允许完全一致。
ServiceSpecsMap.set(
specName,
Object.assign(
{},
ServiceSpecsMap.get(specName),
ServicesSpecModules(key)
)
);
}
});
}
}
CONFIG.forEach(i => getServiceSpecsMap(i));
const errorHandle = (result, ignoreError = false) => {
if (!isServer && typeof result === 'object' && !result.success) {
// 提示弹窗
let ErrorMsg = result.msg || `response.data:${JSON.stringify(result.data)}`;
if (isVerbose) {
ErrorMsg += `(${result.exception || '无更多错误信息'})`;
}
const { Toast } = require('antd-mobile');
if (!ignoreError) {
Toast.fail(`[内部错误]${ErrorMsg}`, 2);
}
}
};
const responseHandleFactory = spec => response => {
const { data, headers, config } = response;
if (isServer) {
// 如果是服务端渲染,需要回写cookie到浏览器
const CLSUtil = require('../CLS.lib');
// Cannot convert undefined or null to object
if (headers) {
CLSUtil.setResponseHeaders(headers, AllowResponseHeaderKeys);
// 因为某些接口在node端调用需要依赖前一个接口的cookie值,所以把接口响应返回的cookie写入req中
CLSUtil.setRequestHeaders(headers, AllowResponseHeaderKeys);
}
}
if (typeof data === 'object') {
if (data.success) {
return data;
}
switch (data.msg) {
case 'ERROR_EMPTY_BODY':
data.msg = '[返回数据为空]';
break;
case 'ERROR_UNKNOWN_REASON':
data.msg = '[未知错误]';
break;
case 'ERROR_REQUEST_TIMEOUT':
data.msg = '[请求超时]';
break;
case 'ERROR_REQUEST_FAILD':
data.msg = '[请求失败]';
break;
default:
}
// 调试环境把接口定义文件中的提示也输出
if (isVerbose) {
data.msg = `${data.msg}${spec.msg}`;
}
}
// 这里不做await,是因为堵塞了,会导致外面的loading一直在转,但是这里有需要弹出登录框,交互有冲突
// Cannot read property 'ignoreError' of undefined
errorHandle(data, config && config.ignoreError);
return data;
};
function requestWithSpec(spec, data, options = {}) {
if (spec.server) {
options.baseURL = spec.server;
} else {
options.baseURL = SERVER;
}
let apiStyle = spec.apiStyle;
// 如果是服务端去CLS中获取调用链上设置的header
if (isServer) {
const CLSUtil = require('../CLS.lib');
// options.baseURL = CLSUtil.getBaseURL() || baseURL;
const serverRequestHeaders = CLSUtil.getRequestHeaders(
AllowRequestHeaderKeys
);
options.headers = Object.assign(
options.headers || {},
serverRequestHeaders
);
}
if (spec.consumes) {
options.headers = Object.assign(options.headers || {}, {
'content-type': spec.consumes
});
}
const responseHandle = responseHandleFactory(spec);
const request = new Request({
responseHandle: responseHandle,
responseErrorHandle: responseHandle,
apiStyle
});
let requestUrl = spec.url;
// 支持URL参数
if (typeof options.urlParams === 'object') {
Object.keys(options.urlParams).map(key => {
requestUrl = requestUrl.replace(`:${key}`, options.urlParams[key]);
});
}
//如果查询参数直接是在请求地址后面 /picture/12
let urlReg = /(\{.+?\})/g;
if (urlReg.test(requestUrl)) {
let newData = JSON.parse(JSON.stringify(data));
let newUrl = requestUrl.replace(urlReg, function() {
return newData[Object.keys(newData)[0]];
});
requestUrl = newUrl;
}
return request({
method: spec.method,
url: requestUrl,
data: data,
...options
});
}
// 并行发起请求
const parallel = async requestList => {
if (Array.isArray(requestList) === false) {
throw new Error('service parallel must accept Array as params.');
}
return await Promise.all(requestList);
};
class Service {
constructor() {
if (ServiceSingleton) {
return ServiceSingleton;
}
ServiceSingleton = {
parallel
};
ServiceSpecsMap.forEach((value, key) => {
const ServiceRequest = {};
const ServiceSpecInstance = value;
Object.keys(ServiceSpecInstance).forEach(requestName => {
ServiceRequest[requestName] = (data, options = {}) => {
const RequestSpec = ServiceSpecInstance[requestName];
return requestWithSpec(RequestSpec, data, options);
};
});
ServiceSingleton[key] = ServiceRequest;
});
return ServiceSingleton;
}
}
/**
* @param data raw 参数
* @param option header等配置
*/
const ServiceInstance = new Service();
module.exports = ServiceInstance;
部分对于服务端调用接口,都cookie的处理需要添加一下工具
const cls = require('cls-hooked');
const CLS_NAMESPACE = 'CLS_NAMESPACE';
module.exports.getBaseURL = (url) => {
const ns = cls.getNamespace(CLS_NAMESPACE);
if (ns) {
try {
const baseURL = 'url';//ns.get('tenant').baseURL;
return baseURL;
} catch (error) {
console.log(error)
}
}
};
// 获取客户端传过来的header
module.exports.getRequestHeaders = (allowed = []) => {
const ns = cls.getNamespace(CLS_NAMESPACE);
if (ns) {
const headers = ns.get('headers');
if (headers) {
// 注意这里是区分大小写的
return Object.keys(headers)
.filter(key => allowed.includes(key))
.reduce((obj, key) => {
obj[key] = headers[key];
return obj;
}, {});
}
}
return {};
};
module.exports.setResponseHeaders = (headers, allowed = []) => {
const ns = cls.getNamespace(CLS_NAMESPACE);
if (ns) {
const expressSetResHeader = ns.get('expressSetResHeader');
if (typeof expressSetResHeader === 'function') {
// 注意这里是区分大小写的
return Object.keys(headers).map(key => {
if (allowed.includes(key)) {
// 把header回写到浏览器
expressSetResHeader(key, headers[key]);
}
});
}
}
return {};
};
module.exports.setRequestHeaders = (headers, allowed = []) => {
const ns = cls.getNamespace(CLS_NAMESPACE);
if (ns) {
const expressSetReqHeader = ns.get('expressSetReqHeader');
if (typeof expressSetReqHeader === 'function') {
// 遍历响应的headers
return Object.keys(headers).map(key => {
if (allowed.includes(key)) {
// 把header回写到req
expressSetReqHeader(
key === 'set-cookie' ? 'cookie' : key,
headers[key]
);
}
});
}
}
return {};
};
到这来基本所有的代码配置都完成的了。
页面的使用,直接按文件路径+导出的命称就可以了
import * as Service from 'libs/swagger.lib'; Service.OldCustom.smallbore(fromData, { headers: { 'Content-Type': 'multipart/form-data', }, }).then(res => { console.log(res) });
拉取swagger是时候,可以把命令配置到package.json里面的scripts去
"swagger": "node libs/swagger.lib/generate.js"
通过npm run swagger就可以拉取了。
脚本拉取swagger文档,就完成了。
没有终点,没有彼岸,坚持就好,愿岁月如初

浙公网安备 33010602011771号