API auto to TypeScript
最近要做一个新项目,准备还是使用 TypeScript
了,上一个项目使用的是 JS,为了更好的编辑体验,写了蛮多的 JSDoc,感觉下来还是 TypeScript 更好一些,因为除了方便的智能提示,最重要的是它所带来的类型检查,这是 JSDoc 所提供不了的。
在之前使用 TypeScript
的过程中,其实遇到的比较烦人的一点就是针对后端服务接口 API 的类型定义,需要定义 Request
类型和 Response
类型,如果接口较少的情况下还好,接口多的情况下不但定义起来麻烦,而且代码量也不小,之前为了不让项目看起来过于混乱,会在单独的文件中手动去定义接口类型。好在我们使用的接口文档工具有提供相应的类型定义,后期有时候图省事就直接复制到项目中粘贴了,当然这样也是存在问题的,例如
- 接口字段做了变更,那就需要前端找到对应的接口定义,然后修改
- 字段的注释需要在写一遍 所以就想着有没有更方便的实现,能够将定义接口类型这一步做到无感知和自动化,查了相关资料后发现确实有一些方案的。
OpenAPI
在做这项工作之前,首先我们需要知道一个规范:OpenAPI
目前后端所定义的接口,基本上都是遵循 OpenAPI 规范的,它允许开发人员用机器可读的格式来记录 API,包括 API 路径、请求方法、请求参数以及响应格式等。以下是一个简单的 OpenAPI 的示例
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
),如 User
和 NewUser
。这些模式可以在不同的路径中重用。
有了这个规范那么要实现通过文档转换出 TypeScript 类型就简单很多了,社区也有了一些相关的项目可以看看:
- openapi-typescript-codegen - 根据 OpenAPI 规范生成 Typescript 客户端的 Node.js 库。(已不维护
- GitHub - hey-api/openapi-ts: ✨ Turn your OpenAPI specification into a beautiful TypeScript client - openapi-typescript-codegen 项目不维护后从其 fork 出来的新项目
- openapi-typescript -将 OpenAPI 3.0/3.1 模式转换为 TypeScript 类型并创建类型安全的获取。
你可以根据项目的需求以及自己的喜好选择对应的项目,我这里使用的是 openapi-typescript
, 我觉得它相对来说简单一点,因为我的需求就很简单,只需要提取 TypeScript 类型就好了。
ApiFox
公司使用的接口文档是 Apifox, 并且它提供了开放接口,可以用来获取某个项目的 OpenAPI 数据,你可以在 API Hub 中找到它的文档。
/v1/projects/{projectId}/export-openapi
接口可用来导出 OpenAPI/Swagger 格式数据,调用该接口需要两个参数:
projectId
- ApiFox 项目的 project Id,可在项目设置/基本信息中查看项目 IDaccess token
- API 访问令牌,在账号设置 - API 访问令牌中生成
设置了这两个参数之后,可以在 ApiFox 中运行接口测试下,一切没问题的话可以看到返回的 OpenAPI 格式的数据
OpenAPI 数据转 TS
使用 Fetch
有了 openApi
数据,就可以使用 openapi-typescript
进行转换类型的操作了,在 NextJS 项目中新建一个 api-to-ts.mjs
文件,用来处理转换,基本逻辑就是通过 fetch
请求 ApiFox
的开放接口获取 OpenAPI
格式的数据,然后调用 openapi-typescript
提供的方法,得到转换后的 typescript
类型文件,最后写入到项目文件中。
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_ID
和 APIFOX_TOKEN
我放在了 .env.local
环境变量文件中,并在 .gitignore
中添加对 .evn.local
文件的过滤,以确保敏感的数据不被提交到 Git 仓库中。
在 package.json
中新添加一个 script
:
"scripts": {
"api": "node --env-file .env.local script/api-to-ts.mjs",
}
通过 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
数据。
npx openapi-typescript api.json -o api.ts
api.json
是从ApiFox
中运行得到的 OpenAPi
数据,api.ts
是转换完成后生成文件,同样 CLI
也支持传入参数定制生成的数据格式。
最终得到的 api.ts
文件如下
创建类型工具
现在有了接口的类型定义,但是获取某个接口的请求参数、响应类型等依然还是有些麻烦,可以创建几个 TypeScript
工具类来方便获取
获取 Get 请求的参数类型
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
可以实现,在输入时的接口自动提示,效果如下
得到的类型为
可以检查下类型 A
的类型是否与 API
文档中定义的类型一致
获取 Post 请求的 Body 参数类型
/**
* 获取 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;
效果如下
获取接口的 Response 类型
GET
接口
POST
接口
Fetch 封装
一般稍微大一点的项目,都会有自己的一套请求方案,我们的 NextJS 项目中同样对 fetch
进行了一层封装来方便进行请求。openapi-typescript
也提供了基于类型安全的 fetch
包装器 openapi-fetch以及基于 @tanstack/react-query
的 openapi-react-query。
这两个包装器都能与 openapi-typescript
完美结合,无需泛型也不许手动输入,所有的请求、响应类型都是自动推断的,你可以尝试这两个项目来使用。我们项目中因为已经有了一个基于 fetch
的封转了,所以就不再使用 openapi-fetch
,而是在现有逻辑的基础上将其与 openapi-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;
核心代码结构如上,主要关心 GET
与 POST
两个方法的类型定义,通过函数重载的方式,让 GET
和 POST
请求支持不同的请求参数
- 支持请求
URL
的智能提示 - 如果请求的
api
是openapi-typescript
生成的类型文件中存在的,则自动推断出请求参数类型和响应类型 - 支持传入
openapi-typescript
不存在的 API(某些情况下),并且可以自定义参数类型和响应类型
使用效果
普通的 GET 请求
export async function getListExampleTwo() {
const data = await HTTP.GET('/val/match/home', {
page: '1'
});
return data;
}
可以自动推断出 GET
请求的 requry
类型和响应类型
自定义返回值类型
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;
}
编辑器查看效果:
请求函数需要透传参数的
使用 UseAPIQueryOptions
获取请求接口的参数类型
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 的方式来实现了
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
中定义
"scripts": {
"lint": "pnpm run check-types && next lint",
"check-types": "tsc --noemit",
}
使用 husky
实现在 commit
或者在发布之前执行 pnpm lint
对项目进行检测,已确保接口类型定义与代码中的实现是一样的。