前端小工具:脚本拉取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 // 强制覆盖,是否覆盖已经拉取的文件。
  }
];

 

简单描述一下:

swaggerUrl: swagger文档,json格式的路径,为空代表本地自定义接口文档
baseURL:接口请求地址的baseURL即是后端服务地址,需求配置其跨域处理。
directory: 区分文件目录名称,目录为空代表当前路径,对已经拉取的接口文档进行兼容。
FilePrefix: 支持拉取曾经带FilePrefix拉取的文档,新拉取建议添加,增加命名唯一性,但避免过长文件名。
apiStyle: 区分当前文档的response返回头处理。
forceOverwrite:  强制覆盖,是否覆盖已经拉取的文件。
注意:本地自定义接口文档,注意directory是否一致;多个swagger文档的话,多添加一个结构就可以了。

构建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文档,就完成了。

 

posted @ 2021-10-25 11:27  smallbore  阅读(729)  评论(0编辑  收藏  举报
回顶部