十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PostHog 前端 API 类型迁移实践:从手写接口调用到 OpenAPI 生成函数的完整工作流

PostHog 前端 API 类型迁移实践:从手写接口调用到 OpenAPI 生成函数的完整工作流 PostHog 前端 API 类型迁移实践从手写接口调用到 OpenAPI 生成函数的完整工作流【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthogPostHog 的前端代码正在从手写的api.getT(url)调用和手写 TypeScript 接口迁移到由 Django 后端序列化器自动生成的 API 函数与类型。本文基于仓库中 .agents/skills/adopting-generated-api-types/SKILL.md 这份迁移技能文档完整讲解「Django 序列化器 → OpenAPI → Orval → TypeScript」的生成链路、三类遗留调用模式的识别与替换方法、类型兼容性的关键差异以及迁移后的验证手段。读完后你将能够在任意前端文件中安全地把手动 API 调用替换为生成的强类型函数并清理掉对应的手写类型。生成链路Django 序列化器如何变成 TypeScriptPostHog 通过 OpenAPI 管道从后端自动生成前端的 API 客户端函数与类型链路如下Django serializer → drf-spectacular → OpenAPI JSON → Orval → TypeScript (api.ts api.schemas.ts api.zod.ts)这条链路在仓库中可以得到逐环验证第 1 环hogli.yaml 中的build:openapi-schema任务运行python manage.py spectacular --file frontend/tmp/openapi.json --format openapi-json --fail-on-warn由 drf-spectacular 从 Django 视图和序列化器导出 OpenAPI JSON第 2 环build:openapi-types调用前端脚本 generate-openapi-types.mjs该脚本读取frontend/tmp/openapi.json并行执行多个 Orval 生成任务产出api.ts函数、api.schemas.ts类型、api.zod.tsZod 校验 schema第 3 环生成文件统一落在两处——核心Corefrontend/src/generated/core/api.ts、frontend/src/generated/core/api.schemas.ts、frontend/src/generated/core/api.zod.ts产品Productsproducts/product/frontend/generated/下同样三个文件例如 products/surveys/frontend/generated/api.ts。一个可操作的细节每个生成文件的头部都带有自描述注释说明「Auto-generated from the Django backend OpenAPI schema」并提示修改应通过更新 Django 序列化器后运行hogli build:openapi完成而不是手改生成文件。生成的类型一律使用Api后缀命名DashboardApi、SurveyApi手写类型从不用这个后缀——这是快速区分「哪些类型该被替换」的最直观信号。三类需要迁移的手写调用模式遗留的 frontend/src/lib/api.ts当前约 7600 行中存在三个层次的调用方式全部是迁移目标。1. 高层对象 API最常见api对象上按领域划分的便捷方法每个实体有自己的命名空间包含 CRUD 及自定义方法api.surveys.get(id) api.surveys.create(data) api.dashboards.list() api.cohorts.update(id, data) api.actions.create(data)自定义方法也挂在这里例如api.surveys.getResponsesCount()、api.dashboards.streamTiles()。2. 带手动 URL 的原始 HTTP 方法api.getSomeType(api/projects/${id}/surveys/) api.createSomeType(api/projects/${id}/surveys/, data) api.updateSomeType(url, data) api.putSomeType(url, data) api.delete(url)URL 需要自己拼接字符串模板泛型参数则需要自己去~/types里找一份手写接口。3. ApiRequest 链式构建器流式 URL 构造const url new ApiRequest().surveys().assembleFullUrl() const response await api.get(url) // 或直接用构建器发起请求 await new ApiRequest().survey(surveyId).withAction(summarize_responses).create({ data })当存在对应的生成函数时三种模式都应被替换。判断「何时该触发迁移」的实用信号你正在改动的文件调用了api.entity.method()、api.getT(...)/api.createT(...)等原始方法、使用了new ApiRequest()构造 URL或者从~/types导入了与 API 响应形状重复的手写接口——包括在后端序列化器改进之后清理前端代码的场景。迁移工作流六个步骤步骤 1识别手动调用的语义从现有调用中抽取三个要素HTTP 方法— GET、POST、PUT、PATCH、DELETE实体与动作— 操作的是哪个资源、哪个操作类型参数— 响应使用的手写类型。步骤 2在生成文件中找到对应函数生成函数命名遵循{resource}{Action}约定。以 surveys 为例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}/这些函数确实存在于 products/surveys/frontend/generated/api.ts 中且每个函数上方都有一个同名的 URL 构建助手getSurveysRetrieveUrl之类可据此反查端点。查找策略有三种在生成的api.ts文件中按实体名 grep核心端点在frontend/src/generated/core/api.ts产品端点在products/product/frontend/generated/api.ts按get*Url助手函数名搜索——每个生成函数上方都有一个 URL 构建器在api.schemas.ts中按带Api后缀的类型名搜索。找不到生成函数时的处理原则后端端点可能缺少extend_schema或validated_request注解。此时应先用后端侧的improving-drf-endpoints流程补齐注解再运行hogli build:openapi重新生成不要在前端手搓一个“临时生成函数”。自定义动作如api.surveys.summarize_responses()同理——只有当后端action带有extend_schema时才会生成对应函数例如 surveysSummarizeResponsesCreate 就存在于生成文件中。步骤 3核对类型兼容性将手写类型与生成的Api类型逐字段对比四类常见差异readonly修饰符— 生成类型会把序列化器中read_onlyTrue的字段标记为只读可选 vs 必填— 生成类型精确反映required声明空值语义—null类型是显式表达的额外字段— 生成类型可能包含手写类型遗漏的字段。步骤 4替换调用以下是覆盖全部三类模式的替换对照完整参考 migration-patterns.md。实体查询get → Retrieve// 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)注意一个行为差异生成函数总是把projectId作为第一个显式参数——高层 API 从上下文隐式取项目 ID生成函数则要求显式传入。列表 / 创建 / 更新 / 删除// Before const surveys await api.surveys.list({ limit: 100 }) const survey await api.surveys.create(surveyPayload) const updated await api.surveys.update(surveyId, surveyPayload) await api.surveys.delete(surveyId) // After const surveys await surveysList(String(values.currentProjectId), { limit: 100 }) const survey await surveysCreate(String(values.currentProjectId), surveyPayload) const updated await surveysPartialUpdate(String(values.currentProjectId), surveyId, surveyPayload) await surveysDestroy(String(values.currentProjectId), surveyId)原始 HTTP 方法含分页// Before — 手动拼 URL 手动指定分页泛型 import { PaginatedResponse, OrganizationDomainType } from ~/types const domain await api.getOrganizationDomainType(api/organizations/${orgId}/domains/${domainId}/) const invites await api.getPaginatedResponseOrganizationInviteType( api/organizations/${orgId}/invites/?limit100 ) const items invites.results // After — 查询参数作为参数对象传入无需手动 URL 编码 import { domainsRetrieve, invitesList } from ~/generated/core/api const domain await domainsRetrieve(orgId, domainId) const invites await invitesList(orgId, { limit: 100 }) const items invites.results // 类型即 OrganizationInviteApi[]创建与部分更新的请求体类型是NonReadonlyT——只读字段如id被自动剥离直接传普通对象即可// Before const domain await api.createOrganizationDomainType(api/organizations/${orgId}/domains/, { domain: example.com, }) const updated await api.updateOrganizationDomainType(api/organizations/${orgId}/domains/${domainId}/, { jit_provisioning_enabled: true, }) await api.delete(api/organizations/${orgId}/domains/${domainId}/) // After const domain await domainsCreate(orgId, { domain: example.com }) const updated await domainsPartialUpdate(orgId, domainId, { jit_provisioning_enabled: true }) await domainsDestroy(orgId, domainId)ApiRequest 构建器替换// Before import { ApiRequest } from lib/api const url new ApiRequest().projects().projectsDetail(projectId).surveys().assembleFullUrl() const surveys await api.getPaginatedResponseSurvey(url) // After const surveys await surveysList(String(projectId))带action的构建器调用只要后端注解齐全就有对应生成函数// Before await new ApiRequest() .survey(surveyId) .withAction(summarize_responses) .withQueryString({ question_index: 1 }) .create({ data: { force_refresh: true } }) // After — 后端 action 已带 extend_schema 时 await surveysSummarizeResponsesCreate(String(projectId), String(surveyId), { force_refresh: true, }) // 若生成函数不存在保留构建器写法先修后端注解。Kea 状态管理中的 loader 与 listener// Afterloader 中 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 }, }, ], }) // Afterlistener 中错误处理结构不变 listeners({ saveDomain: async ({ domain }) { try { const response await domainsPartialUpdate(orgId, domain.id, domain) actions.saveDomainSuccess(response) } catch (e) { actions.saveDomainFailure(String(e)) } }, })错误处理语义保持一致apiMutator抛出的ApiError与api.update抛出的异常相同listener 的 try/catch 结构无需改动。步骤 5替换使用处的类型把下游引用从手写类型换成生成类型// Before function renderSurvey(survey: Survey): JSX.Element { ... } // After function renderSurvey(survey: SurveyApi): JSX.Element { ... }步骤 6清理死类型当某个手写类型的所有使用点都迁移完毕后从~/types或本地文件删除该类型定义删除未使用的 import运行pnpm --filterposthog/frontend typescript:check确认没有破坏其他引用。类型兼容性详解生成类型 vs 手写类型这部分内容参考 type-compatibility.md是迁移中真正会“咬人”的地方。readonly 字段序列化器中read_onlyTrue的字段在生成类型中被标记为readonly// 生成类型 interface DashboardApi { readonly id: number readonly created_at: string name: string // 可写 } // 典型手写类型 interface DashboardType { id: number created_at: string name: string }后果是直接修改响应对象的代码会报错Cannot assign to id because it is a read-only property。两种修法停止原地修改响应对象展开成局部可变副本const local { ...dashboard, id: 123 }若是在构造请求体使用Patched*Api类型或用Parameterstypeof fooCreate[1]从函数签名推导参数类型。Patched 类型对应 PATCH 语义生成类型包含Patched*Api变体所有字段均为可选与部分更新的语义匹配interface PatchedDashboardApi { readonly id?: number name?: string description?: string }构造部分更新负载时优先使用PatchedFooApi。分页包装类型生成类型的分页结构interface PaginatedDashboardListApi { count: number next?: string | null previous?: string | null results: DashboardApi[] }它替代手写类型中的通用PaginatedResponseT。形状完全一致使用侧代码不需要变动只有类型注解需要换。nullable 与 optional 的区分生成类型严格区分三种情况nullable— 字段可为nullfield: string | nulloptional— 字段可省略field?: string两者兼备—field?: string | null。手写类型经常对两种情况都用?表示。迁移时可能出现「代码检查if (field)但生成类型声明字段总是存在只是可能为null」的报错。由于null是 falsy绝大多数if (response.verified_at)写法在迁移后依然可用只有极少数依赖「属性不存在」语义的代码需要调整。枚举类型生成的枚举使用as const对象export type SurveyTypeApi (typeof SurveyTypeApi)[keyof typeof SurveyTypeApi] export const SurveyTypeApi { Popover: popover, Widget: widget, FullScreen: full_screen, } as const手写枚举可能是 TypeScriptenum或字符串联合。迁移后既可用survey.type SurveyTypeApi.Popover带自动补全保留survey.type popover字面量写法也完全合法。字段缺失的两种方向手写有、生成没有说明序列化器没有暴露该字段。常见原因——字段已从序列化器移除、字段本来就是客户端计算的从未来自 API、或字段属于另一个端点的序列化器列表视图 vs 详情视图。对于客户端计算字段继承生成类型来扩展import type { DashboardApi } from products/dashboards/frontend/generated/api.schemas interface DashboardWithLocal extends DashboardApi { _localDraft: boolean // 仅客户端使用的字段 }生成有、手写没有序列化器暴露了手写类型从未包含的字段。这没问题——生成类型才是事实来源source of truth前端代码反而可以开始利用这些新字段。请求体类型生成函数内部接受NonReadonlyT参数一个剥离readonly的未导出工具类型无需直接导入或引用传普通对象即可。若需要显式标注请求体变量类型从函数签名推导type CreateBody Parameterstypeof domainsCreate[1] const body: CreateBody { domain: example.com }决策指南什么该换、什么先别换场景动作生成函数存在用生成函数替换手动调用生成类型存在但函数不存在手动调用上使用该生成类型作为泛型参数并开一个后续任务补extend_schema两者都不存在保留手动模式先修后端序列化器/视图集自定义动作无生成对应物保留api.entity.method()调用先补后端action注解生成类型与手写类型形状不同让调用点适配生成形状——序列化器是事实来源代码会修改响应对象使用局部可变副本const mutable { ...response }后修改副本需要读类型和写类型各一份读用FooApi写类型用Parameterstypeof fooCreate[1]推导或用PatchedFooApi导入约定与路径规则// 核心生成函数 — 从 api.ts 导入 import { domainsList, domainsCreate, domainsRetrieve } from ~/generated/core/api // 核心生成类型 — 从 api.schemas.ts 导入类型 import type { OrganizationDomainApi } from ~/generated/core/api.schemas // 核心生成 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 import type { LogsAlertConfigurationApi } from ../generated/api.schemas import { LogsAlertsCreateBody } from ../generated/api.zod路径规则三条核心~/generated/core/...tilde 前缀产品端、从外部导入products/product/frontend/generated/...无 tilde产品端、从内部导入相对路径../generated/...或./generated/...。类型一律使用import type以保证正确的 tree-shaking。生成函数的底层机制HTTP 行为零变化生成函数通过 frontend/src/lib/api-orval-mutator.ts 中的自定义 Orval mutator 复用同一个api模块。调用链为surveysList(projectId, params) → apiMutator(url, { method: GET }) → api.get(url)mutator 的实现api-orval-mutator.ts按 HTTP 方法分发GET → api.get、POST → api.create、PUT → api.put、PATCH → api.update、DELETE → api.delete并把 Orval 传来的signal、headers含Headers对象到普通对象的转换、JSON 字符串 body会JSON.parse还原逐一适配回api模块的签名。因此切换到生成函数不改变任何 HTTP 行为——同样的 Cookie、同样的 CSRF、同样的错误处理唯一的差异是类型安全与 URL 构造方式。这也解释了为什么迁移可以放心地“边改边混用”// 同一文件内增量迁移是允许的 import api from lib/api // 未迁移的调用继续用它 import { domainsList, domainsCreate } from ~/generated/core/api // 已迁移的调用 import type { OrganizationDomainApi } from ~/generated/core/api.schemas // 已迁移 const domains await domainsList(orgId) // 未迁移该自定义动作没有生成函数 const verification await api.create(api/organizations/${orgId}/domains/${id}/verify/)两条边界情况原则abort signal生成函数的最后一个参数是options?: RequestInit把signal、headers等放在这里传await myEndpointRetrieve(id, undefined, { signal: controller.signal })不要强迁没有生成函数对应的调用尤其是缺少extend_schema的自定义动作如api.surveys.getResponsesCount()、api.dashboards.streamTiles()保留原样先登记后端注解补齐的后续任务。验证迁移是否完成三步验证全部可在本地执行TypeScript 检查pnpm --filterposthog/frontend typescript:checkgrep 残留在代码库范围内搜索旧类型名确认没有遗漏的手写类型引用运行相关测试hogli test test_file。重新生成类型统一使用hogli build:openapi该任务串联build:openapi-schema→build:openapi-types等子任务见 hogli.yaml。相关资源后端侧注解改进生成“差类型”的根因修复使用improving-drf-endpoints技能类型系统总览文档docs/published/handbook/engineering/type-system.mdAPI mutator 实现frontend/src/lib/api-orval-mutator.ts遗留手动客户端frontend/src/lib/api.ts生成管线脚本frontend/bin/generate-openapi-types.mjs本技能完整参考migration-patterns.md、type-compatibility.md。【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表