
Etherpad Admin 类型安全 API 客户端基于 OpenAPI 代码生成与 TanStack Query 的工程实践【免费下载链接】etherpadEtherpad: A modern really-real-time collaborative document editor.项目地址: https://gitcode.com/gh_mirrors/et/etherpad导读本文围绕 Etherpad 仓库中 issue 7638 的设计文档 展开介绍管理员后台admin/如何建立一套由 OpenAPI 规范驱动的类型安全 REST API 客户端并以 TanStack Query 作为请求缓存与状态管理层。这是一次只铺设轨道、不迁移调用点rails-only的基础设施改造当前 admin 页面仍以 socket.io zustand 承载数据流REST 客户端与 Query 基础设施先行就位为后续新增的管理端 REST 端点#7601提供开箱即用的类型安全通道。读完本文你将掌握openapi-typescript/openapi-fetch/openapi-react-query的完整接入流程、gen:api代码生成管线的实现细节、双客户端public/admin的类型隔离设计以及配套的测试与 CI 校验策略。一、背景为什么只铺轨道不迁移调用点设计文档docs/superpowers/specs/2026-05-01-issue-7638-admin-typesafe-api-design.md开篇即澄清了一个关键事实issue 原始诉求中迁移所有useEffectfetch调用点的说法与实际代码并不相符。以设计文档记录、并与当前仓库核对管理端仅有的 RESTfetch()调用点是 App.tsx 与 LoginScreen.tsx二者都 POST 到/admin-auth/以及 i18n.ts 的语言包加载所有承载真实数据流的 admin 页面Settings、Plugins、Pads、Shout都运行在socket.io zustand之上而非 REST由 openapi.ts 生成的 OpenAPI 规范只覆盖公共 Etherpad HTTP API/api/{version}/*零个 admin 端点——既没有/admin-auth/也没有 #7601 计划中的/admin/*REST 端点。因此生成的客户端当下在admin/src/内没有任何可标注类型的目标。这个 PR 的价值在于把工具链与运行时轨道铺好让 #7601以及后续所有 admin REST 工作从第一天起就能直接采用。admin 端点在 OpenAPI 规范中的覆盖将作为独立 issue 跟进——在它落地之前任何调用点迁移都没有意义。范围界定Out of scope同样明确不动 OpenAPI 规范中的 admin 端点覆盖、不迁移任何现有fetch()调用点、不做后端改动、不涉及 Pad 端前端。二、工具链选型五个包各司其职设计文档给出了完整的工具链表格当前 admin/package.json 已按此落地依赖已安装版本采用标准 caret 范围包类型用途仓库中实际版本openapi-typescriptdevDependency实际位于etherpad/openapi-codegen内从 OpenAPI 规范生成.d.ts类型定义经 tools/openapi-codegen/package.json 固定openapi-fetchdependency类型安全的fetch封装^0.17.0openapi-react-querydependency基于客户端的 TanStack Query 绑定^0.5.4tanstack/react-querydependencyQuery 运行时^5.102.8tanstack/react-query-devtoolsdependency仅开发环境使用的调试面板^5.102.8设计文档约定不固定精确版本仅用标准 caret 范围实现时取最新稳定版——实际仓库正是这样落地的。一个值得注意的实现偏差openapi-typescript为什么住在独立包里设计文档假设openapi-typescript是admin的 devDependency但实际实现把它放进了私有包 etherpad/openapi-codegen。原因是仓库根目录已升级到 TypeScript 7原生移植版而openapi-typescript生成输出时依赖 TypeScript编译器 APIts.factory、ts.SyntaxKind、printer——TS 7 的主导出只有./lib/version.cjs编译器 API 完全缺失代码生成会直接抛错TypeError: Cannot read properties of undefined (reading createKeywordTypeNode)由于typescript是openapi-typescript的peer 依赖pnpm 会从依赖它的那个包里解析版本——admin声明typescript: ^7.0.2时 peer 必然解析到 7无论overrides还是packageExtensions都无法覆盖。因此专门为它建一个只依赖 TS 6.x 的私有包让 peer 解析到带可用编译器 API 的版本。该包仅在构建期运行产物schema.d.ts是普通文本tsc7 可正常消费待openapi-typescript支持原生编译器后即可删除此包。三、代码生成管线gen:api全流程拆解3.1 设计文档定义的管线设计文档option 3hybrid 模式定义了admin/scripts/gen-api.mjs的四步流程导入 src/node/hooks/express/openapi.ts 的规范构建入口或一个薄封装模块在不启动 Express 的前提下调用 spec builder把生成的 JSON 写入os.tmpdir()下的临时文件以子进程方式执行openapi-typescript tmp -o admin/src/api/schema.d.ts在输出文件头部追加生成声明注释// GENERATED — do not edit. Run \pnpm gen:api to regenerate.清理临时文件。文档同时预判了一个风险如果openapi.ts无法无副作用地作为 ES module 加载例如 import 时就加载设置或启动 Express就必须把纯粹的 spec builder 抽取到独立模块中——这个重构在本次范围内且改动要保持最小。3.2 仓库中的实际落地实现当前仓库的实现正是抽取纯 spec builder路线的结果gen-api.mjs不再直接 importopenapi.ts而是通过tsx调用 dump-spec.ts由它负责导入并合并规范。完整链路如下pnpm gen:api └─ node scripts/gen-api.mjs ├─ spawnSync(pnpm exec tsx scripts/dump-spec.ts tmp/spec.json) │ ├─ import APIHandler.ts → latestApiVersion │ ├─ import openapi.ts → generateDefinitionForVersion(version, APIPathStyle.FLAT) │ ├─ import openapi-admin.ts → generateAdminDefinition() │ └─ mergeOpenAPI(publicSpec, adminSpec) → 写 JSON 到 tmp ├─ spawnSync(pnpm --filter etherpad/openapi-codegen exec openapi-typescript spec -o admin/src/api/schema.d.ts) ├─ 在 schema.d.ts 头部写入 GENERATED 注释含来源标注 Source: src/node/hooks/express/openapi.ts ├─ 读取 spec.info.version → 生成 admin/src/api/version.tsLATEST_API_VERSION API_BASE_URL └─ finally: 递归删除临时目录几个值得注意的工程细节见 gen-api.mjs为何用文件参数而非 stdoutdump-spec.ts注释明确说明importopenapi*.ts会触发 Settings 初始化log4js 会把 INFO/WARN 日志写到 stdout若用 stdout 传 JSON日志会与 JSON 混流。Windows 兼容spawnSync使用shell: process.platform win32因为 Windows 上 pnpm 解析为pnpm.cmd必须经 shell 才能找到所有参数均为固定值、无用户输入shell: true不构成注入风险。额外产出version.ts生成的schema.d.ts路径是不带版本前缀的如/createGroup但后端把规范挂载在/api/version/下。因此脚本读取 spec 的info.version写入运行时常量LATEST_API_VERSION和API_BASE_URL \/api/${LATEST_API_VERSION}供client.ts 构造正确的 baseUrl。3.3 双规范合并规则设计文档最初只提到openapi.ts一个规范来源实际实现中 admin 端点规范已由 openapi-admin.ts 提供两者经 merge-openapi.mjs 深合并paths按 key 求并集碰撞直接抛错path collisioncomponents.{schemas,parameters,responses,securitySchemes}按名称求并集碰撞抛错根级info、servers、security以 public 文档为准admin 的根级声明被忽略admin 路径上的逐操作per-operationsecurity 原样保留不受根级覆盖影响。这套合并规则确保了开放 API 与 admin API 可以共存于一份 spec且不会因命名冲突而静默出错。3.4 生成文件的 Git 策略与自动触发一个与设计稿的差异值得说明设计文档主张schema.d.tscheck-in 仓库并由 CI 用git diff --exit-code强制新鲜度而当前仓库的 admin/README.md 明确写着生成文件schema.d.ts与version.ts是gitignored、绝不提交。作为补偿gen:api被挂到了dev、build、build-copy、test四个脚本的第一步// admin/package.jsonscripts 节 dev: pnpm gen:api vite, gen:api: node scripts/gen-api.mjs, build: pnpm gen:api tsc vite build, build-copy: pnpm gen:api tsc vite build --outDir ../src/templates/admin --emptyOutDir, test: pnpm gen:api tsx --test src/**/__tests__/*.test.ts src/**/__tests__/*.test.tsx也就是说全新 checkout 只要执行上述任一命令生成文件就会自动产出无需任何手工步骤。修改以下任何一处后下一次pnpm dev|build|test都会自动刷新生成文件也可直接运行pnpm --filter admin gen:apisrc/node/hooks/express/openapi.tsAPIHandler.ts涉及latestApiVersion的变更openapi.ts引用的资源定义四、运行时客户端public / admin 双客户端类型隔离4.1 设计文档的初始方案设计文档给出的是一个单一客户端 Query 绑定的最小骨架// admin/src/api/client.ts设计稿 import createClient from openapi-fetch; import createQueryHooks from openapi-react-query; import type { paths } from ./schema; export const fetchClient createClientpaths({ baseUrl: / }); export const $api createQueryHooks(fetchClient);4.2 落地实现按 URL 前缀拆分两个类型域实际仓库的 client.ts 演进出更精细的设计合并后的 spec 覆盖两个 baseUrl 不同的表面——公共版本化 API/api/version/如/createGroup与根路径下的 admin 端点如/admin-auth/。若共用同一个createClientpaths运行期 baseUrl 会静默指向错误的表面。解决方案是用 TypeScript 条件类型在编译期把paths拆成两个域type AdminPath Extractkeyof paths, /admin${string}; type PublicPath Excludekeyof paths, AdminPath; type PublicPaths Pickpaths, PublicPath; type AdminPaths Pickpaths, AdminPath; export const fetchClient createClientPublicPaths({ baseUrl: API_BASE_URL }); export const adminFetchClient createClientAdminPaths({ baseUrl: / }); export const $api createQueryHooks(fetchClient); export const $adminApi createQueryHooks(adminFetchClient);这套类型体操的收益是在公共客户端上调用 admin 路径或反之会在tsc阶段直接报错——不存在一个共享客户端在运行期悄悄打错 baseUrl 的可能。这是类型安全从参数/响应类型延伸到端点归属层面的体现。4.3 QueryProvider惰性挂载 DevTools 的 Provider 封装QueryProvider.tsx 实现了设计文档的全部要求用useState初始化器构造单例QueryClient模块级或 lazy initializer避免每次渲染重建默认配置staleTime: 30_00030 秒内重复请求不触发重新拉取、refetchOnWindowFocus: true窗口聚焦自动刷新其余保持库默认值DevTools 仅当import.meta.env.DEV为真时启用且用lazy(() import(tanstack/react-query-devtools))动态导入配合Suspense fallback{null}——保证 devtools 代码不会进入生产 bundle。const Devtools import.meta.env.DEV ? lazy(() import(tanstack/react-query-devtools).then((m) ({ default: m.ReactQueryDevtools, })), ) : null;4.4 应用挂载main.tsx 中包裹全局main.tsx 中QueryProvider包裹在React.StrictMode内部、I18nextProvider与路由之上。由于 Provider 对 socket.io 数据流是惰性的这一包裹对现有 Settings / Plugins / Pads 页面理论上零侵入——这也是手动冒烟测试要验证的重点之一。4.5 使用示例admin/README.md 给出了拿到 admin 端点后的调用范式$api.useQuery(get, /admin/settings)为示例路径import { $api } from ./api/client; const SettingsPanel () { const { data } $api.useQuery(get, /admin/settings); // example return pre{JSON.stringify(data, null, 2)}/pre; };路径参数、query 参数、响应类型全部由schema.d.ts推导改动 OpenAPI 规范后重新gen:api即可让类型失效点浮出水面。当前 admin 端点尚未进入 spec该客户端仅由冒烟测试驱动等待 #7638 的后续工作补齐端点覆盖。五、测试策略冒烟测试 CI 新鲜度 手动验证设计文档定义了三层验证仓库均已落地或规划1. 模块加载冒烟测试——client.test.ts 动态 importclient.ts断言四个导出均存在且方法签名正确test(client module exports public admin clients and query hooks, async () { const mod await import(../client.ts); assert.ok(mod.fetchClient, fetchClient export is present); assert.ok(mod.adminFetchClient, adminFetchClient export is present); assert.ok(mod.$api, $api export is present); assert.ok(mod.$adminApi, $adminApi export is present); assert.equal(typeof mod.fetchClient.GET, function, fetchClient.GET is a function); assert.equal(typeof mod.$api.useQuery, function, $api.useQuery is a function); });该测试的价值在于捕获工具链装配断裂peer 依赖缺失、生成器输出没有导出paths、绑定包导出形状变化等。pnpm test同样以gen:api开头保证测试在最新 schema 上运行。2. CI 新鲜度检查——设计文档给出了设计阶段拟用的命令供参考pnpm --filter admin gen:api git diff --exit-code admin/src/api/schema.d.tsdiff 非空即失败并提示贡献者运行gen:api后提交结果。落地版本改为生成文件 gitignored 构建命令前置gen:api见 3.4等效保证了构建产物一定与当前 spec 同步。3. 手动冒烟测试——PR 合并后在本地安装该分支打开/admin验证现有 socket.io 数据流settings / plugins / pads无回归——确认QueryProvider包裹是惰性的React Query DevTools 面板在开发构建pnpm --filter admin dev中出现、在生产构建中消失。文档特别注明按项目惯例自动化测试优先于手动验证但 devtools 可见性与 Provider 包裹属运行时行为手动冒烟是不可避免的次级安全网而非主要测试策略。六、文档与工程落地admin/README.md已创建或扩展覆盖重新生成方法pnpm --filter admin gen:api、何时重新生成改动openapi.ts或任何影响 spec 的内容后、会重新生成什么仅schema.d.ts与version.ts、CI 新鲜度检查及失败恢复方式、客户端用法片段。PR 计划设计文档fork 自johnmclear/etherpad-lite项目惯例禁止直接提交上游、分支chore/admin-typesafe-api-7638、基于最新mainPR 标题chore(admin): typesafe API client TanStack Query railssemver 标记为patch纯构建工具 未使用的运行时库无可观察行为变化PR 描述注明 rails-only、独立 spec 覆盖 issue 跟进、#7601 应在合并后 rebase 到该分支之上。七、风险与完成定义设计文档列出的三项风险及其缓解策略风险缓解策略openapi.ts无法干净导入抽取 spec builder 触碰生产路径保持抽取外科手术式最小化若膨胀则拆分为独立 PR7638 在其上 rebaseBundle 体积增大TanStack Query react-query 绑定即使无调用点也新增约 12 KB gzipped对内部 admin UI 可接受在 PR 描述中透明标注QueryProvider包裹引入回归对 socket.io 路径应为惰性以手动冒烟确认Definition of done设计文档原文要点pnpm --filter admin gen:api在全新 checkout 上干净运行pnpm --filter admin build成功schema.d.ts带生成头注释QueryProvider包裹App /且 devtools 在开发构建可见、生产构建缺席CI 新鲜度检查接入并通过admin/README.md记录 codegen 工作流手动冒烟确认 socket.io 页面无回归。结语一套可复用的类型安全基建模板回顾整条链路OpenAPI 规范openapi.tsopenapi-admin.ts→merge-openapi.mjs合并 →dump-spec.ts落盘 →openapi-typescript生成schema.d.tsversion.ts→openapi-fetch双客户端 openapi-react-query绑定 →QueryProvider全局注入。这套rails-only改造没有迁移任何一个现有调用点却为 #7601 的 admin REST 端点铺好了从规范到 UI 的类型安全高速公路。对读者而言它同样是一份可借鉴的实践模板如何在不破坏既有架构的前提下以最小侵入把 OpenAPI 驱动的类型安全请求层引入一个以 socket.io 为主要数据通道的既有前端。后续跟进方向admin 端点 spec 覆盖与演进注意点TS 7 编译器 API、etherpad/openapi-codegen的退役时机也已在本仓库的 README 与 openapi-codegen 说明 中白纸黑字记录在案。【免费下载链接】etherpadEtherpad: A modern really-real-time collaborative document editor.项目地址: https://gitcode.com/gh_mirrors/et/etherpad创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考