OpenAPI 自动生成接口请求代码

OpenAPI 自动生成接口请求代码

前言

OpenAPI简介

小问题,为什么Java同事只写了一些注解,我们就能看到了swagger文档的定义了呢?答案其实很简单就是OpenAPI规范。

定义

OpenAPI规范又名Swagger规范,最早属于swagger项目的一部分,下面是swaagger对于OpenAPI的定义。

”OpenAPI 规范 (OAS) 为 RESTful API 定义了一个与语言无关的标准接口,它允许人类和计算机在不访问源代码、文档或通过网络流量检查的情况下发现和理解服务的功能。如果定义得当,消费者可以用最少的实现逻辑来理解远程服务并与之交互。“

修订历史

目前用的最多是swagger 2.0版本(以我公司为例)。后续介绍以2.0举例介绍。

版本日期笔记
3.1.02021-02-15发布 OpenAPI 规范 3.1.0
3.0.32020-02-20OpenAPI 规范 3.0.3 的补丁发布
3.0.22018-10-08OpenAPI 规范 3.0.2 的补丁发布
3.0.12017-12-06OpenAPI 规范 3.0.1 的补丁发布
3.0.02017-07-26发布 OpenAPI 规范 3.0.0
2.02014-09-08Swagger 2.0 发布
1.22014-03-14正式文件的初步发布
1.12012-08-22Swagger 1.1 的发布
1.02011-08-10Swagger 规范的第一个版本

规范(Specification)

只列举常用字段,详细字段请参考swagger.io官网

字段含义
swagger版本号
info提供关于API的元数据。如果需要的话,客户端可以使用元数据
host接口host
basePath提供API的基本路径
paths必需的。API的可用路径和操作符。对应接口路径请求方式等, 以接口path为键值
definitions一个对象,用来保存由操作生成和使用的数据类型。对应后端models
tags对应后台的Controller,会按照tag进行接口分组

通过上面的介绍我们可以得出一个狭义的通俗说法,openapi(特指swagger.json)本质就是一个用户描述接口信息的json文件。

那么swagger.json已经详细描述接口信息,并且能根据json生成swagger-ui, 那我们是不是能根据swagger.json生成我们跟接口相关的请求、响应声明的typing.d.ts呢,那进一步我们能不能根据一定的规则模板生成我们想要请求代码呢,答案是肯定的,下面章节我们详细讲述怎么通过工具库做到这些。

请求、响应声明

背景

写了挺久的Typescript代码, 除了业务组件外, 对于类型定义最为迫切的其实是接口响应数据。然而根据将swagger的数据结构搬过来转为type 或 interface的工作量是巨大且繁琐的,所以有时候就会偷懒直接使用了any类型,那这样就违背了使用typecript的初衷。所以内心有个想法就是Java的既然是强类型语言,那是否存在一种场景java数据类型跟typescript数据类型的映射关系,直接将Java的类型直接转为typescript 的数据类型呢,这一搜果然,已经有相关库实现了这个功能。openapi-typescript, 并根据这个库进一步了解了openapi的规范。

实现原理

根据上述的swagger规范,将不同字段转为interface,核心代码是transform目录,根据不同的字段进行转换,底层通过nodeType实现了swagger类型与typescript的数据类型映射关系

image.png

