API auto to TypeScript

Published on 12 October 2024
16 min read
TypeScript
NextJS
API auto to TypeScript

最近要做一个新项目,准备还是使用 TypeScript 了,上一个项目使用的是 JS,为了更好的编辑体验,写了蛮多的 JSDoc,感觉下来还是 TypeScript 更好一些,因为除了方便的智能提示,最重要的是它所带来的类型检查,这是 JSDoc 所提供不了的。

在之前使用 TypeScript 的过程中,其实遇到的比较烦人的一点就是针对后端服务接口 API 的类型定义,需要定义 Request 类型和 Response 类型,如果接口较少的情况下还好,接口多的情况下不但定义起来麻烦,而且代码量也不小,之前为了不让项目看起来过于混乱,会在单独的文件中手动去定义接口类型。好在我们使用的接口文档工具有提供相应的类型定义,后期有时候图省事就直接复制到项目中粘贴了,当然这样也是存在问题的,例如

  • 接口字段做了变更,那就需要前端找到对应的接口定义,然后修改
  • 字段的注释需要在写一遍 所以就想着有没有更方便的实现,能够将定义接口类型这一步做到无感知和自动化,查了相关资料后发现确实有一些方案的。

OpenAPI

在做这项工作之前,首先我们需要知道一个规范:OpenAPI

OpenAPI 规范(OAS),是定义一个标准的、与具体编程语言无关的 RESTful API 的规范。OpenAPI 规范使得人类和计算机都能在“不接触任何程序源代码和文档、不监控网络通信”的情况下理解一个服务的作用。如果您在定义您的 API 时做的很好,那么使用 API 的人就能非常轻松地理解您提供的 API 并与之交互了。 如果您遵循 OpenAPI 规范来定义您的 API,那么您就可以用文档生成工具来展示您的 API,用代码生成工具来自动生成各种编程语言的服务器端和客户端的代码,用自动测试工具进行测试等等。

目前后端所定义的接口,基本上都是遵循 OpenAPI 规范的,它允许开发人员用机器可读的格式来记录 API,包括 API 路径、请求方法、请求参数以及响应格式等。以下是一个简单的 OpenAPI 的示例

yml
openapi: 3.0.0
info:
  title: Simple User API
  version: 1.0.0
paths:
  /users:
    get:
      summary: Get all users
      responses:
        '200':
          description: A list of users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
    post:
      summary: Create a new user
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewUser'
      responses:
        '201':
          description: The created user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: John Doe
        email:
          type: string
          example: [email protected]
    NewUser:
      type: object
      properties:
        name:
          type: string
          example: John Doe
        email:
          type: string
          example: [email protected]

从上面的示例中可以看到,它描述了一个接口的说明、请求路径以及返回结果,components 则定义了通用的对象模式(schemas),如 UserNewUser。这些模式可以在不同的路径中重用。

有了这个规范那么要实现通过文档转换出 TypeScript 类型就简单很多了,社区也有了一些相关的项目可以看看:

你可以根据项目的需求以及自己的喜好选择对应的项目,我这里使用的是 openapi-typescript, 我觉得它相对来说简单一点,因为我的需求就很简单,只需要提取 TypeScript 类型就好了。

ApiFox

公司使用的接口文档是 Apifox, 并且它提供了开放接口,可以用来获取某个项目的 OpenAPI 数据,你可以在 API Hub 中找到它的文档。 CleanShot 2024-10-10 at 21.57.12@2x.png

CleanShot 2024-10-10 at 21.58.57@2x.png

/v1/projects/{projectId}/export-openapi 接口可用来导出 OpenAPI/Swagger 格式数据,调用该接口需要两个参数:

  • projectId - ApiFox 项目的 project Id,可在项目设置/基本信息中查看项目 ID
  • access token - API 访问令牌,在账号设置 - API 访问令牌中生成

