一份契约,两端编译:OpenAPI + Orval 用结构化契约替代文本对齐,构建端到端编译期安全防线
在前后端协同开发中,接口定义的高频变动通常是效率瓶颈:
业务管理后台中,每个数据实体均涉及标准 CRUD 接口的定义与前端调用层封装; 表单模型调整字段名称或增删属性,前端需同步更新对应的数据模型与请求方法。 在缺乏确定性契约的场景下,依赖人工同步或通过自然语言让模型阅读 Git Diff 修改代码,容易消耗额外上下文,且极易出现类型遗漏或字段漂移。
契约驱动与编译期防线
将 OpenAPI 契约作为前后端沟通的桥梁。后端代码注解自动派生 swagger.yaml,前端 Orval 自动生成强类型 React Query SDK。类型漂移与重构中断全部在 tsc 编译期硬性拦截。
放大图表
通过这一流程,接口文档与类型系统保持同步,大幅降低跨端交互的心智与通信成本。
契约同步流程与端到端实践 以一个发帖与点赞互动的社交场景为例:
“后端实现点赞与取消点赞接口;前端帖子详情页渲染红心按钮,支持点击状态切换与计数实时刷新。”
前后端协作的核心依据,是由后端代码自动生成的 swagger.yaml。在 Go 处理函数上方配置结构化声明注解:
// 获取我点赞的帖子
//
// @Summary 获取我点赞的帖子
// @Tags likes
// @Security BearerAuth
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} render.Response[pageResponse[listPostsItemResponse]]
// @Failure 500 {object} render.errorResponse
// @Router /me/likes [get]
func ( h * LikeHandler ) ListLikedPosts ( w http . ResponseWriter , r * http . Request ) {
// ...
} swag 会解析这些注解,连同 render.Response[T] 泛型结构体中的字段与 json 标签,统一输出为标准 OpenAPI 契约文件。接口与注释位于同一源码文件中,变更天然保持一致。
swag init -d internal/api,internal/pkg/render -g routes.go --parseInternal --v3.1 -ot yaml 前端强类型 SDK 自动生成 在前端工程根目录配置 orval.config.ts:
import { defineConfig } from "orval" ;
export default defineConfig ({
bluebook : {
input : {
target : "./swagger.yaml" , // 接口契约规范来源
},
output : {
mode : "tags-split" , // 按 @Tags 划分模块文件
target : "src/api" , // 输出生成目录
client : "react-query" , // 生成 TanStack Query Hooks
httpClient : "axios" , // 底层 HTTP 客户端
formatter : "oxfmt" , // 使用 oxfmt 进行输出排版
override : {
mutator : {
path : "./src/mutator.ts" , // 注入自定义 Axios 请求实例
name : "customInstance" ,
},
},
},
},
}); 运行 pnpm gen:api,所有接口自动派生为强类型代码,并按 Tag 进行模块归档:
src/api/ api.schemas.ts posts/ likes/ users/ 每个接口展开为完整的强类型调用单元:
/**
* Generated by orval 🍺
* Do not edit manually.
*/
// 1. 强类型请求函数, 参数与返回值均具备类型推导
export const getPostsPostId = ( postId : string , options? : SecondParameter < typeof customInstance >, signal? : AbortSignal ) => {
return customInstance < RenderResponseApiGetPostsResponse >(
{ url : `/posts/ ${ postId } ` , method : "GET" , signal },
options ,
);
};
// 2. QueryKey 生成器, 保障缓存清理的类型确定性
export const getGetPostsPostIdQueryKey = ( postId : string ) => {
return [ `/posts/ ${ postId } ` ] as const ;
};
// 3. 内置 TanStack Query Hook, 在组件内直接使用
export function useGetPostsPostId < TData , TError = RenderErrorResponse >(...) { ... } mutator:统一收口底层传输与拦截逻辑代码生成器负责结构与类型映射,跨请求的业务通信特征(如统一响应解构、鉴权失效续期)则收口于 mutator.ts:
// 统一返回包装规范示例
{
"code": 200,
"msg": "操作成功",
"data": { ... }
} 针对双 Token 机制与全局错误拆包,可在 src/mutator.ts 中集中实现:
import Axios , {
AxiosError ,
type AxiosRequestConfig ,
type AxiosResponse ,
type InternalAxiosRequestConfig ,
} from "axios" ;
import { postAuthRefresh } from "@/api/auth/auth" ;
type ApiResponse = { code : number ; msg : string ; data? : unknown };
type RetriableRequestConfig = InternalAxiosRequestConfig & { _retriedAfterRefresh? : boolean };
const ACCESS_TOKEN_KEY = "access_token" ;
const REFRESH_TOKEN_KEY = "refresh_token" ;
const ME_KEY = "blue_book:me" ;
const UNAUTHENTICATED_AUTH_ENDPOINTS = new Set ([ "/auth/login" , "/auth/register" , "/auth/refresh" ]);
export class ApiError extends Error {
code : number ;
msg : string ;
data? : unknown ;
constructor ( msg : string , code : number , data? : unknown ) {
super ( msg );
this . name = "ApiError" ;
this . code = code ;
this . msg = msg ;
this . data = data ;
}
}
type ApiPayload < T > = T extends { data? : infer TData } ? TData : void ;
export const AXIOS_INSTANCE = Axios . create ({
baseURL : "/api/v1" ,
});
function isUnauthenticatedAuthEndpoint ( url? : string ) {
return UNAUTHENTICATED_AUTH_ENDPOINTS . has ( url ?? "" );
}
AXIOS_INSTANCE . interceptors . request . use (( config ) => {
const token = localStorage . getItem ( ACCESS_TOKEN_KEY );
if ( token && config . headers && ! isUnauthenticatedAuthEndpoint ( config . url )) {
config . headers . Authorization = `Bearer ${ token } ` ;
}
return config ;
});
let refreshPromise : Promise < string > | undefined ;
function clearSession() {
localStorage . removeItem ( ACCESS_TOKEN_KEY );
localStorage . removeItem ( REFRESH_TOKEN_KEY );
localStorage . removeItem ( ME_KEY );
window . dispatchEvent ( new Event ( "blue_book:session-expired" ));
}
async function refreshAccessToken() {
const refreshToken = localStorage . getItem ( REFRESH_TOKEN_KEY );
if ( ! refreshToken ) throw new ApiError ( "登录已过期,请重新登录" , 401 );
const auth = await postAuthRefresh ({ refresh_token : refreshToken });
if ( ! auth . access_token ) throw new ApiError ( "登录凭证刷新失败" , 401 );
localStorage . setItem ( ACCESS_TOKEN_KEY , auth . access_token );
if ( auth . refresh_token ) localStorage . setItem ( REFRESH_TOKEN_KEY , auth . refresh_token );
return auth . access_token ;
}
async function retryAfterRefresh ( config : RetriableRequestConfig ) {
if ( config . _retriedAfterRefresh || isUnauthenticatedAuthEndpoint ( config . url )) {
throw new ApiError ( "登录已过期,请重新登录" , 401 );
}
try {
refreshPromise ??= refreshAccessToken (). finally (() => {
refreshPromise = undefined ;
});
const token = await refreshPromise ;
config . _retriedAfterRefresh = true ;
config . headers . Authorization = `Bearer ${ token } ` ;
return AXIOS_INSTANCE ( config );
} catch {
clearSession ();
throw new ApiError ( "登录已过期,请重新登录" , 401 );
}
}
// 统一响应与状态拦截
AXIOS_INSTANCE . interceptors . response . use (
async ( response : AxiosResponse < ApiResponse >) => {
const { code , msg , data } = response . data ;
if ( code < 200 || code >= 300 ) {
const apiError = new ApiError ( msg , code , data );
if ( code === 401 ) return retryAfterRefresh ( response . config as RetriableRequestConfig );
throw apiError ;
}
return response ;
},
async ( error : AxiosError < ApiResponse >) => {
if ( error . response ? . data ) {
const { code , msg , data } = error . response . data ;
if ( code === 401 || error . response . status === 401 ) {
return retryAfterRefresh ( error . config as RetriableRequestConfig );
}
throw new ApiError ( msg , code , data );
}
throw error ;
},
);
export const customInstance = < T >(
config : AxiosRequestConfig | string ,
options? : AxiosRequestConfig ,
) : Promise < ApiPayload < T >> => {
const requestConfig : AxiosRequestConfig =
typeof config === "string" ? { url : config , ... options } : { ... config , ... options };
return AXIOS_INSTANCE ( requestConfig ). then (({ data }) => data . data as ApiPayload < T >);
};
export default customInstance ; 错误信封解析与 Token 静默刷新在网络层实现单点收口,上层调用无需编写防御样板代码。
业务组件实现与编译期校验 引入自动生成的 SDK 后,组件只需聚焦于状态流转与交互呈现:
import {
useDeletePostsPostIdLike ,
useGetPostsPostId ,
usePutPostsPostIdLike ,
} from "@/api/posts/posts" ;
import { ApiError } from "@/mutator" ;
const postQuery = useGetPostsPostId ( postId );
const likeMutation = usePutPostsPostIdLike ();
const unlikeMutation = useDeletePostsPostIdLike ();
const post = postQuery . data ;
const handleLike = async () => {
if ( ! post ) return ;
try {
if ( ! post . viewer_liked ) await likeMutation . mutateAsync ({ postId });
else await unlikeMutation . mutateAsync ({ postId });
} catch ( err ) {
if ( err instanceof ApiError ) toast . error ( err . msg );
}
}; 当后端将 like_count 字段重构为 count 时,仅需触发构建:
# 后端重新导出契约
make swagger
# 前端依据新契约生成 SDK 并执行类型检查
pnpm gen:api && pnpm build TypeScript 编译器输出明确的不兼容定位:
src/routes/PostDetailPage.tsx:87:30: error TS2339: Property 'like_count' does not exist on type 'RenderResponseApiGetPostsResponse'. Did you mean 'count'? Agent 根据编译诊断清单即可完成针对性修复:
- <span>{post.like_count} 点赞</span>
+ <span>{post.count} 点赞</span>
将跨端联调验证收敛为确定性的编译检查回路。
Agent 提示词配置实践 在前端工程的 package.json 中配置生成脚本:
{
"scripts" : {
"gen:api" : "orval"
}
} 前端工程配置规范(AGENTS.md):
### 前端 API 使用规范
1. `swagger.yaml` 是接口定义的唯一来源;运行 `pnpm gen:api` 重新生成 `src/api` 。
2. 严禁手动修改或新增 `src/api` 目录下的任何文件,其归属自动化工具生成。
3. 所有数据请求必须调用 `src/api` 中生成的方法,禁止在组件中直接手写 `fetch` 或重复声明响应类型。
4. 请求异常优先展示 `ApiError.msg` 。 后端工程配置规范:
### 后端接口规范
1. 接口注解或响应类型变更后,必须执行 `make swagger` 重新生成 `docs/swagger.yaml` 。
2. 注解统一使用泛型 `render.Response[T]` ,保持 `@Success` 注释格式规范,避免声明松散的无类型结构。 契约驱动协同流水线 后端注解声明 :Go Handler 顶部编写 OpenAPI 注解并绑定 render.Response[T] 泛型结构。导出契约规范 (make swagger) :swag 静态分析 AST 导出最新的 swagger.yaml 规格文件。派生强类型 SDK (pnpm gen:api) :Orval 自动生成按模块分类的 React Query Hooks 与 Schemas。端到端类型校验 (pnpm build) :TypeScript 编译器执行全量类型断言,字段重构时直接输出行号级报错闭环。