export function nodeType(obj: any): SchemaObjectType { if (!obj || typeof obj !== "object") { return "unknown"; } if (obj.$ref) { return "ref"; } // const if (obj.const) { return "const"; } // enum if (Array.isArray(obj.enum) && obj.enum.length) { return "enum"; } // Treat any node with allOf/ anyOf/ oneOf as object if (obj.hasOwnProperty("allOf") || obj.hasOwnProperty("anyOf") || obj.hasOwnProperty("oneOf")) { return "object"; } // boolean if (obj.type === "boolean") { return "boolean"; } // string if ( obj.type === "string" || obj.type === "binary" || obj.type === "byte" || obj.type === "date" || obj.type === "dateTime" || obj.type === "password" ) { return "string"; } // number if (obj.type === "integer" || obj.type === "number" || obj.type === "float" || obj.type === "double") { return "number"; } // array if (obj.type === "array" || obj.items) { return "array"; } // object if (obj.type === "object" || obj.hasOwnProperty("properties") || obj.hasOwnProperty("additionalProperties")) { return "object"; } // return unknown by default return "unknown"; }

最终通过转换生成模板字符串,经过prettier整理格式,使用bin目录下的node脚本输入出到output文件中中。

接口请求代码

背景

随着自我追求的不断变高(不是,其实是更懒了),就想到另外一个问题,既然我都能生成接口的请求类型和响应类型了,而且openapi规范中还规定了host、basePath、path等这些字段存在,已经能完整的具备了一个接口请求代码所需要的必要条件,那么我们能不能工程化的程度更彻底一点呢,直接通过openapi的规范直接生成接口请求的代码呢?答案是肯定的, 本着不重复造轮子的想法,迅速google,果然有人先动手了,@umijs/openapi, 乍一看是umijs的一个子项目有配套的umi插件, 其实这个库本身是跟框架解耦的,只要你是typescript的项目,后端接口遵循openapi规范就可以引入这个库。

实现原理

@umijs/openapi 依赖了swagger2openapi用来做swagger2.0到OpenAPI 3.0的转换。

最后统一使用3.0的协议进行类型映射,转换为typescript类型。

其中service的生成逻辑由serviceGenerator.ts 实现,mock生成由mockGenerator.ts 实现。

我们重点介绍 serviceGenerator.ts的逻辑。

首先是类型映射,是类似于openapi-typescript的类型映射逻辑。

const getType = (schemaObject: SchemaObject | undefined, namespace: string = ''): string => { if (schemaObject === undefined || schemaObject === null) { return 'any'; } if (typeof schemaObject !== 'object') { return schemaObject; } if (schemaObject.$ref) { return [namespace, getRefName(schemaObject)].filter((s) => s).join('.'); } let { type } = schemaObject as any; const numberEnum = [ 'int64', 'integer', 'long', 'float', 'double', 'number', 'int', 'float', 'double', 'int32', 'int64', ]; const dateEnum = ['Date', 'date', 'dateTime', 'date-time', 'datetime']; const stringEnum = ['string', 'email', 'password', 'url', 'byte', 'binary']; if (numberEnum.includes(schemaObject.format)) { type = 'number'; } if (schemaObject.enum) { type = 'enum'; } if (numberEnum.includes(type)) { return 'number'; } if (dateEnum.includes(type)) { return 'Date'; } if (stringEnum.includes(type)) { return 'string'; } if (type === 'boolean') { return 'boolean'; } if (type === 'array') { let { items } = schemaObject; if (schemaObject.schema) { items = schemaObject.schema.items; } if (Array.isArray(items)) { const arrayItemType = (items as any) .map((subType) => getType(subType.schema || subType, namespace)) .toString(); return `[${arrayItemType}]`; } const arrayType = getType(items, namespace); return arrayType.includes(' | ') ? `(${arrayType})[]` : `${arrayType}[]`; } if (type === 'enum') { return Array.isArray(schemaObject.enum) ? Array.from( new Set( schemaObject.enum.map((v) => typeof v === 'string' ? `"${v.replace(/"/g, '"')}"` : getType(v), ), ), ).join(' | ') : 'string'; } if (schemaObject.oneOf && schemaObject.oneOf.length) { return schemaObject.oneOf.map((item) => getType(item, namespace)).join(' | '); } if (schemaObject.allOf && schemaObject.allOf.length) { return `(${schemaObject.allOf.map((item) => getType(item, namespace)).join(' & ')})`; } if (schemaObject.type === 'object' || schemaObject.properties) { if (!Object.keys(schemaObject.properties || {}).length) { return 'Record<string, any>'; } return `{ ${Object.keys(schemaObject.properties) .map((key) => { const required = 'required' in (schemaObject.properties[key] || {}) ? ((schemaObject.properties[key] || {}) as any).required : false; /** * 将类型属性变为字符串,兼容错误格式如: * 3d_tile(数字开头)等错误命名, * 在后面进行格式化的时候会将正确的字符串转换为正常形式, * 错误的继续保留字符串。 * */ return `'${key}'${required ? '' : '?'}: ${getType( schemaObject.properties && schemaObject.properties[key], namespace, )}; `; }) .join('')}}`; } return 'any'; };

其次是service 其他字段的生成逻辑

字段取值openAPi 字段
functionNameoperationIdlogListUsingPOST
pathbasePath/path/log/list
methodpaths—key-keypost
接口名称注释summary日志列表
content-typeconsumes"application/json"
parametersparameters根据in字段判断body还是formdata, required是否必填, $refs关联具体类型,有什么字段
responsesresponses一般只看200响应, 通过$ref 关联具体类型

openAPi 示例

{
"paths": {
  "/log/list": {
      "post": {
          "tags": [
              "log-controller"
          ],
          "summary": "日志列表",
          "operationId": "logListUsingPOST",
          "consumes": [
              "application/json"
          ],
          "produces": [
              "*/*"
          ],
          "parameters": [
              {
                  "in": "body",
                  "name": "log",
                  "description": "log",
                  "required": true,
                  "schema": {
                      "$ref": "#/definitions/LogRequest"
                  }
              }
          ],
          "responses": {
              "200": {
                  "description": "OK",
                  "schema": {
                      "$ref": "#/definitions/ResponsePageModel"
                  }
              },
              "201": {
                  "description": "Created"
              },
              "401": {
                  "description": "Unauthorized"
              },
              "403": {
                  "description": "Forbidden"
              },
              "404": {
                  "description": "Not Found"
              }
          },
          "deprecated": false
      }
  }
}
}

具体的实现源码就不看不了,就是把所需要的字段都按照上述规则提取出出来。

然后根据njk模板生成了文件, 类似与ejs模板,只截取一段大家参考下,详细的大家去github仓库查看

{%- if api.params and api.hasParams %} params: { {%- for query in api.params.query %} {% if query.schema.default -%} // {{query.name | safe}} has a default value: {{ query.schema.default | safe }} '{{query.name | safe}}': '{{query.schema.default | safe}}', {%- endif -%} {%- endfor -%} ...{{ 'queryParams' if api.params and api.params.path else 'params' }}, {%- for query in api.params.query %} {%- if query.isComplexType %} '{{query.name | safe}}': undefined, ...{{ 'queryParams' if api.params and api.params.path else 'params' }}['{{query.name | safe}}'], {%- endif %} {%- endfor -%} }, {%- endif %}

其他类似与interface,mock 实现的方案跟service 大同小异,只是映射的字段和生成的模板不同而已, 就不一一表述了。

项目实践(以Vue3为例)

安装依赖

yarn add @umijs/openapi -D

新建一个配置文件

//根目录新建 script/config.js
module.exports = {
  openApi: [
    {
      requestLibPath: "import request from '@/utils/request'", // 想怎么引入封装请求方法
      schemaPath:'your host', // openAPI规范地址
      projectName: 'open-bff-patent', // 生成到哪个目录内
      apiPrefix: '"/open-bff-patent"',// 需不需要增加前缀
      serversPath: './src/service', // 生成代码到哪个目录
    }
  ],
};

//根目录新建 script/openapi.js
const { generateService } = require('@umijs/openapi')
const { openApi } = require('./config.js')


async function run() {
    for (let index = 0; index < openApi.length; index++) {
        await generateService(openApi[index])
    }
}

run()

新增一个script

"scripts": {
  "openapi": "node ./script/openapi.js"
}

生成代码

yarn openapi

代码使用

// 生成代码示例
/** 详情 GET /search/patentHistory/detail */
export async function detailUsingGET(
  // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
  params: API.detailUsingGETParams & {
    // header
    /** 登录token */
    accessToken?: string;
    /** 参数加密token */
    signature?: string;
    /** user-agent-web */
    'user-agent-web'?: string;
  },
  options?: { [key: string]: any },
) {
  return request<API.ResponseListModelString_>(`/open-bff-patent/search/patentHistory/detail`, {
    method: 'GET',
    headers: {},
    params: { ...params },
    ...(options || {}),
  });
}

// 业务代码使用
import { detailUsingGET } from '@/service/open-bff-patent/patentHistory';

async function deleteSomeThing() {
  await detailUsingGET({})
}

代码demo

openapi-demo

常见问题

1. 后端的接口定义使用中文描述,生成api声明错误?

标准方案就是让后台生成接口的时候使用英文,描述为中文即可。

该库是根据后端的接口定义生成function nameinterface name 等,生成前会通过正则过滤掉特殊符号,所以中文会过滤掉,导致展示为_number。 但是每次生成的number不固定,导致如果项目中有使用typing.d.ts 这类声明后,重新生成后,可能类型错乱了。

后端实在不改的解决方案,就是生成的时候引入pinyin库,源码改为不过滤中文,使用拼音库将中文转为pinyin即可。我们项目单独fork了一份处理

2. 后台更改接口定义,或者更换controller,导致原来的方法不可用?

解决方案,要求后端保证接口的operationId唯一切固定。详情参考Spring-doc-openapi3实用配置

原文地址

个人博客 OpenAPI 自动生成接口请求代码