Polar 内部 API 客户端@polar-sh/client深度解析:OpenAPI 驱动的类型安全前端调用层
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
@polar-sh/client是 Polar 仓库中供前端应用使用的生成式 API 客户端,它由后端 OpenAPI Schema 自动生成,并基于openapi-fetch+openapi-typescript构建了完整的端到端类型安全调用链。本文将以 clients/packages/client/README.md 为骨架,结合该包的源码、生成脚本、上游补丁以及前端 app 中的真实消费方式,深入讲解它的设计原理、核心 API、错误处理模型与工程化最佳实践,读完即可在自己的 Polar 相关前端项目中复现这套方案。
一、包定位:Polar 前端的「唯一 API 出口」
按 clients/packages/client/README.md 的定义:该包包含 Polar 前端应用内部使用的生成式 API 客户端,由后端的 OpenAPI Schema 生成。它的package.json(clients/packages/client/package.json)给出了更完整的定位信息:
- 包名:
@polar-sh/client,描述为 "Polar Internal API Client"; - 许可证:Apache-2.0,声明为
private: true(虽然publishConfig.access为public,但本质上是仓库内部的 workspace 包); - 产物形态:同时输出
dist/index.cjs(CommonJS)与dist/index.js(ESM),类型声明为dist/index.d.ts,导出映射exports["."]同时提供types与default两个条件入口; - 依赖核心:
openapi-fetch@^0.15.0、openapi-typescript-helpers@^0.0.15与date-fns(用于指标时间范围计算)。
在clients/这个 pnpm workspace 中,它被apps/app(Expo 移动端)、apps/web(Next.js 站点)等多个应用同时依赖。换句话说,整个 Polar 前端的网络层都收敛在这一处,后端的路由、Schema、枚举变化都只需重新生成这一个包,即可同步到所有前端应用。
二、从 OpenAPI 到 TypeScript 的生成流水线
2.1 生成脚本做了什么
package.json中的generate脚本是理解整个包的钥匙:
"generate": "uv run --directory ../../../server/ -m scripts.generate_openapi | openapi-typescript --enum-values -o ./src/v1.ts && oxfmt ./src && pnpm run build"这条命令链做了四件事:
- 产出 OpenAPI Schema:在
server/目录下用uv run执行 Python 模块scripts.generate_openapi,把 JSON Schema 打印到标准输出。该脚本(server/scripts/generate_openapi.py)通过get_openapi(version, routes_for_version(...), get_webhook_routes())汇总 FastAPI 路由与 webhook 路由,并支持按 API 版本生成; - 生成 TS 类型:把 Schema 管道输入
openapi-typescript --enum-values,写入 clients/packages/client/src/v1.ts。--enum-values会让枚举保留为字面量联合,从而支持从v1.ts中 re-export 出运行时可用的枚举值数组; - 格式化:用
oxfmt统一格式化src/下所有文件; - 构建产物:执行
pnpm run build(即tsup),产出 ESM/CJS 双格式并生成类型声明。
2.2 生成产物的规模与内容
生成的 src/v1.ts 是一份约 7.2 万行的自动生成文件,文件头明确声明:
/** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */它导出了三组核心类型:paths(所有路由的请求/响应形状)、components(Schema 与枚举定义)、operations(按 operationId 索引的操作类型)。由于文件是生成的,任何手工修改都会在下次pnpm generate时被覆盖——这是该包最重要的使用约束之一。
2.3 上游补丁:为了让 7 万行类型“活”下来
仓库里还保留了一份针对生成器的补丁 clients/patches/openapi-typescript@7.10.1.patch,例如给transformSchemaObject增加fromAdditionalProperties参数、给oapiRef增加deep参数。这印证了项目为了在**复杂 Schema(嵌套 additionalProperties、深层引用)**下正确生成类型,对生成器本身做了针孔式修复,也说明该客户端对类型完整性的要求极高。
三、运行时客户端:createClient的组装逻辑
包的运行时核心在 src/index.ts:
export const createClient = ( baseUrl: string, token?: string, headers?: HeadersOptions, ) => ({ ...createOpenAPIFetchClient<paths>({ baseUrl, credentials: 'include', headers: { ...(headers ? headers : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }), baseUrl, })三个入参的含义与默认行为:
| 参数 | 类型 | 说明 |
|---|---|---|
baseUrl | string | API 根地址,如https://api.polar.sh,必填 |
token | string \| undefined | 可选的 Bearer Token,存在时自动注入Authorization: Bearer <token>请求头 |
headers | HeadersOptions \| undefined | 额外请求头,与 token 头合并 |
内部实现基于openapi-fetch的createOpenAPIFetchClient<paths>,因此所有 HTTP 方法(GET/POST/PATCH/DELETE 等)都按路径强类型化:路径、查询参数、请求体、响应体的类型全部从paths推导,写错参数名或类型会在编译期直接报错。credentials: 'include'表明客户端默认携带跨域 Cookie,适合与后端 session 认证配合;baseUrl也被挂在返回对象上,方便调用方读取。
3.1 真实调用形态
以 clients/apps/app/providers/PolarClientProvider.tsx 为例,移动端在 React Context 中创建客户端:
const polar = useMemo(() => { const client = createClient( process.env.EXPO_PUBLIC_POLAR_SERVER_URL ?? 'https://api.polar.sh', session ?? '', CLIENT_VERSION_HEADERS, ) client.use(refreshMiddleware) return client }, [session])要点:
- baseUrl 可配置:通过
EXPO_PUBLIC_POLAR_SERVER_URL环境变量注入,缺省回退到生产地址https://api.polar.sh; - Token 来自 session:登录态变化时通过
useMemo依赖重建客户端; - 版本头随请求发送:
CLIENT_VERSION_HEADERS携带X-Polar-Client-Version(如mobile/1.0.0)、X-Polar-Client-Runtime(Expo runtimeVersion)、X-Polar-Client-Update(OTA updateId),方便后端区分是哪个构建在调用; - Middleware 扩展:
client.use(refreshMiddleware)挂上 openapi-fetch 的中间件,实现 token 刷新逻辑。
四、类型安全三件套:unwrap、错误类与schemas
4.1unwrap:把“结果三态”收敛为“要么数据、要么抛错”
openapi-fetch 的请求返回{ data, error, response }三态结构,业务代码若每个请求都手动判断会非常啰嗦。src/index.ts 提供的unwrap把这个过程统一收敛:
export const unwrap = async <T, Options, Media>( p: Promise<FetchResponse<T, Options, Media>>, handlers?: { [status: number]: (response: Response) => never }, ): Promise<ParseAsResponse<SuccessResponse<ResponseObjectMap<T>, Media>, Options>> => { const { data, error, response } = await p if (handlers) { const handler = handlers[response.status] if (handler) return handler(response) } if (response.status === 429) { throw new TooManyRequestsResponseError({ message: 'Too Many Requests' }, response) } if (error) { if (response.status === 401) throw new UnauthorizedResponseError(error, response) else if (response.status === 404) throw new NotFoundResponseError(error, response) throw new ClientResponseError(error, response) } if (!data) throw new Error('No data returned') return data }行为优先级:
- 自定义 handlers 优先:可按状态码注入自定义处理(返回
never,常用于重定向或兜底); - 429 限流:直接抛
TooManyRequestsResponseError; - 401/404 特化:分别抛
UnauthorizedResponseError与NotFoundResponseError; - 其他错误:统一抛
ClientResponseError; - 成功但无数据:抛普通
Error('No data returned'),避免undefined污染类型。
4.2 错误类继承体系
export class ClientResponseError extends Error { error: ClientResponseErrorBody response: Response constructor(error: ClientResponseErrorBody, response: Response) { ... } } export class UnauthorizedResponseError extends ClientResponseError { ... } export class NotFoundResponseError extends ClientResponseError { ... } export class TooManyRequestsResponseError extends ClientResponseError { ... }ClientResponseErrorBody = Record<string, unknown> & { message?: string }允许访问后端返回的任意错误字段。所有特化错误都继承自ClientResponseError,因此前端只需捕获基类即可覆盖全部错误场景,同时又能按子类精确处理 401(去登录)或 429(退避重试)。
4.3schemas与校验辅助
export type schemas = components['schemas'] export type Client = ReturnType<typeof createClient> export const isValidationError = (detail: unknown): detail is { loc: ...; msg: string; type: string }[] => ...schemas是全部业务 Schema 的类型别名,供 UI 层直接引用,例如 clients/apps/app/components/Metrics/utils.ts 中的schemas['Metric']、schemas['Organization']。isValidationError是类型守卫,用于识别 FastAPI 风格的校验错误结构(loc/msg/type数组)。
五、前端 hooks 中的真实用法:React Query + unwrap
Polar 前端把客户端与 TanStack Query 深度绑定。以 clients/apps/app/hooks/polar/customers.ts 为例:
export const useCustomer = (organizationId: string | undefined, id: string) => { const { polar } = usePolarClient() return useQuery({ queryKey: ['customers', organizationId, { id }], queryFn: () => unwrap( polar.GET('/v1/customers/{id}', { params: { path: { id } }, }), ), enabled: !!organizationId, }) }值得注意的细节:
- 路径参数强类型:
'/v1/customers/{id}'是模板字符串类型,params.path.id必须匹配;enabled: !!organizationId在组织未就绪时避免无效请求; - 分页查询复用类型:
useCustomers通过operations['customers:list']['parameters']['query']直接复用后端 operationId 的查询参数类型做Omit,保证前后端参数契约完全一致,分页用useInfiniteQuery的pageParam驱动。
同样的模式遍布 hooks/polar/checkout_links.ts、hooks/polar/custom_fields.ts、hooks/polar/finance.ts 等文件,构成一套可复制的「hook 模板」。
六、刷新中间件:client.use 的工程化示例
clients/apps/app/auth/refreshMiddleware.ts 展示了Middleware的完整用法:
- onRequest:跳过 OAuth 端点;若本地 refresh token 存在且 access token 过期,先
refreshAccessToken()再重写Authorization头;否则用最新 token 校正请求头; - onResponse:遇 401 且不是 OAuth 端点且有 refresh token 时,刷新后用新 token重放原请求(
options.fetch(retry))。
该中间件还配有单元测试 clients/apps/app/auth/refreshMiddleware.test.ts,对onRequest/onResponse的输入输出进行契约化验证,可作为编写自定义 Middleware 的参考模板。
七、metrics 工具:把时间范围算明白
src/metrics.ts 为指标 API 提供时间范围计算:
export type MetricsRange = '24h' | '30d' | '3m' | '12m' | 'today' | 'all_time' export const getMetricsRangeDates = (range, options?): [Date, Date] => { ... }实现要点:
- 基于
date-fns的subDays/subMonths/subYears/startOfDay,now可注入以便测试; - 30d 用
subDays(end, 29):注释明确解释“用 29 天而不是 30 天,使指标 API 返回的含首尾两天的日桶恰好覆盖 30 天(含今天)”; all_time必须提供createdAt(如组织创建时间),否则抛错。
前端在 clients/apps/app/components/Metrics/utils.ts 中组合使用它构造24h/30d/3m/all_time等预设区间,并依据区间跨度推导聚合粒度(≥3 年用年、≥4 个月用月、>4 周用周、>1 天用天、否则用小时)。
八、构建与工程约束
8.1 tsup 双格式构建
clients/packages/client/tsup.config.ts:
export default defineConfig([ { entry: ['src/index.ts'], format: ['cjs', 'esm'], minify: true, dts: process.env.POLAR_SKIP_DTS !== '1', }, ])- 同时产出 CJS 与 ESM,满足不同消费方;
- 生产构建默认压缩;设置
POLAR_SKIP_DTS=1可跳过类型声明生成(调试加速); - 类型声明默认开启,与
package.json的types字段对应。
8.2 共享 tsconfig
tsconfig.json 继承@polar-sh/typescript-config/bundled.json(见 clients/packages/typescript-config),设置outDir: dist、rootDir: src,仅编译src。包内还提供typecheck脚本(tsc --noEmit)用于 CI 校验。
8.3 枚举 re-export
src/enums.ts 从生成的v1.ts中 re-export 一批运行时可用的枚举值数组,例如orderStatusValues、checkoutStatusValues、benefitTypeValues、recurringIntervalValues、webhookEventTypeValues等。这些数组可直接用于下拉选项渲染、前端校验与参数序列化,避免手写枚举与后端漂移。
九、结语与延伸阅读
@polar-sh/client的价值可以概括为三句话:
- 单一事实来源:API 契约只存在于后端的 FastAPI/OpenAPI 定义中,前端类型全部自动生成,杜绝手工维护 API 层;
- 端到端类型安全:
openapi-fetch+paths/operations/schemas类型 +unwrap收敛错误,让“写错接口”从运行时错误提前到编译期错误; - 可扩展的工程基础:
createClient的三参数设计、Middleware钩子、可注入的now、可测试的纯函数,为 token 刷新、版本上报、指标聚合等复杂场景提供了干净的扩展点。
若要继续深入,推荐按以下路径阅读仓库源码:
- 生成入口:server/scripts/generate_openapi.py,了解 Schema 如何从 FastAPI 路由与 webhook 路由汇总;
- 客户端核心:clients/packages/client/src/index.ts,掌握
createClient/unwrap/错误类的完整实现; - 消费示例:clients/apps/app/providers/PolarClientProvider.tsx 与 clients/apps/app/hooks/polar/customers.ts,对照学习在 React/Expo 中的接入方式;
- 中间件示例:clients/apps/app/auth/refreshMiddleware.ts 及其测试 clients/apps/app/auth/refreshMiddleware.test.ts;
- 指标工具:clients/packages/client/src/metrics.ts 与 clients/apps/app/components/Metrics/utils.ts。
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考