Polar 内部 API 客户端 `@polar-sh/client` 深度解析:OpenAPI 驱动的类型安全前端调用层
2026/9/16 16:36:23 网站建设 项目流程

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.accesspublic,但本质上是仓库内部的 workspace 包);
  • 产物形态:同时输出dist/index.cjs(CommonJS)与dist/index.js(ESM),类型声明为dist/index.d.ts,导出映射exports["."]同时提供typesdefault两个条件入口;
  • 依赖核心:openapi-fetch@^0.15.0openapi-typescript-helpers@^0.0.15date-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"

这条命令链做了四件事:

  1. 产出 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 版本生成;
  2. 生成 TS 类型:把 Schema 管道输入openapi-typescript --enum-values,写入 clients/packages/client/src/v1.ts。--enum-values会让枚举保留为字面量联合,从而支持从v1.ts中 re-export 出运行时可用的枚举值数组;
  3. 格式化:用oxfmt统一格式化src/下所有文件;
  4. 构建产物:执行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, })

三个入参的含义与默认行为:

参数类型说明
baseUrlstringAPI 根地址,如https://api.polar.sh,必填
tokenstring \| undefined可选的 Bearer Token,存在时自动注入Authorization: Bearer <token>请求头
headersHeadersOptions \| undefined额外请求头,与 token 头合并

内部实现基于openapi-fetchcreateOpenAPIFetchClient<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 }

行为优先级:

  1. 自定义 handlers 优先:可按状态码注入自定义处理(返回never,常用于重定向或兜底);
  2. 429 限流:直接抛TooManyRequestsResponseError
  3. 401/404 特化:分别抛UnauthorizedResponseErrorNotFoundResponseError
  4. 其他错误:统一抛ClientResponseError
  5. 成功但无数据:抛普通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,保证前后端参数契约完全一致,分页用useInfiniteQuerypageParam驱动。

同样的模式遍布 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-fnssubDays/subMonths/subYears/startOfDaynow可注入以便测试;
  • 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.jsontypes字段对应。

8.2 共享 tsconfig

tsconfig.json 继承@polar-sh/typescript-config/bundled.json(见 clients/packages/typescript-config),设置outDir: distrootDir: src,仅编译src。包内还提供typecheck脚本(tsc --noEmit)用于 CI 校验。

8.3 枚举 re-export

src/enums.ts 从生成的v1.ts中 re-export 一批运行时可用的枚举值数组,例如orderStatusValuescheckoutStatusValuesbenefitTypeValuesrecurringIntervalValueswebhookEventTypeValues等。这些数组可直接用于下拉选项渲染、前端校验与参数序列化,避免手写枚举与后端漂移。

九、结语与延伸阅读

@polar-sh/client的价值可以概括为三句话:

  1. 单一事实来源:API 契约只存在于后端的 FastAPI/OpenAPI 定义中,前端类型全部自动生成,杜绝手工维护 API 层;
  2. 端到端类型安全openapi-fetch+paths/operations/schemas类型 +unwrap收敛错误,让“写错接口”从运行时错误提前到编译期错误;
  3. 可扩展的工程基础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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询