首页
/ PostHog 生成式 API 客户端迁移模式:从手写 API 调用到 OpenAPI 生成函数的 17 个 Before/After 对照

PostHog 生成式 API 客户端迁移模式:从手写 API 调用到 OpenAPI 生成函数的 17 个 Before/After 对照

2026-09-05 17:15:43作者:苗圣禹Peter

本文基于 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.tsproducts/surveys/frontend/generated/api.ts 等仓库源码,说明生成函数的签名约定、NonReadonly 请求体机制与 hogli build:openapi 重生成流程。读完本文,你可以将任意一个仍在使用手写 API 调用的前端文件,安全地迁移到带完整类型安全的生成式客户端。

背景:手写调用层与生成式客户端的关系

PostHog 前端存在两套 API 调用体系:

生成类型统一使用 Api 后缀(SurveyApiDashboardApi),手写类型从不用此后缀——这是一个快速判断某类型是否为生成类型的规则。生成函数的命名遵循 {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 个模式的约定:

  1. projectId 永远是第一个参数——高层 API 从 context 隐式取项目,生成函数则要求显式传入(Kea 场景中通常写作 String(values.currentProjectId));
  2. 查询参数以参数对象传入params?: SurveysListParams),由 URL builder 内部完成 URLSearchParams 编码,不再需要手写 URL 编码;
  3. 最后一个参数是 options?: RequestInit——signalheaders 等请求选项统一从这里传入。

所有生成函数最终都委托给同一个 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 对象归一化为普通对象、透传 signalheaders

请求体的 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.tsNonReadonly 工具类型所示。若需要显式声明请求体变量类型,可从函数签名推导:

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))
    }
  },
})

错误处理代码保持原样——apiMutatorapi.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?: RequestInitsignalheaders 从这里传入,经 frontend/src/lib/api-orval-mutator.tsapiOptions 透传到 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/apiproducts/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/...

定位生成函数与重生成类型

查找生成对应物时按三步走:

  1. 在生成 api.ts 中 grep 实体名(core 端点查 frontend/src/generated/core/api.ts,产品端点查 products/<product>/frontend/generated/api.ts);
  2. get*Url 辅助函数搜索——每个生成函数上方都有对应的 URL builder(如 getSurveysListUrl),URL 形态可直接反查端点;
  3. api.schemas.ts 中按 Api 后缀类型名搜索。

找不到时,说明后端端点缺少 @extend_schema@validated_request 注解,需要先在 Django 侧修复,再运行 hogli build:openapi 重生成。该流水线的触发范围在 tools/hogli-commands/hogli_commands/build.py 中定义:posthog/api/*.pyee/api/*.pyproducts/*/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)。

迁移验证与收尾

迁移完成后按三步验证:

  1. 类型检查pnpm --filter=@posthog/frontend typescript:check
  2. 残留清理:全局搜索旧的手写类型名,确认无遗漏引用;确认某个手写类型的所有使用点都迁移后,从 ~/types 或本地文件中删除该类型定义及无用 import
  3. 跑相关测试hogli test <test_file>

需要强调的边界:readonly 字段(对应 serializer 的 read_only=True)意味着对响应对象的直接赋值(如 dashboard.id = 123)会编译报错,迁移时若发现此类代码,应改为展开到新对象再修改;请求体侧则交由 NonReadonly<T> 机制自动处理,无需额外操作。只要遵循"生成函数存在即替换、不存在先修后端"的原则,配合上述 17 个模式对照,任意遗留的 lib/api 手工调用都可以安全、增量地迁移到 PostHog 的生成式 API 客户端。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388