前端小工具:脚本拉取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 :
简单描述一下:
构建generate.js:
生产文档使用nunjucks去动态设置文件内容
function getFileHeaderTmpl() { let fileHeaderTmpl = `// 该文档由脚本自动生成,请勿修改 // {{ description }} `; return fileHeaderTmpl; } 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}} {{param.description}} * {% if param.raw %}raw { {% for raw in param.raw %} * {{raw.key}}: {{raw.type}} {% if raw.description %}// {{raw.description}} {% endif %} {% endfor %} * } {% endif %}{% endfor %} * {@link {{docPath}}/{{ functionName }}}. */ exports.{{ functionName }} = { server: '{{server}}', method: '{{ method }}', url: '{{ url }}', msg: '请求[{{description}}]出错', consumes: '{{consumes}}' }; `; return tmpl; }
先构建一下文件的结构,其中{%%}等的,就是nunjucks的模板语法了,不懂得自行去了解一下。
构建generate函数,拉取和处理文件生成输入
function generate(option) { if (!option.swaggerUrl) { return; } const { swaggerUrl, fileName, baseURL: server } = option; 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: `${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 functionName = valueOfMethod.operationId; const renderString = nunjucks.renderString(getTmpl(), { server, TagName, description: valueOfMethod.summary, functionName, 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 dirPath = path.resolve(__dirname, fileName); const filePath = fs.existsSync(dirPath); if (!filePath) { fs.mkdirSync(dirPath); } 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); }); }
其中axios中的proxy就是whistle的配置了,不了解的可以去看看,whistle本地代理
对于raw结构的参数需要单独处理
function uppperFirstChar(string) { return string.charAt(0).toUpperCase() + string.slice(1); } function handleParameters(method, params, option) { if (method === 'get' || !params) { return params; } const newParams = []; params.forEach((item) => { if (item.schema && Object.keys(item.schema).includes('$ref')) { const { $ref: refPath } = item.schema; const definitions = refPath.split('#/definitions/')[1].trim(); const { properties } = option[definitions]; const raw = []; for (let [keyParam, valueParam] of Object.entries(properties)) { raw.push({ ...valueParam, key: keyParam, }); } item['raw'] = raw; } newParams.push(item); }); return newParams; }
这样,拉取swagger文档就已经完成了。
拉取的文件大概就是长成这个样:
文件有了,然后就把文件导出来生成前端请求的API就可以了。
先构建个request文件,跟普通的请求封装没有太多区别,直接上代码了:
const requestInterceptor = config => { config.params = config.params || {}; if (config.method.toUpperCase() === 'GET') { config.params = { ...config.params, ...config.data }; } config.params.md = Math.random(); log.info(`request [${config.method}] ${config.baseURL}${config.url}`); log.debug(`params [${JSON.stringify(config.params)}]`); log.debug(`data [${JSON.stringify(config.data)}]`); return config; }; const requestInterceptorError = error => { log.error(error.toString()); 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' }, // 覆盖掉外面的全局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:
const ServiceSpecsMap = new Map(); const isServer = typeof window === 'undefined'; // 允许客户端透传的header const AllowRequestHeaderKeys = ['cookie']; // 允许透传回客户端的header const AllowResponseHeaderKeys = ['set-cookie']; // 单例 let ServiceSingleton = null; function getServiceSpecsMap(option) { if (isServer) { // 扫描文件夹下的所有定义文件 require('fs') .readdirSync('./libs/swagger.lib/' + option.fileName) .forEach((file) => { if (file.endsWith('.spec.js')) { const specName = file.split('.spec.js')[0]; try { const specObj = require('./' + option.fileName + '/' + 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, specObj); } catch (e) { log.warn(`load spec ${file} faild.`); } } }); } else {
} } config.forEach((i) => getServiceSpecsMap(i)); const errorHandle = (result, config) => { const { ignoreError = false } = config; 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'); // 绑定微信接口fmp/member/bind/bindWx在入口文件,不需要toast提示,单独处理 if (config.url.indexOf('fmp/member/bind/bindWx') === -1 && !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}`; } } // TODO 这里不做await,是因为堵塞了,会导致外面的loading一直在转,但是这里有需要弹出登录框,交互有冲突 errorHandle(data, config); return data; }; function requestWithSpec(spec, data, options = {}) { if (typeof spec.server === 'string') { options.baseURL = spec.server; } let apiStyle = spec.apiStyle; // 如果是服务端去CLS中获取调用链上设置的header if (isServer) { const CLSUtil = require('../CLS.lib'); options.baseURL = CLSUtil.getBaseURL() || spec.server; 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文档,就完成了。