设置了这两个参数之后,可以在 ApiFox 中运行接口测试下,一切没问题的话可以看到返回的 OpenAPI 格式的数据 CleanShot 2024-10-11 at 10.52.04@2x.png

OpenAPI 数据转 TS

使用 Fetch

有了 openApi 数据,就可以使用 openapi-typescript 进行转换类型的操作了,在 NextJS 项目中新建一个 api-to-ts.mjs 文件,用来处理转换,基本逻辑就是通过 fetch 请求 ApiFox 的开放接口获取 OpenAPI 格式的数据,然后调用 openapi-typescript 提供的方法,得到转换后的 typescript 类型文件,最后写入到项目文件中。

javascript
import fs from 'node:fs';

import openapiTS, { astToString } from 'openapi-typescript';

const APIFOX_PROJECT_ID = process.env.APIFOX_PROJECT_ID;
const APIFOX_TOKEN = process.env.APIFOX_TOKEN;
if (!APIFOX_PROJECT_ID) {
	throw new Error('在.env.local 文件中添加 APIFOX_PROJECT_ID');
}
if (!APIFOX_TOKEN) {
	throw new Error('在.env.local 文件中添加 APIFOX_TOKEN');
}

const APIFOX_OPENAPI_PATH = `https://api.apifox.com/v1/projects/${APIFOX_PROJECT_ID}/export-openapi?locale=Zh-CN`;

async function main() {
	const headers = new Headers();
	headers.append('X-Apifox-Version', '2024-01-20');
	headers.append('Authorization', `Bearer ${APIFOX_TOKEN}`);
	headers.append('User-Agent', 'Apifox/1.0.0 (https://apifox.com)');
	headers.append('Accept', '*/*');
	headers.append('Host', 'api.apifox.com');
	headers.append('Connection', 'keep-alive');

	const requestOptions = {
		method: 'POST',
		headers,
		redirect: 'follow'
	};

	const data = await fetch(APIFOX_OPENAPI_PATH, requestOptions);
	const json = await data.json();
	const ast = await openapiTS(json);
	const content = astToString(ast);

	await fs.promises.writeFile('./openapi/api.ts', content);
}

main();

APIFOX_PROJECT_IDAPIFOX_TOKEN 我放在了 .env.local 环境变量文件中,并在 .gitignore 中添加对 .evn.local 文件的过滤,以确保敏感的数据不被提交到 Git 仓库中。

json

package.json 中新添加一个 script:

