Pullcode:基于OpenAPI规范的TypeScript HTTP客户端代码生成器深度解析

136 阅读6分钟

引言

在现代前端开发中,与后端API的交互是不可避免的。传统的做法是手动编写HTTP请求代码,这不仅耗时且容易出错。Pullcode作为一个基于OpenAPI规范的TypeScript HTTP客户端代码生成器,通过解析Swagger 2.0和OpenAPI 3.0文档,自动生成类型安全的API客户端代码,大大提高了开发效率。

本文将深入分析Pullcode的源码架构、核心实现原理以及在实际项目中的应用,帮助开发者快速理解并应用这个工具。

项目架构概览

核心模块结构

Pullcode采用模块化设计,主要包含以下几个核心模块:

src/
├── index.ts                 # 主入口文件
├── generators/              # 代码生成器
│   ├── ServiceGenerator.ts  # 服务类生成器
│   ├── TypesGenerator.ts    # 类型定义生成器
│   └── BizServiceGenerator.ts # 业务服务基类生成器
├── models/                  # 数据模型
│   ├── OpenServer.ts        # OpenAPI服务器模型
│   ├── OpenService.ts       # 服务模型
│   ├── OpenRoute.ts         # 路由模型
│   └── OpenType.ts          # 类型模型
├── httputil/               # HTTP工具类
│   ├── Axios.ts            # Axios封装
│   └── axiosTransform.ts   # 请求响应转换
└── types/                  # 类型定义

技术栈分析

package.json可以看出,Pullcode使用了以下核心技术:

  • TypeScript: 提供完整的类型支持
  • Axios: HTTP客户端库
  • EJS: 模板引擎,用于代码生成
  • Commander.js: 命令行工具
  • swagger2openapi: Swagger 2.0转OpenAPI 3.0转换器
  • Prettier: 代码格式化

核心实现原理

1. 文档解析与转换

Pullcode首先需要处理不同版本的OpenAPI文档。在src/index.ts中,我们可以看到文档处理的逻辑:

// 支持Swagger 2.0和OpenAPI 3.0
if ((obj as OpenAPIV2.Document).swagger) {
  // 使用swagger2openapi转换Swagger 2.0到OpenAPI 3.0
  converter.convert(obj, _options, function (err, ret) {
    if (err) {
      throw err
    }
    genCode(ret.openapi)
  });
} else {
  // 直接处理OpenAPI 3.0文档
  genCode(obj as OpenAPIV3.Document)
}

2. OpenAPI文档模型转换

OpenServer.convert()方法是整个转换过程的核心,它将OpenAPI 3.0文档转换为内部模型:

