React 和 REST API:基于 OpenAPI 文档的端到端 TypeScript
当您处理从 REST API 获取数据的 React 和 TypeScript 项目时,保持数据类型同步可能会有问题。
你当然可以手动创建前端的所有类型。但这是一个繁琐且容易出错的过程。你可能会弄错类型,或者 API 在你不知情的情况下更改并破坏了前端代码。
理想情况下,前端的类型应该始终与后端保持同步。自动同步。不会再出现错误类型。API 更新后也不会再焦虑。TypeScript 会在将错误推送到生产环境之前发出警告。
实现这种端到端类型安全的方法有很多。你可能有一个包含共享类型的 TypeScript Monorepo。恭喜你。但是,由于某些原因,例如你的后端是用其他编程语言编写的,这个选项可能不可行。
尽管如此,如果您的 REST API 使用 OpenAPI 标准(这在生产服务器中非常常见),您仍然可以轻松受益于端到端类型安全。代码生成器可以为您处理大部分繁琐的工作。在本页面,您可以了解它的工作原理。
目录
我们的 REST API
为了本文,我创建了一个 REST API 以及一个 OpenAPI(以前称为 Swagger)文档。这里我不想赘述细节,但它是用 Nest.js 及其 Swagger 插件构建的。
该 API 托管于https://prolog-api.profy.dev/issue,https://prolog-api.profy.dev
您可以访问https://prolog-api.profy.dev/issue查看示例端点的运行情况。它是我为React Job Simulator创建的错误跟踪应用程序(类似于 Sentry)的一部分。
示例:我们的 REST API 的 OpenAPI 文档
如果您从未看过 OpenAPI 或 Swagger 文档,请允许我快速带您了解一下这个示例。您可以在https://prolog-api.profy.dev/api查看文档。打开此页面后,您可以看到所有可用的端点(这里甚至包括这些端点的不同版本)。
当您单击其中任何一个端点时,您可以看到所有可用查询参数、它们的类型和可用选项(对于枚举)的概述。
另外,请注意右上角的“试用”按钮。这允许您直接从文档向 API 发送请求并检查响应。
在查询参数列表下方,您可以看到此端点的示例响应。
有关可能值的更多详细信息,您还可以检查架构。
您可以看到返回的类型(例如,items
是一个Issue
数组)。在某些情况下,使用枚举(例如Issue.level
),您可以看到所有可能的值。如果您需要在前端使用这些枚举值,这将非常有帮助。您无需猜测所有可能的值,而是可以直接在文档中看到它们。
如何将 OpenAPI 转换为 TypeScript
现在该开始动手了。有很多方法可以从 OpenAPI 文档生成 TypeScript。每个选项都使用文档的 JSON 表示形式(您可以在https://prolog-api.profy.dev/api-json找到我们的 JSON 表示形式)。
在此页面上,我们将使用名为Orval的代码生成器。
Orval 似乎是一个不错的选择,因为它维护活跃,GitHub 上拥有大量 star,并且支持丰富的功能。它不仅可以生成类型,还支持生成 fetch 函数以及react-query
hooks。
使用 Orval 生成 TypeScript
使用 Orval 生成类型和获取函数非常简单。我们只需在项目中添加一个简单的配置文件,告诉 Orval
- 从哪里获取 OpenAPI JSON
- 在哪里写入 TypeScript 输出。
// orval.config.js
module.exports = {
api: {
input: "https://prolog-api.profy.dev/api-json",
output: "./api/generated-api.ts",
},
};
事实上,你甚至不需要这个配置文件,而是可以使用 CLI 选项。使用文件仍然更灵活,也更易于维护。
现在我们只需通过 npx 执行 Orval。
npx orval
这将在配置文件中定义的输出目标处创建一个新文件。我们稍后会看一下这个文件。但首先……
部署前自动生成类型
好的,生成类型非常简单。这省去了我们手动创建类型的繁琐工作。
但正如本页开头所述,如果我们能够自动保持类型与 REST API 同步就太好了。这样,我们就能在将代码部署到生产环境之前意识到前端的类型是否已过时。
不幸的是,我们无法真正保持类型同步。这需要我们监听 API 更新。不过,我们可以在构建和部署前端之前更新类型。这样,我们至少可以在部署之前捕获任何错误。
实现此目的的一个简单方法是使用 npmpre*
或post*
脚本package.json
。
// package.json
{
"scripts": {
"predev": "npm run generate-api",
"dev": "next dev",
"prebuild": "npm run generate-api",
"build": "next build",
"start": "next start",
"generate-api": "orval"
},
...
}
使用这些脚本,我们在运行dev
orbuild
命令之前先运行 Orval(也就是生成输出类型)。现在,我们可以确保始终使用最新的类型进行工作和部署。
或者,我们也可以
generate-api
在 中运行脚本postinstall
。当项目部署在像 GitHub Actions 这样的 CI 流水线中时,这种方法很有效。在这里,我们通常从头开始安装 npm 依赖项。
由于类型现在是自动创建的,我们不再需要在 Git 仓库中检查生成的代码。因此,我们可以在.gitignore
文件中添加新行。
// .gitignore
# generated api file
api/generated-api.ts
生成代码概述
正如承诺的那样,现在让我们看一下生成的代码。我们从一些简单类型的简短示例开始:
// ./api/generated-api.ts
/**
* Generated by orval v6.10.2 🍺
* Do not edit manually.
* ProLog API
* The ProLog API documentation
* OpenAPI spec version: 1.0
*/
import axios from 'axios'
import type {
AxiosRequestConfig,
AxiosResponse
} from 'axios'
export const ProjectStatus = {
error: 'error',
warning: 'warning',
info: 'info',
} as const;
export const ProjectLanguage = {
react: 'react',
node: 'node',
python: 'python',
} as const;
export interface Project {
id: string;
name: string;
language: ProjectLanguage;
numIssues: number;
numEvents24h: number;
status: ProjectStatus;
}
export const projectControllerFindAll = <TData = AxiosResponse<Project[]>>(
options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`/v2/project`,options
);
}
export const projectControllerFindOne = <TData = AxiosResponse<Project>>(
id: string, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`/v2/project/${id}`,options
);
}
在顶部,我们可以找到响应中使用的类型定义。ProjectStatus
并且是接口/类型ProjectLanguage
使用的枚举。Project
在底部,我们还可以看到两个函数。我们可以用它们从/v2/project
和/v2/project/{id}
端点获取数据。这两个函数已经设置了正确的参数和响应类型。无需再为此操心。
默认情况下,Orval 生成的代码使用 Axios 发送 API 请求。但我们也可以使用自定义客户端(稍后会看到)。
上面的类型和函数名称相当清晰简洁。但我们还可以找到更详细的类型和函数,例如这些(即使你暂时无法理解也不用担心):
export type IssueControllerFindAllV2200AllOf = {
items?: Issue[];
};
export type IssueControllerFindAllV2200 = PageDto & IssueControllerFindAllV2200AllOf;
export const IssueControllerFindAllV2Level = {
error: 'error',
warning: 'warning',
info: 'info',
} as const;
export const IssueControllerFindAllV2Status = {
open: 'open',
resolved: 'resolved',
} as const;
export type IssueControllerFindAllV2Params = { page?: number; limit?: number; status?: IssueControllerFindAllV2Status; level?: IssueControllerFindAllV2Level; project?: string };
export type IssueControllerFindAll200AllOf = {
items?: Issue[];
};
export type IssueControllerFindAll200 = PageDto & IssueControllerFindAll200AllOf;
export const IssueControllerFindAllLevel = {
error: 'error',
warning: 'warning',
info: 'info',
} as const;
export const IssueControllerFindAllStatus = {
open: 'open',
resolved: 'resolved',
} as const;
export type IssueControllerFindAllParams = { page?: number; limit?: number; status?: IssueControllerFindAllStatus; level?: IssueControllerFindAllLevel; project?: string };
export const issueControllerFindAll = <TData = AxiosResponse<IssueControllerFindAll200>>(
params?: IssueControllerFindAllParams, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`/issue`,{
...options,
params: {...params, ...options?.params},}
);
}
export const issueControllerFindAllV2 = <TData = AxiosResponse<IssueControllerFindAllV2200>>(
params?: IssueControllerFindAllV2Params, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`/v2/issue`,{
...options,
params: {...params, ...options?.params},}
);
}
这些类型和函数用于问题端点的版本 1 和 2(/issue
和/v2/issue
)。有些名称很难阅读,例如type IssueControllerFindAllV2200
版本(V2
)和响应状态码(200
)。
创建类型和函数包装器
正如你已经注意到的,我不太喜欢这样的名字IssueControllerFindAllV2200
。但更重要的是,这些名字可能会因为各种原因而改变。例如:
- 每当有新版本的 API 时,我们可能必须突然切换到
IssueControllerFindAllV3200
(注意 而V3
不是V2
)。 - 我们可能想要换出代码生成库,然后突然必须使用完全不同的类型和功能。
因此,如果我们将应用程序与生成的 TypeScript 代码耦合得太紧,可能需要花费大量精力来适应这些变化。我们可以通过包装生成的类型和函数,并在代码中使用这些包装器来降低这种风险。
让我们从一个简单的例子开始:我们端点的类型和函数/v2/project
。我们在应用程序代码中使用的所有类型都可以简单地重新导出。
// api/projects.types.ts
export { ProjectLanguage, ProjectStatus } from "./generated-api";
export type { Project } from "./generated-api";
我们还可以包装 fetch 函数。这使我们能够选择一个更好的名称并data
从 Axios 响应中提取对象。
// api/projects.ts
import { projectControllerFindAll } from "./generated-api";
export async function getProjects() {
const { data } = await projectControllerFindAll();
return data;
}
这看起来可能没什么大不了的。但还记得吗?我们还看到,v2/issue
端点的类型和功能更加冗长。
包装类型有助于我们向其余代码库公开改进的名称。
// api/issues.types.ts
export { IssueLevel } from "./generated-api";
export type {
Issue,
IssueControllerFindAllV2200 as IssuePage,
IssueControllerFindAllV2Params as IssueFilters,
} from "./generated-api";
另一方面,包装 fetch 函数允许我们稍微重新定义函数参数并再次data
从 Axios 响应中提取对象。
// api/issues.ts
import { issueControllerFindAllV2 } from "./generated-api";
import { IssueFilters } from "./issues.types";
export async function getIssues(
page: number,
filters: Omit<IssueFilters, "page">,
options?: { signal?: AbortSignal }
) {
const { data } = await issueControllerFindAllV2(
{ page, ...filters },
{ signal: options?.signal }
);
return data;
}
有了这个包装器,我们现在可以简单地将issueControllerFindAllV2
函数替换为任何新版本,而无需修改任何其他代码。
使用 react-query 的包装器
如上所述,除了创建类型和获取函数之外,Orval 还可以生成react-query
钩子。虽然这很有用,但也有一些限制。例如,我们无法在钩子中添加其他逻辑。
这就是为什么我宁愿react-query
手动创建钩子,并且只使用生成的 fetch 函数和类型(实际上是我们的包装器)。以下是一个例子:
import { useQuery } from "@tanstack/react-query";
import { getIssues } from "@api/issues";
import type { IssuePage } from "@api/issues.types";
export function IssueList({ page }) {
const issuePage = useQuery(
["issues", page],
({ signal }) => getIssues(page, { status: "open" }, { signal }),
{ keepPreviousData: true }
);
return (
<table>
<tbody>
{(issuePage.data?.items || []).map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
/>
))}
</tbody>
</table>
);
}
别误会,我不建议直接将钩子添加到组件中。而是应该像上一篇文章中描述的那样,将其分离到 API 层中。
正如您所看到的,我们组件中的数据现在已正确输入。
事实上,在本文使用的React Job Simulator项目中,我最初是手动创建所有类型的。而 Job Simulator 中的首要任务之一就是修复由错误类型导致的 bug。
通过我们生成的代码,我们立即知道存在问题。
使用自定义 Axios 实例
我们的代码现在已经正确输入,并且与后端同步了。但不幸的是,它还不能正常工作。
Orval 默认在生成的 fetch 函数中不使用基础 URL。以下是生成代码的一个简化示例:
export const projectControllerFindAll = (options) => {
return axios.get(
`/v2/project`,options
);
}
我们的服务器与前端不在同一个域名下。因此,目前我们的 API 请求会返回 404。我们必须设置一个基准 URL。
如前所述,Orval 允许我们创建自定义 HTTP 客户端。因此,我们可以创建一个全局 Axios 实例,在其中设置基本 URL,并将此实例用作自定义 HTTP 客户端。
// ./api/axios.ts
import Axios, { AxiosRequestConfig } from "axios";
// our global Axios instance including the base URL
const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});
// this function was taken from the Orval docs
export default async function customInstance<T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig
): Promise<T> {
const { data } = await axios({ ...config, ...options });
return data;
};
有关该
customInstance
函数的更多信息,请查看 Orval 文档。
现在我们只需要告诉 Orval 使用这个自定义实例。这很简单,只需在mutator
配置中添加选项即可。
// ./orval.config.js
module.exports = {
api: {
input: "https://prolog-api.profy.dev/api-json",
output: {
target: "./api/generated-api.ts",
override: {
mutator: "./api/axios.ts",
},
},
},
};