"scripts": {
	"api": "node --env-file .env.local script/api-to-ts.mjs",
}
注意这里使用了 `node --env-file` 用来加载环境变量文件,这个特性从 `Nodejs v20.6.0` 才开始得到支持 ([Node v20.6.0 (Current)](https://nodejs.org/en/blog/release/v20.6.0#built-in-env-file-support)),如果你使用的 `Node` 版本低于这个,需要使用其他加载环境变量的方式,例如 `dotenv` 等。

通过 fetch 获取到数据传入给 openapiTS 方法,该方法将 OpanAPI 格式数据转换为 TypeScript AST. 该方法支持传入第二个参数,用来自定义生成的数据格式,包括:

  • enum: 生成真正的 TS 枚举而不是字符串联合类型
  • enumValues: 将枚举值导出为数据
  • exportType: 导出 type 而不是 interface
  • immutable: 生成不可变类型(只读属性和只读数组)
  • ….

你可以根据需求来设置不同的参数,完整参数列表可查看 openapi-typescript 文档。

获取到的 TypeScript AST 数据你还可以根据需要对数据进行 遍历/修改等操作,然后再调用 astToString 方法将 TypeScript AST 转换为字符串写入到文件中。

使用 CLI

如果你不需要使用 fetch 请求获取 OpenAPI 数据,还可以使用 openapi-typescript 提供的 CLI 来直接转换 OpenAPI 数据。

bash
npx openapi-typescript api.json -o api.ts

api.json 是从ApiFox 中运行得到的 OpenAPi 数据,api.ts 是转换完成后生成文件,同样 CLI 也支持传入参数定制生成的数据格式。

最终得到的 api.ts 文件如下 CleanShot 2024-10-11 at 11.18.53@2x.png CleanShot 2024-10-11 at 11.19.12@2x.png

创建类型工具

现在有了接口的类型定义,但是获取某个接口的请求参数、响应类型等依然还是有些麻烦,可以创建几个 TypeScript 工具类来方便获取

获取 Get 请求的参数类型

typescript
import { type paths } from './api';

export type UseAPIQueryOptions<T extends keyof paths> = paths[T]['get'] extends {
	parameters: infer P;
}
	? P extends { query?: infer Q }
		? Q
		: never
	: never;

通过泛型 T extends keyof paths 可以实现,在输入时的接口自动提示,效果如下 CleanShot 2024-10-11 at 11.24.21@2x.png

得到的类型为 CleanShot 2024-10-11 at 11.25.00@2x.png

可以检查下类型 A 的类型是否与 API 文档中定义的类型一致

获取 Post 请求的 Body 参数类型

typescript
/**
 * 获取 POST 请求的 Body 参数类型
 */
export type UseAPIPostBody<
	T extends keyof paths,
	Method extends keyof paths[T] = 'post'
> = paths[T][Method] extends {
	requestBody?: infer B;
}
	? B extends { content: { 'application/json': infer Req } }
		? Req
		: never
	: never;

效果如下 CleanShot 2024-10-11 at 11.26.50@2x.png

获取接口的 Response 类型

GET 接口 CleanShot 2024-10-11 at 11.28.18@2x.png

POST 接口 CleanShot 2024-10-11 at 11.28.33@2x.png

Fetch 封装

一般稍微大一点的项目,都会有自己的一套请求方案,我们的 NextJS 项目中同样对 fetch 进行了一层封装来方便进行请求。openapi-typescript 也提供了基于类型安全的 fetch 包装器 openapi-fetch以及基于 @tanstack/react-queryopenapi-react-query。 这两个包装器都能与 openapi-typescript 完美结合,无需泛型也不许手动输入,所有的请求、响应类型都是自动推断的,你可以尝试这两个项目来使用。我们项目中因为已经有了一个基于 fetch 的封转了,所以就不再使用 openapi-fetch,而是在现有逻辑的基础上将其与 openapi-typescript 生成的类型文件结合起来,实现类型的自动推断

typescript
import { ApiPaths, UseAPIPostBody, UseAPIQueryOptions, UseAPIResponse } from '@openapi/index';

interface RequestOptions extends RequestInit {
	responseType?: 'json' | 'text' | 'blob';
	customError?: boolean;
}

const request = async (url: string, options: RequestOptions = {}) => {
	// 具体的请求逻辑,包含鉴权、错误码处理等
};

/**
 * GET Request
 */
async function GET<P extends keyof ApiPaths>(
	url: P,
	params: UseAPIQueryOptions<P>,
	options?: RequestOptions
): Promise<UseAPIResponse<P>>;
async function GET<
	P extends string,
	R,
	T extends Record<string, unknown> = Record<string, unknown>
>(
	url: P,
	params: P extends keyof ApiPaths ? UseAPIQueryOptions<P> : T,
	options?: RequestOptions
): Promise<R>;
async function GET<
	P extends string,
	R,
	T extends Record<string, unknown> = Record<string, unknown>
>(
	url: P,
	params: P extends keyof ApiPaths ? UseAPIQueryOptions<P> : T,
	options?: RequestOptions
): Promise<R> {
	return await request(`${url}?${new URLSearchParams(params as Record<string, string>)}`, options);
}

/**
 * POST Request
 */
async function POST<P extends keyof ApiPaths>(
	url: P,
	body: UseAPIPostBody<P>,
	options?: RequestOptions
): Promise<UseAPIResponse<P, 'post'>>;
async function POST<
	P extends string,
	R,
	T extends Record<string, unknown> = Record<string, unknown>
>(
	url: P,
	body: P extends keyof ApiPaths ? UseAPIPostBody<P> : T,
	options?: RequestOptions
): Promise<R>;
async function POST<
	P extends string,
	R,
	T extends Record<string, unknown> = Record<string, unknown>
>(
	url: P,
	body: P extends keyof ApiPaths ? UseAPIPostBody<P> : T,
	options?: RequestOptions
): Promise<R> {
	return await request(url, {
		body: JSON.stringify(body),
		...options,
		method: 'POST'
	});
}

const HTTP = {
	GET,
	POST
};

export default HTTP;

核心代码结构如上,主要关心 GETPOST 两个方法的类型定义,通过函数重载的方式,让 GETPOST 请求支持不同的请求参数

  1. 支持请求 URL 的智能提示
  2. 如果请求的 apiopenapi-typescript 生成的类型文件中存在的,则自动推断出请求参数类型和响应类型
  3. 支持传入 openapi-typescript 不存在的 API(某些情况下),并且可以自定义参数类型和响应类型

使用效果

普通的 GET 请求

typescript
export async function getListExampleTwo() {
	const data = await HTTP.GET('/val/match/home', {
		page: '1'
	});
	return data;
}

可以自动推断出 GET 请求的 requry 类型和响应类型 CleanShot 2024-10-11 at 13.06.06@2x.png

自定义返回值类型

typescript
export const EXAMPLE_LIST = '/user/list';

export type ExampleResponseType = {
	name: string;
	job: string;
};

export async function getListExampleThree() {
	const data = await HTTP.GET<string, ExampleResponseType>(EXAMPLE_LIST, {
		age: 18,
		type: 'test'
	});
	return data;
}

编辑器查看效果: CleanShot 2024-10-11 at 13.07.08@2x.png

请求函数需要透传参数的

使用 UseAPIQueryOptions 获取请求接口的参数类型

typescript
export const EXAMPLE_LIST = '/user/list';

export async function getListExample(params: UseAPIQueryOptions<EXAMPLE_LIST>) {
	const data = await HTTP.GET(EXAMPLE_LIST, params);
	return data;
}

动态拼接的 URL

有些接口地址是需要传入 path 参数的,例如 /api/user/[userId],我目前没有实现对这种接口的支持,不过 openapi-fetch 是实现了的 - openapi-fetch | OpenAPI TypeScript。目前对于动态请求 API,我直接使用自定义 ResponseType 的方式来实现了

typescript
export async function getDetail(id: string) {
	const detail = await HTTP.GET<string, UseAPIResponse<'/user/info', 'get'>>(
		`/user/info/${id}`,
		{}
	);
	return detail;
}

类型检测

使用 TypeScript 的目的处理在于更好的编辑器提示之外,更重要的是他的类型检测,我们希望如果后端同学修改了 API 文档之后,前端如果没有及时修改,可以自动检测出其中的错误,当下的流程是,新需求开发时,执行 pnpm run api 通过接口类型定义,当完成需求提交代码时,通过 tsc --noemit 检测类型是否存在错误,在 package.json 中定义

json
"scripts": {
    "lint": "pnpm run check-types && next lint",
    "check-types": "tsc --noemit",
}

使用 husky 实现在 commit 或者在发布之前执行 pnpm lint 对项目进行检测,已确保接口类型定义与代码中的实现是一样的。

是否需要自动化 API 类型定义应该取决于你的项目来,个人感觉项目太小或者太大都不太合适,太小的项目没有必要,大的项目往往会存在多个 API 服务,使用起来需要避免混乱。而且实现自动化类型还需要对团队后端同学有一定的要求,API 的定义必须规范,最好有对字段的定义说明,以及参数和响应的完整定义,如果不满足的话还是自己手动定义类型比较好。