PostHog 生成式 API 客户端迁移模式:从手写 API 调用到 OpenAPI 生成函数的 17 个 Before/After 对照
本文基于 PostHog 仓库中 .agents/skills/adopting-generated-api-types/references/migration-patterns.md 的迁移模式文档展开,完整覆盖从 api.surveys.get()、api.get<T>(url) 到 new ApiRequest() 三种手写调用层向 Orval 生成函数的全部 17 个 Before/After 对照模式,并结合 frontend/src/lib/api-orval-mutator.ts、products/surveys/frontend/generated/api.ts 等仓库源码,说明生成函数的签名约定、NonReadonly 请求体机制与 hogli build:openapi 重生成流程。读完本文,你可以将任意一个仍在使用手写 API 调用的前端文件,安全地迁移到带完整类型安全的生成式客户端。
背景:手写调用层与生成式客户端的关系
PostHog 前端存在两套 API 调用体系:
- 遗留的手写层:
frontend/src/lib/api.ts(约 6000 行)提供三种调用方式——高层对象 API(api.surveys.get(id)、api.dashboards.list()等按实体划分的命名空间)、带类型参数的原始 HTTP 方法(api.get<T>(url)、api.create<T>(url, data))、以及new ApiRequest()流式 URL 构造器。 - 生成式客户端:由
Django serializer → drf-spectacular → OpenAPI JSON → Orval → TypeScript流水线产出,落在三组文件中:- Core 端点:frontend/src/generated/core/api.ts、frontend/src/generated/core/api.schemas.ts、frontend/src/generated/core/api.zod.ts
- 产品端点:
products/<product>/frontend/generated/api.ts、api.schemas.ts、api.zod.ts(如 products/surveys/frontend/generated/api.ts)
生成类型统一使用 Api 后缀(SurveyApi、DashboardApi),手写类型从不用此后缀——这是一个快速判断某类型是否为生成类型的规则。生成函数的命名遵循 {resource}{Action} 约定:
surveysList — GET /api/projects/{id}/surveys/
surveysCreate — POST /api/projects/{id}/surveys/
surveysRetrieve — GET /api/projects/{id}/surveys/{id}/
surveysPartialUpdate — PATCH /api/projects/{id}/surveys/{id}/
surveysDestroy — DELETE /api/projects/{id}/surveys/{id}/
下面 17 个模式即为此约定的完整落地对照,按调用层分为五组:高层对象 API、原始 HTTP 方法、ApiRequest builder、Kea 逻辑、边界情况。
生成函数的签名约定:从源码看
在展开具体模式之前,先看 products/surveys/frontend/generated/api.ts 中的真实签名,它决定了所有模式的转换方式:
// L48-L62:每个生成函数上方都有一个 URL builder
export const getSurveysListUrl = (projectId: string, params?: SurveysListParams) => {
const normalizedParams = new URLSearchParams()
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : String(value))
}
})
const stringifiedParams = normalizedParams.toString()
return stringifiedParams.length > 0
? `/api/projects/${projectId}/surveys/?${stringifiedParams}`
: `/api/projects/${projectId}/surveys/`
}
// L64-L73:函数体 = URL builder + apiMutator
export const surveysList = async (
projectId: string,
params?: SurveysListParams,
options?: RequestInit
): Promise<PaginatedSurveyListApi> => {
return apiMutator<PaginatedSurveyListApi>(getSurveysListUrl(projectId, params), {
...options,
method: 'GET',
})
}
由此可以归纳出三条贯穿全部 17 个模式的约定:
projectId永远是第一个参数——高层 API 从 context 隐式取项目,生成函数则要求显式传入(Kea 场景中通常写作String(values.currentProjectId));- 查询参数以参数对象传入(
params?: SurveysListParams),由 URL builder 内部完成URLSearchParams编码,不再需要手写 URL 编码; - 最后一个参数是
options?: RequestInit——signal、headers等请求选项统一从这里传入。
所有生成函数最终都委托给同一个 mutator。frontend/src/lib/api-orval-mutator.ts 中的 apiMutator 按 HTTP 方法分发回旧的 api 模块:
switch (method) {
case 'GET': return api.get(url, apiOptions)
case 'POST': return api.create(url, data, apiOptions)
case 'PUT': return api.put(url, data, apiOptions)
case 'PATCH': return api.update(url, data, apiOptions)
case 'DELETE': return api.delete(url)
}
这意味着切换到生成函数不改变任何 HTTP 行为——同样的 cookie、同样的 CSRF 处理、同样的错误处理(ApiError 抛出方式一致),差异只在类型安全与 URL 构造。mutator 同时负责解析 JSON 字符串 body、把 Headers 对象归一化为普通对象、透传 signal 与 headers。
请求体的 readonly 剥离也由生成代码内置完成。products/surveys/frontend/generated/api.ts 头部定义了一个非导出的 NonReadonly<T> 工具类型,逐键剥离 readonly 修饰符并递归处理嵌套对象。因此 surveysCreate 的参数类型是 NonReadonly<SurveySerializerCreateUpdateOnlySchemaApi>:id 等只读字段会被自动从请求体类型中排除,调用方直接传普通对象即可,无需自行构造可写副本。
模式 1–5:高层对象 API 迁移
高层对象 API 是最常见的模式。以 surveys 实体为例,五个 CRUD 操作的对照如下。
1. 实体获取(Entity get)
// Before
import api from 'lib/api'
import { Survey } from '~/types'
const survey = await api.surveys.get(surveyId)
// After
import { surveysRetrieve } from 'products/surveys/frontend/generated/api'
const survey = await surveysRetrieve(String(values.currentProjectId), surveyId)
2. 实体列表(Entity list)
// Before
const surveys = await api.surveys.list({ limit: 100 })
// After
import { surveysList } from 'products/surveys/frontend/generated/api'
const surveys = await surveysList(String(values.currentProjectId), { limit: 100 })
3. 实体创建(Entity create)
// Before
const survey = await api.surveys.create(surveyPayload)
// After
import { surveysCreate } from 'products/surveys/frontend/generated/api'
const survey = await surveysCreate(String(values.currentProjectId), surveyPayload)
4. 实体更新(Entity update)
// Before
const updated = await api.surveys.update(surveyId, surveyPayload)
// After
import { surveysPartialUpdate } from 'products/surveys/frontend/generated/api'
const updated = await surveysPartialUpdate(String(values.currentProjectId), surveyId, surveyPayload)
注意命名映射:Django REST 的部分更新对应 partialUpdate 动作,生成函数名是 surveysPartialUpdate 而非 surveysUpdate——查找生成函数时按 DRF 动作名(retrieve/list/create/partial_update/destroy)拼写,而不是按 api 对象上的方法名(get/update)。
5. 实体删除(Entity delete)
// Before
await api.surveys.delete(surveyId)
// After
import { surveysDestroy } from 'products/surveys/frontend/generated/api'
await surveysDestroy(String(values.currentProjectId), surveyId)
模式 6–10:原始 HTTP 方法迁移
api.get<T>(url) / api.create<T>(url, data) 这类带手写 URL 模板和类型参数的调用,替换为生成函数后 URL 拼串与类型注解同时消失。
6. 带类型参数的 GET
// Before
import api from 'lib/api'
import { OrganizationDomainType } from '~/types'
const domain = await api.get<OrganizationDomainType>(`api/organizations/${orgId}/domains/${domainId}/`)
// After
import { domainsRetrieve } from '~/generated/core/api'
const domain = await domainsRetrieve(orgId, domainId)
这里 domain 属于 core 端点,因此从 ~/generated/core/api 导入(tilde 前缀指向 frontend/src)。
7. 分页 GET
// Before
import { PaginatedResponse, OrganizationInviteType } from '~/types'
const invites = await api.get<PaginatedResponse<OrganizationInviteType>>(
`api/organizations/${orgId}/invites/?limit=100`
)
const items = invites.results
// After
import { invitesList } from '~/generated/core/api'
const invites = await invitesList(orgId, { limit: 100 })
const items = invites.results // typed as OrganizationInviteApi[]
两个变化点:limit=100 从 URL 字符串移入参数对象;泛型包装 PaginatedResponse<T> 被具体的 Paginated*ListApi 类型取代(如 PaginatedSurveyListApi,其 count/next/previous/results 形状与原泛型一致,使用侧代码无需改动)。
8. POST(创建)
// Before
const domain = await api.create<OrganizationDomainType>(`api/organizations/${orgId}/domains/`, {
domain: 'example.com',
})
// After
import { domainsCreate } from '~/generated/core/api'
const domain = await domainsCreate(orgId, { domain: 'example.com' })
请求体类型是 NonReadonly<OrganizationDomainApi>——id 这类只读字段被自动剔除,正如前文 products/surveys/frontend/generated/api.ts 中 NonReadonly 工具类型所示。若需要显式声明请求体变量类型,可从函数签名推导:
type CreateBody = Parameters<typeof domainsCreate>[1]
9. PATCH(部分更新)
// Before
const updated = await api.update<OrganizationDomainType>(`api/organizations/${orgId}/domains/${domainId}/`, {
jit_provisioning_enabled: true,
})
// After
import { domainsPartialUpdate } from '~/generated/core/api'
const updated = await domainsPartialUpdate(orgId, domainId, {
jit_provisioning_enabled: true,
})
10. DELETE
// Before
await api.delete(`api/organizations/${orgId}/domains/${domainId}/`)
// After
import { domainsDestroy } from '~/generated/core/api'
await domainsDestroy(orgId, domainId)
模式 11–12:ApiRequest builder 迁移
流式 builder 构造 URL 的写法是最啰嗦的一类,也是替换收益最大的一类。
11. Builder 到生成函数
// Before
import { ApiRequest } from 'lib/api'
const url = new ApiRequest().projects().projectsDetail(projectId).surveys().assembleFullUrl()
const surveys = await api.get<PaginatedResponse<Survey>>(url)
// After
import { surveysList } from 'products/surveys/frontend/generated/api'
const surveys = await surveysList(String(projectId))
12. 带 action 的 builder
// Before
await new ApiRequest()
.survey(surveyId)
.withAction('summarize_responses')
.withQueryString({ question_index: 1 })
.create({ data: { force_refresh: true } })
// After — if the @action has @extend_schema, a generated function exists:
import { surveysSummarizeResponsesCreate } from 'products/surveys/frontend/generated/api'
await surveysSummarizeResponsesCreate(String(projectId), String(surveyId), {
force_refresh: true,
})
// If no generated function exists, keep the builder and fix the backend first.
自定义 action 的生成前提是后端 @action 带有 @extend_schema 注解。仓库中 products/surveys/frontend/generated/api.ts 确实存在 surveysSummarizeResponsesCreate,印证了该 action 已具备生成条件。若生成函数不存在,不要强行迁移——保留 builder 原样,先修后端再重生成。
模式 13–14:Kea 逻辑迁移
PostHog 前端大量数据获取发生在 Kea logic 的 loaders/listeners 中,迁移方式与普通调用一致,只是类型注解位置不同。
13. Kea logic loader
// Before
loaders({
domains: [
[] as OrganizationDomainType[],
{
loadDomains: async () => {
const response = await api.get<PaginatedResponse<OrganizationDomainType>>(
`api/organizations/${values.currentOrganizationId}/domains/`
)
return response.results
},
},
],
})
// After
import { domainsList } from '~/generated/core/api'
import type { OrganizationDomainApi } from '~/generated/core/api.schemas'
loaders({
domains: [
[] as OrganizationDomainApi[],
{
loadDomains: async () => {
const response = await domainsList(values.currentOrganizationId)
return response.results
},
},
],
})
注意 loader 初始值断言([] as OrganizationDomainApi[])也要同步换成生成类型。
14. 带错误处理的 Kea listener
// Before
listeners({
saveDomain: async ({ domain }) => {
try {
const response = await api.update<OrganizationDomainType>(
`api/organizations/${orgId}/domains/${domain.id}/`,
domain
)
actions.saveDomainSuccess(response)
} catch (e) {
actions.saveDomainFailure(String(e))
}
},
})
// After
import { domainsPartialUpdate } from '~/generated/core/api'
listeners({
saveDomain: async ({ domain }) => {
try {
const response = await domainsPartialUpdate(orgId, domain.id, domain)
actions.saveDomainSuccess(response)
} catch (e) {
actions.saveDomainFailure(String(e))
}
},
})
错误处理代码保持原样——apiMutator 和 api.update 抛出 ApiError 的行为一致(见 frontend/src/lib/api-orval-mutator.ts)。
模式 15–17:边界情况
15. 带 abort signal 的调用
// Before
const controller = new AbortController()
const result = await api.get<MyType>(url, { signal: controller.signal })
// After
const controller = new AbortController()
const result = await myEndpointRetrieve(id, undefined, { signal: controller.signal })
生成函数的最后一个参数是 options?: RequestInit,signal、headers 从这里传入,经 frontend/src/lib/api-orval-mutator.ts 的 apiOptions 透传到 api.get。注意中间占位的 undefined:当函数签名为 (projectId, id, params?, options?) 而需要跳过 params 时,需显式传 undefined。
16. 混合文件——增量迁移
当文件中存在大量手工调用、本次只改其中一部分时:
// OK to migrate incrementally — mix generated and manual calls in the same file
import api from 'lib/api' // keep for un-migrated calls
import { domainsList, domainsCreate } from '~/generated/core/api' // migrated
import type { OrganizationDomainApi } from '~/generated/core/api.schemas'
// Migrated
const domains = await domainsList(orgId)
// Not yet migrated (no generated function for this custom action)
const verification = await api.create(`api/organizations/${orgId}/domains/${id}/verify/`)
增量迁移是明确支持的策略:同一文件内新旧调用可以共存,lib/api 的 import 仅为未迁移的调用保留。
17. 无生成对应物的自定义方法
api.surveys.getResponsesCount()、api.dashboards.streamTiles() 这类高层 API 的自定义方法,若后端 @action 缺少 @extend_schema,则不存在生成对应物。此时保留原调用并跟进后端标注:
// Keep as-is until the backend @action is annotated
const counts = await api.surveys.getResponsesCount(surveyIds)
决策速查表
| 场景 | 动作 |
|---|---|
| 生成函数存在 | 用生成函数替换手工调用 |
| 生成类型存在但函数不存在 | 手工调用的泛型参数改用生成类型,并跟进补 @extend_schema |
| 两者都不存在 | 保留手工模式,先修后端 serializer/viewset |
| 自定义 action 无生成对应物 | 保留 api.<entity>.<method>() 调用,先补后端 @action 注解 |
| 生成类型与手写类型形状不同 | 以 serializer 为真相源,适配调用侧代码 |
| 代码直接修改响应对象 | 生成类型带 readonly 会报错,改用本地可变副本 const mutable = { ...response } |
| 同时需要读/写类型 | 读用 FooApi,写用 Parameters<typeof fooCreate>[1] 推导或 PatchedFooApi |
更细的类型兼容规则(Patched*Api、nullable 与 optional 的区分、as const 枚举对象等)见同目录的 type-compatibility.md,完整迁移工作流(识别调用、定位生成函数、清理死类型)见 SKILL.md。
导入路径约定
迁移时的 import 写法有固定规则,17 个模式中出现的 ~/generated/core/api 与 products/surveys/frontend/generated/api 两种路径分别对应:
// Core 生成函数 — 从 api.ts 导入
import { domainsList, domainsCreate, domainsRetrieve } from '~/generated/core/api'
// Core 生成类型 — import type 自 api.schemas.ts(import type 利于 tree-shaking)
import type { OrganizationDomainApi } from '~/generated/core/api.schemas'
// Core 生成 Zod schema — 从 api.zod.ts 导入
import { DomainsCreateBody } from '~/generated/core/api.zod'
// 产品生成函数 — 无 tilde 前缀,使用 'products/' 路径
import { surveysList, surveysRetrieve } from 'products/surveys/frontend/generated/api'
import type { SurveyApi } from 'products/surveys/frontend/generated/api.schemas'
import { SurveysCreateBody } from 'products/surveys/frontend/generated/api.zod'
// 产品内部也可用相对导入
import { logsAlertsCreate } from '../generated/api'
路径规则小结:Core 用 ~/generated/core/...(tilde 前缀);从产品外引用产品端点用 products/<product>/frontend/generated/...(无 tilde);产品内部用相对路径 ../generated/... 或 ./generated/...。
定位生成函数与重生成类型
查找生成对应物时按三步走:
- 在生成
api.ts中 grep 实体名(core 端点查frontend/src/generated/core/api.ts,产品端点查products/<product>/frontend/generated/api.ts); - 按
get*Url辅助函数搜索——每个生成函数上方都有对应的 URL builder(如 getSurveysListUrl),URL 形态可直接反查端点; - 在
api.schemas.ts中按Api后缀类型名搜索。
找不到时,说明后端端点缺少 @extend_schema 或 @validated_request 注解,需要先在 Django 侧修复,再运行 hogli build:openapi 重生成。该流水线的触发范围在 tools/hogli-commands/hogli_commands/build.py 中定义:posthog/api/*.py、ee/api/*.py、products/*/backend/api/*.py 等后端 API 代码变更均会触发 build:openapi 重建,hogli build 命令默认按 git 变更做智能检测,也可用 --force 全量重建、--dry-run 预览。生成文件头部的注释也写明了这一契约——"To modify these types, update the Django serializers or views, then run: hogli build:openapi"(见 products/surveys/frontend/generated/api.ts)。
迁移验证与收尾
迁移完成后按三步验证:
- 类型检查:
pnpm --filter=@posthog/frontend typescript:check - 残留清理:全局搜索旧的手写类型名,确认无遗漏引用;确认某个手写类型的所有使用点都迁移后,从
~/types或本地文件中删除该类型定义及无用 import - 跑相关测试:
hogli test <test_file>
需要强调的边界:readonly 字段(对应 serializer 的 read_only=True)意味着对响应对象的直接赋值(如 dashboard.id = 123)会编译报错,迁移时若发现此类代码,应改为展开到新对象再修改;请求体侧则交由 NonReadonly<T> 机制自动处理,无需额外操作。只要遵循"生成函数存在即替换、不存在先修后端"的原则,配合上述 17 个模式对照,任意遗留的 lib/api 手工调用都可以安全、增量地迁移到 PostHog 的生成式 API 客户端。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00