public static convert(document: OpenAPIV3.Document): OpenServer {
  const openServer = new OpenServer();
  
  // 1. 提取服务器信息
  const servers = document.servers;
  if (servers && servers.length) {
    openServer.name = servers[0].url
  }
  
  // 2. 解析类型定义
  const types: OpenType[] = [];
  const schemas = document.components && document.components.schemas;
  if (schemas) {
    for (let prop in schemas) {
      types.push(OpenType.fromSchema({
        schema: schemas[prop] as OpenAPI3Schema,
        createEnum: true,
        typeName: prop.replace(/[^a-zA-Z0-9_]/g, ""),
      }))
    }
  }
  
  // 3. 解析API路径并分组
  const paths = document.paths;
  const pathItemWrapperMap = new Map<string, PathItemWrapper[]>();
  
  // 按服务名分组API路径
  for (let prop in paths) {
    let key = _.trimStart(prop, "/");
    if (!key) continue;
    
    const split = key.split("/");
    const wrapper = new PathItemWrapper();
    const pathItem = paths[prop];
    const tags = OpenServer.getTags(pathItem as OpenAPIV3.PathItemObject);
    
    // 使用第二个tag作为服务类名,否则使用路径第一部分
    let service = _.upperFirst(split[0].replace(/[^a-zA-Z0-9_]/g, "")) + "Service";
    if (tags && tags.length > 1 && tags[1]) {
      service = _.upperFirst(tags[1].replace(/[^a-zA-Z0-9_]/g, "")) + "Service";
    }
    
    wrapper.service = service;
    wrapper.endpoint = prop.replace(/{/g, "${params.");
    wrapper.pathItem = pathItem;
    
    const wrappers = pathItemWrapperMap.get(service) || [];
    wrappers.push(wrapper)
    pathItemWrapperMap.set(service, wrappers);
  }
  
  // 4. 生成服务对象
  const services = [] as OpenService[];
  pathItemWrapperMap.forEach((wrappers: PathItemWrapper[], key: string) => {
    const service = new OpenService();
    service.name = key;
    service.module = wrappers[0].module;
    
    const routes = [] as OpenRoute[];
    for (let wrapper of wrappers) {
      const pathItem = wrapper.pathItem!;
      
      // 为每个HTTP方法创建路由
      pathItem.get && routes.push(OpenRoute.of(wrapper.endpoint!, "get", pathItem.get as OpenAPI3Operation, document.components || {}))
      pathItem.post && routes.push(OpenRoute.of(wrapper.endpoint!, "post", pathItem.post as OpenAPI3Operation, document.components || {}))
      pathItem.put && routes.push(OpenRoute.of(wrapper.endpoint!, "put", pathItem.put as OpenAPI3Operation, document.components || {}))
      pathItem.delete && routes.push(OpenRoute.of(wrapper.endpoint!, "delete", pathItem.delete as OpenAPI3Operation, document.components || {}))
    }
    
    service.routes = routes;
    service.types = openServer.types?.map(t => t.name) as any
    services.push(service)
  })
  
  openServer.services = services;
  return openServer;
}

3. 代码生成器架构

Pullcode使用模板引擎EJS来生成代码,每个生成器都对应一个模板文件:

ServiceGenerator - 服务类生成器

export class ServiceGenerator {
  public service: OpenService | undefined;
  public outputDir: string = '';
  private static template: string = 'Service.ts.ejs';

  public generate() {
    if (!this.service) {
      return
    }
    
    const name = _.upperFirst(this.service.name);
    let out = path.join(this.outputDir, name + ".ts")
    
    // 使用EJS模板渲染代码
    ejs.renderFile(path.resolve(__dirname, ServiceGenerator.template), {
      version,
      types: this.service.types,
      name,
      module,
      routes: this.service.routes,
      _,
    }, {}, function (err, str) {
      if (err) {
        throw err;
      }
      // 使用Prettier格式化生成的代码
      fs.writeFile(out, prettier.format(str, { parser: "typescript" }), function (err) {
        if (err) {
          throw err;
        }
        console.log(`file ${out} is generated`)
      })
    });
  }
}

生成的Service类模板(Service.ts.ejs)包含:

export class <%- name %> extends BizService {
  constructor(options?: Partial<CreateAxiosOptions>) {
    super(options);
  }
  
  <%_ routes.forEach(function(route){ -%>
  /**
   * <%- _.toUpper(route.httpMethod) %> <%- route.endpoint %>
   * @param <%- route.reqBody.name %> <%- route.reqBody.doc %>
   * @returns Promise<<%- route.respData.type %>>
   */
  <%- route.name %>(<%- route.reqBody.name %>: <%- route.reqBody.type %>): Promise<<%- route.respData.type %>> {
    return this.getAxios().<%- route.httpMethod %>(`<%- route.endpoint %>`, <%- route.reqBody.name %>, {});
  }
  <%_ }) -%>
}

TypesGenerator - 类型定义生成器

export class TypesGenerator {
  public types: OpenType[] | undefined;
  public outputDir: string = '';
  private static template: string = 'types.ts.ejs';

  public generate() {
    if (!this.types || !this.types.length) {
      return
    }
    
    ejs.renderFile(path.resolve(__dirname, TypesGenerator.template), {
      version,
      types: this.types,
    }, {}, function (err, str) {
      if (err) {
        throw err;
      }
      fs.writeFile(out, prettier.format(str, { parser: "typescript" }), function (err) {
        if (err) {
          throw err;
        }
        console.log(`file ${out} is generated`)
      })
    });
  }
}

4. HTTP客户端封装

Pullcode基于Axios构建了一个功能强大的HTTP客户端封装:

VAxios类 - 核心HTTP客户端

export class VAxios {
  private axiosInstance: AxiosInstance;
  private readonly options: CreateAxiosOptions;

  constructor(options?: Partial<CreateAxiosOptions>) {
    this.options = merge(cloneDeep(defaultOptions), options || {});
    this.axiosInstance = axios.create(options);
    this.setupInterceptors();
  }

  /**
   * 设置请求和响应拦截器
   */
  private setupInterceptors() {
    const {
      requestInterceptors,
      requestInterceptorsCatch,
      responseInterceptors,
      responseInterceptorsCatch,
    } = this.getTransform() || {};

    // 请求拦截器
    this.axiosInstance.interceptors.request.use((config: AxiosRequestConfig) => {
      const { requestOptions, axiosCanceler, tokenGetter } = this.options;
      
      if (requestOptions && isObject(requestOptions) && Object.keys(requestOptions).length) {
        const { apiUrl, joinPrefix, formatDate, joinTime = true, urlPrefix } = requestOptions;
        
        // URL前缀处理
        if (joinPrefix) {
          config.url = `${urlPrefix}${config.url}`;
        }
        if (apiUrl && isString(apiUrl)) {
          config.url = `${apiUrl}${config.url}`;
        }
        
        // 参数格式化
        const params = config.params || {};
        const data = config.data || false;
        formatDate && data && !isString(data) && formatRequestDate(data);
        
        // GET请求添加时间戳
        if (config.method?.toUpperCase() === RequestEnum.GET) {
          if (!isString(params)) {
            config.params = Object.assign(params || {}, joinTimestamp(joinTime, false));
          } else {
            config.url = config.url + params + `${joinTimestamp(joinTime, true)}`;
            config.params = undefined;
          }
        }
      }
      
      // Token处理
      if (tokenGetter && isFunction(tokenGetter)) {
        const token = tokenGetter();
        if (token && (config as Recordable)?.requestOptions?.withToken !== false) {
          (config as Recordable).headers.Authorization = this.options.authenticationScheme
            ? `${this.options.authenticationScheme} ${token}`
            : token;
        }
      }
      
      // 自定义请求拦截器
      if (requestInterceptors && isFunction(requestInterceptors)) {
        config = requestInterceptors(config, this.options);
      }
      
      return config;
    }, undefined);

    // 响应拦截器
    this.axiosInstance.interceptors.response.use((res: AxiosResponse<any>) => {
      const { axiosCanceler } = this.options;
      res && axiosCanceler && axiosCanceler.removePending(res.config);
      
      if (responseInterceptors && isFunction(responseInterceptors)) {
        if (this.options.requestOptions && this.options.requestOptions.isReturnNativeResponse) {
          return responseInterceptors(res, this.options)
        }
        return responseInterceptors(res.data, this.options)
      }
      
      if (this.options.requestOptions && this.options.requestOptions.isReturnNativeResponse) {
        return res
      }
      return res.data
    }, undefined);
  }
}

实际应用示例

1. 快速开始

安装依赖

npm install --save pullcode

配置npm script

{
  "scripts": {
    "pull": "pullcode -u https://petstore3.swagger.io/api/v3/openapi.json -o src/api"
  }
}

生成代码

npm run pull

2. 生成的代码结构

执行命令后,会在src/api目录下生成以下文件:

src/api/
├── BizService.ts      # 业务服务基类(可编辑)
├── PetService.ts      # 宠物服务类
├── StoreService.ts    # 商店服务类
├── UserService.ts     # 用户服务类
└── types.ts          # 类型定义

BizService.ts - 配置基础服务

import { merge } from "lodash-es";
import { CreateAxiosOptions, VAxios } from "pullcode/dist/esm/httputil/Axios";
import { RequestOptions } from "pullcode/dist/esm/types/axios";

const defaultOptions: CreateAxiosOptions = {
  requestOptions: {
    apiUrl: 'https://petstore3.swagger.io/api/v3', // API基础URL
    urlPrefix: '', // URL前缀
  } as RequestOptions,
};

export class BizService extends VAxios {
  constructor(options?: Partial<CreateAxiosOptions>) {
    super(merge(defaultOptions, options || {}));
  }
}

PetService.ts - 生成的API客户端

export class PetService extends BizService {
  constructor(options?: Partial<CreateAxiosOptions>) {
    super(options);
  }

  /**
   * GET /pet/findByStatus
   * Multiple status values can be provided with comma separated strings
   * @param params
   * @returns Promise<Pet[]>
   */
  getPetFindByStatus(params: {
    status?: PetStatusEnum;
  }): Promise<Pet[]> {
    return this.getAxios().get(`/pet/findByStatus`, {
      params: qs.stringify(params),
    });
  }

  /**
   * POST /pet
   * Add a new pet to the store.
   * @param payload Create a new pet in the store
   * @returns Promise<Pet>
   */
  postPet(payload: Pet): Promise<Pet> {
    return this.getAxios().post(`/pet`, payload, {});
  }
}

types.ts - 类型定义

export interface Pet {
  id?: number;
  name: string;
  category?: Category;
  photoUrls: string[];
  tags?: Tag[];
  status?: PetStatusEnum;
}

export enum PetStatusEnum {
  AVAILABLE = 'available',
  PENDING = 'pending',
  SOLD = 'sold',
}

3. 在Vue项目中使用

<script setup lang="ts">
import { petService } from "@/api/PetService";
import { Pet, PetStatusEnum } from "@/api/types";
import { ref } from "vue";

const loading = ref(true);
const dataSource = ref([] as Pet[]);

// 调用API
petService
  .getPetFindByStatus({
    status: PetStatusEnum.AVAILABLE,
  })
  .then((resp: Pet[]) => {
    dataSource.value = resp;
    loading.value = false;
  })
  .catch((error) => {
    console.error('获取宠物列表失败:', error);
    loading.value = false;
  });
</script>

<template>
  <div>
    <a-table :dataSource="dataSource" :columns="columns" :loading="loading">
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'tags'">
          <span>
            <a-tag v-for="tag in record.tags" :key="tag.id">
              {{ tag.name.toUpperCase() }}
            </a-tag>
          </span>
        </template>
        <template v-else-if="column.key === 'category'">
          {{ record.category?.name }}
        </template>
      </template>
    </a-table>
  </div>
</template>

4. 高级配置

自定义拦截器

// src/api/interceptor.ts
import type { AxiosResponse } from 'axios';
import type { AxiosTransform } from 'pullcode/dist/esm/httputil/axiosTransform';
import { useMessage } from '/@/hooks/web/useMessage';

const { createMessage } = useMessage();

export const transform: AxiosTransform = {
  /**
   * 响应错误处理
   */
  responseInterceptorsCatch: (_: AxiosResponse, error: any) => {
    const { response, code, message } = error || {};
    let errMessage = '';

    if (code === 'ECONNABORTED' && message.indexOf('timeout') !== -1) {
      errMessage = '请求超时';
    }
    if (error?.includes('Network Error')) {
      errMessage = '网络异常';
    }

    if (errMessage) {
      createMessage.error(errMessage);
      return Promise.reject(error);
    }

    return Promise.reject(error);
  },
};

配置Token获取

// src/api/BizService.ts
import { getToken } from '/@/utils/auth';

const defaultOptions: CreateAxiosOptions = {
  transform,
  requestOptions: {
    apiUrl: globSetting.apiUrl,
    urlPrefix: '/myservice',
  } as RequestOptions,
  tokenGetter: getToken as () => string
}

技术优势分析

1. 类型安全

Pullcode生成的代码具有完整的TypeScript类型支持:

  • 接口参数类型: 根据OpenAPI schema自动生成
  • 响应数据类型: 精确的类型定义
  • 枚举类型: 自动生成枚举值
  • 可选参数: 正确处理required字段

2. 开发体验

  • 智能提示: IDE提供完整的代码补全
  • 错误检查: 编译时发现类型错误
  • 文档注释: 自动生成JSDoc注释
  • 代码格式化: 使用Prettier保持代码风格一致

3. 可扩展性

  • 拦截器支持: 可自定义请求和响应拦截器
  • 配置灵活: 支持全局和局部配置
  • 错误处理: 统一的错误处理机制
  • Token管理: 自动的认证token处理

4. 框架无关

Pullcode生成的代码是纯TypeScript,不依赖特定框架,可以在任何支持TypeScript的项目中使用:

  • Vue 2/3
  • React
  • Angular
  • Node.js
  • 其他TypeScript项目

项目配置说明

TypeScript配置

确保tsconfig.json包含以下配置:

{
  "compilerOptions": {
    "importsNotUsedAsValues": "remove"
  }
}

说明importsNotUsedAsValues: "remove" 配置允许直接导入类型而不需要 type 关键字,这是 pullcode 生成的代码所必需的。

推荐目录结构

src/
├── api/                 # 生成的API代码
│   ├── BizService.ts   # 基础配置
│   ├── PetService.ts   # 宠物API
│   ├── UserService.ts  # 用户API
│   └── types.ts        # 类型定义
├── utils/
│   └── auth.ts         # 认证工具
└── components/
    └── PetList.vue     # 使用API的组件

总结

Pullcode作为一个基于OpenAPI规范的TypeScript HTTP客户端代码生成器,通过以下技术特点为前端开发带来了显著的价值:

  1. 自动化代码生成: 从OpenAPI文档自动生成类型安全的API客户端
  2. 完整的类型支持: 基于TypeScript提供完整的类型定义
  3. 灵活的配置: 支持自定义拦截器、错误处理等
  4. 框架无关: 可在任何TypeScript项目中使用
  5. 开发体验优化: 提供智能提示、错误检查等功能

通过深入理解Pullcode的源码架构和实现原理,开发者可以更好地利用这个工具,提高API开发的效率和质量。在实际项目中,合理配置和使用Pullcode可以大大减少手动编写API客户端代码的工作量,同时确保类型安全和代码质量。