用 OpenAPI 契约驱动前端类型生成与接口测试
起因是一堆对不上的字段
前段时间在改一个后台项目,一个列表页的字段明明显示正常,复制出来的表格数据却是空的。查了半天,后端在某个版本里把 userName 改成了 username,而前端那个 interface 是几个月前照着接口文档手抄的,抄完之后再也没人碰过它。TS 也不会报错,因为从接口返回的数据本来就是 any,然后我们手动 as User[] 了一下,整个过程它都不吭声。
这类事我遇到过不止一次。更烦的是每次后端更新接口,你永远不知道哪个页面会漏。看文档?文档可能过期;看代码?后端代码你也不一定想翻。后来我就想,既然后端已经在用 OpenAPI 输出了,那为什么还要手写类型?让机器去抄,抄得比人准,还不会漏。
后端其实已经把契约给你了
现在 Java 那边用 springdoc,Go 那边用 swag,Node 那边用 swagger-jsdoc,反正只要启动起来,基本都会有个 /v3/api-docs 或者 /swagger.json。我这边的做法是直接把这份 JSON 拉下来,存进前端仓库,比如 ./openapi.json。
如果你连拉都懒得拉,也可以直接对着开发环境的地址生成。不过我更建议存一份到仓库,好处是接口变化在 git 里能看到 diff,review 的时候顺便就审了一遍契约。
curl -s http://localhost:8080/v3/api-docs -o openapi.json
用 openapi-typescript 生成纯类型
我试过几个工具。openapi-generator 功能最全,但它会给你生成一整套 client,axios 封装、枚举、错误类,全塞进来,跟我项目里已有的 request 封装打架。orval 也不错,还能顺手生成 react-query 的 hooks,但配置起来比较重。
最后我还是用 openapi-typescript,原因很简单:它只生成类型,一个字节的运行时代码都没有。
npm i -D openapi-typescript
npx openapi-typescript ./openapi.json -o src/types/api.ts
生成出来的文件长这样,paths 里是每个接口的请求和响应,components["schemas"] 里是每个数据模型。
import type { paths, components } from "@/types/api";
type User = components["schemas"]["User"];
type UserListResp =
paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"];
这里有个小坑,responses 的 key 是字符串 "200",不是数字 200,写错了 TS 会给你一堆 never,我第一次就是这么写的。
类型有了,怎么用起来
只生成类型还不够,得让请求函数真的把类型带出来,不然大家还是习惯 as any。
type GetJson<P extends keyof paths> = paths[P] extends {
get: { responses: { 200: { content: { "application/json": infer R } } } };
}
? R
: never;
async function getJson<P extends keyof paths>(url: P): Promise<GetJson<P>> {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url} ${res.status}`);
return res.json() as Promise<GetJson<P>>;
}
// 调用处直接就有类型了
const list = await getJson("/api/users");
带路径参数的接口 "/api/users/{id}" 不能直接当 URL 用,我另外写了个小函数,把 {id} 替换掉,顺便把 path 的泛型约束成必须包含 { 的那些,这块就不展开了,反正写一次能用很久。
顺手把 mock 也接了
这件事的真正好处在联调前就出现了。以前前端要等后端把接口写完才能动手,现在拿着 openapi.json 直接起一个 mock server。
npx @stoplight/prism-cli mock ./openapi.json -p 4010
Prism 会按 schema 生成符合结构的数据,enum 只从枚举里挑,required 的字段一定给,连 format: date-time 都给你一个像模像样的时间字符串。前端把 baseURL 指到 http://localhost:4010 就能开工了,后端那边还在写 SQL。
这个 mock 还有一个隐性好处:它严格按契约来,你在 mock 上跑通了,说明你的字段访问方式跟契约一致。等真接口上来,理论上只可能因为数据内容出问题,不会因为字段名出问题。
契约漂移检查
光靠自觉是不行的,我见过太多次“这次先不改生成文件了”然后就没有然后了。所以 CI 里加一步:
{
"scripts": {
"api:types": "openapi-typescript ./openapi.json -o src/types/api.ts",
"api:check": "npm run api:types && git diff --exit-code src/types/api.ts"
}
}
有人更新了 openapi.json 但忘了重新生成类型,git diff 就会返回非零,流水线直接红。这个卡点非常粗暴,也非常有效。
后端那边如果想更严格一点,还可以拿 schemathesis 对着运行中的服务跑一轮契约测试,它会读 OpenAPI 文档自动构造请求,专挑边界值和非法输入打:
uvx schemathesis run http://localhost:8080/v3/api-docs --checks all
我们目前只在前端仓库做了 api:check,因为后端同学的契约质量还行,暂时没到需要自动 fuzz 的程度。但如果你的后端经常改字段不写文档,这个工具值得试。
几个踩过的坑
第一个坑是 nullable。Java 那边的 Integer 字段,生成出来是 number | null。我一开始觉得这是工具的问题,后来发现人家写得没错,是我自己的代码没处理 null。把 strictNullChecks 打开之后,一堆 object is possibly 'null' 冒出来,那都是真 bug,不是噪音。
第二个坑是 oneOf 和 anyOf。有些接口的返回值是“成功时是 A,失败时是 B”,工具会老老实实生成联合类型,然后你就得在业务代码里写类型守卫。我觉得这是好事,总比 as 一下强,但确实会让人觉得麻烦。
第三个是枚举。后端如果用的是 @JsonValue 之类的自定义序列化,契约里可能只是 string,生成出来就是 string,拿不到字面量类型。这种情况只能去改后端契约,前端没办法补救。
第四个是文件大小。我们项目全量生成大概 4000 多行,编辑器偶尔会卡一下。可以按模块拆几个 schema 文件分别生成,但我觉得没必要,现在这样还能接受。
值不值得折腾
说实话,这套东西的初始成本大概是一天。配脚本、写那个 getJson 封装、把已有的 interface 全删掉换成生成的类型,会有一波集中的红波浪线,看着挺吓人的。
但换来的是:接口改了你能立刻知道,联调前能先跑起来,字段拼错在编辑器里就报错。这三个收益里,我觉得第一个最值钱,因为它解决的不是效率问题,而是“你根本不知道自己漏了什么”的问题。
当然也不是所有项目都适合。如果一共就五个接口,一年也不改一次,手写 interface 真没啥问题,别为了工具而工具。但只要你到了“改完后端之后,需要挨个页面试一遍”的程度,那这套东西就值得上。
我们现在的流程是:后端改契约 → 提交 openapi.json → 前端 CI 报红 → 前端跑一次 api:types → review 里看到类型 diff → 该改的地方 TS 全都指出来。整个过程没有一句沟通,但该知道的事都知道了。我觉得这就够了。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。