在 Rocket.Chat 中构建实验性 REST API 命名空间:/api/experimental的不稳定契约、类型化路由设计与五步实现指南
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
导读
本文基于 Rocket.Chat 仓库内的 docs/experimental-api-endpoints-plan.md 展开,系统讲解该团队如何在不违反语义化版本(semver)承诺的前提下,让 REST 端点能够随任意版本发布、变更甚至无警告移除。文章完整继承原计划的核心契约、源码级设计论证与五步实现方案,并结合 apps/meteor/server/api/api.ts、ApiClass.ts 与 rest-typings 包的实际落地代码,向读者展示如何为任意团队交付一套通用、可选、独立于稳定 SDK 的类型化实验端点机制。读完本文,你将掌握:Rocket.Chat 的 API 实例(API.v1/API.experimental/API.default)如何被创建与挂载、x-experimental不稳定信号的运行时实现原理、以及如何新增一个属于自己的实验性端点并将其安全提升(promotion)到/v1。
一、问题定义:允许"不稳定"端点上线而不破坏 semver
1.1 核心目标
Rocket.Chat 希望 REST 端点能够"带着不稳定状态发布到生产环境",但允许它们在任意一次发布中被更改或移除,而无需触发主版本号(major-version)提升。要做到这一点,机制必须是通用的——任何团队、任何功能模块都能直接使用,而不是为某一个功能单独打补丁。
1.2 契约(The contract)
原计划文档给出了如下一段必须被所有调用方知晓的契约文本:
Endpoints under
/api/experimental/...are unstable. They may change shape or be removed in any release, without notice and without a deprecation cycle. No semver promise attaches to this namespace.
翻译过来即是:位于/api/experimental/...之下的端点不稳定;它们可能在任意一次发布中改变形态或被移除,不提前通知、不经过弃用周期;此命名空间不附带任何 semver 承诺。
1.3 命名空间本身就是契约
这套机制最精妙的设计在于:命名空间(namespace)本身就是契约。调用方只要命中了/api/experimental/*,光凭 URL 就等同于"自愿选择进入不稳定区"——无需阅读额外的文档,也无需修改客户端配置。与此同时,/v1隐式承载的 semver 稳定性承诺保持原样、不受任何影响。
这一点在 api.ts 的 API 对象字面量中体现得直白:v1、experimental、default三个实例并存,分别以version区分(见下文源码证据)。
1.4 规则:只有新类型化 API 允许出现在 experimental 路由上
计划文档明确规定,实验性端点只能通过.get()/.post()/.put()/.delete()注册,并且必须携带 AJV 的body/query/response校验器;已被废弃的.addRoute()禁止在实验性路由上使用——新的表面积(surface area)不应诞生在遗留注册路径上。
需要特别指出的是:这是一条文档化规则,而非编译器强制的规则。原因在计划文档与源码中都有交代:
API.experimental本质上只是一个普通的APIClass,并没有在类型层面剔除.addRoute();.addRoute()在其可达的每一处重载上都已携带@deprecated注解(见 ApiClass.ts 中第 783–815 行对addRoute的 JSDoc),IDE 与 lint 工具会提示开发者远离它。
之所以放弃"从类型上隐藏addRoute",是因为类型化方法(.get()等)返回this——一个被限制的表面积只会保持到第一次链式注册为止,要堵上这个口子就必须复制每一个类型化方法的签名并拓宽负责模式匹配APIClass的路由提取类型,成本收益不成正比。
二、为什么这么设计:来自当前代码库的关键发现
计划文档在动手前先梳理了既有 REST 体系的关键事实,这些发现直接决定了最终方案。结合仓库源码逐一印证如下。
2.1createApi({ version })把 version 字符串变成 URL 路径段
在 api.ts 中,createApi是一个薄封装,本质是new APIClass({ apiPath: '', useDefaultAuth: false, ...options })。而APIClass的构造函数(ApiClass.ts)会这样拼装路径:
this.apiPath = [properties.apiPath, properties.version].filter(Boolean).join('/').replaceAll('//', '/'); ... this.router = new RocketChatAPIRouter(`/${this.apiPath}`.replace(/\/$/, '').replaceAll('//', '/'));version直接成为apiPath段,再由apiPath参与router的根路径组装。因此,只要以version: 'experimental'新建一个实例,它天然挂在/api/experimental/<name>下——路由器内部逻辑一行都不用改。唯一的前提是:新路由器仍需在startRestAPI中被挂载(即计划中的 Step 2)。
2.2 类型化路由方法不与Endpoints联合类型绑定
类型化路由方法.get()/.post()/.put()/.delete()对TSubPathPattern extends string泛型化(见 ApiClass.ts 中的APIClass.method()及其各动词包装器),并不是被keyof Endpoints约束的。也就是说,注册的路由会"向外"累积进实例的TOperations类型参数,再由 api.ts 底部的ExtractApiClassEndpoints读回。这意味着:一个独立的 experimental 实例天然与类型系统协同工作,不需要额外的类型体操。
2.3 稳定客户端类型面的隔离依赖Endpoints接口
PathPattern、Method、Path以及类型化客户端,全部从Endpoints接口(见 packages/rest-typings/src/index.ts)派生。因此,只要把 experimental 路径排除在Endpoints之外,稳定客户端表面积就能保持纯净,并且迫使任何想调用实验端点的代码做出显式的 opt-in。
2.4 复用既有的弃用响应头框架
弃用框架已经在往响应里写x-deprecation-*头(writeDeprecationHeader位于 apps/meteor/server/lib/deprecationWarningLogger.ts)。experimental 的x-experimental/Warning信号将镜像这一模式,风格统一、维护成本低。
2.5 认证、权限、限流、CORS、AJV 校验与指标全部"免费获得"
Auth、权限、限流、CORS、AJV 校验与 Prometheus 指标均来自createApi以及startRestAPI中的中间件链。experimental 端点只要挂在同一管线之后,就自动享有与/v1完全一致的这些能力——这正是 Step 1 中"限流 watcher 必须覆盖 experimental"被视为强制的根因(详见 Step 1)。
三、提交约束:一步一提交,保证可二分与可回滚
原计划对落地过程施加了严格的工程纪律,值得任何大型改动借鉴:
- 每一步实现 = 恰好一个提交。没有一步被拆成多个提交,也没有一个提交横跨多个步骤。
- 保持历史可二分(bisectable),每个阶段独立可评审、可回滚,并让 PR review 与计划 1:1 对齐。
- 每步的提交必须让仓库处于可编译、lint 干净的状态(
yarn lint --quiet通过)——不完整的工作在提交前必须被 squash。 - 提交信息主题要指名步骤,例如:
feat(api): add experimental API instance (step 1)。 - 若某一步暴露出计划之外的前置条件,则把它并入该步的唯一提交,而不是额外引入计划外提交。
四、五步实现详解
Step 1 — 添加experimentalAPI 实例
改动文件:apps/meteor/server/api/api.ts
1. 在API对象字面量中添加实例,放置于v1与default之间:
experimental: createApi({ version: 'experimental', useDefaultAuth: true }),2. 为API类型注解增加experimental条目,使其获得类型:APIClass<'/experimental'>。如前文所述,"从该类型中隐藏addRoute()"的方案曾被考虑但被否决。
3. 设置变更时的路由刷新是"必须的对等条件"(required parity),而非可选项。契约承诺 experimental 端点"免费获得限流",只有当刷新回调覆盖到它们时才成立。以下settings.watch(...)回调必须同步更新API.experimental:
| 设置项 | 回调 |
|---|---|
API_Enable_Rate_Limiter_Limit_Time_Default | reloadRoutesToRefreshRateLimiter() |
API_Enable_Rate_Limiter_Limit_Calls_Default | reloadRoutesToRefreshRateLimiter() |
Accounts_CustomFields | setLimitedCustomFields() |
已知缺口(Known gap):限流 watcher 已实现对等;但Accounts_CustomFieldswatcher 目前仍只更新API.v1。当前这是无害的——尚无 experimental 端点会返回用户对象——但在出现此类端点之前必须补齐。
在仓库当前代码(api.ts 第 43–111 行)中,该步骤已经落地:API类型包含experimental: APIClass<'/experimental'>,实例位于v1与default之间,且reloadRoutesToRefreshRateLimiter已经同时刷新API.v1与API.experimental两个实例的限流规则。
验收标准(Acceptance):API.experimental.get('ping', { ... }, handler)可编译,并在GET /api/experimental/ping上提供服务。
提交(共 5 个中的第 1 个):feat(api): add experimental API instance
Step 2 — 在请求管线中挂载 experimental 路由器
改动文件:apps/meteor/server/api/api.ts 中的startRestAPI
1. 把.use(API.experimental.router)插入中间件链,位置在.use(API.default.router)之前。顺序至关重要:default是兜底(catch-all)路由器。
2. 新增一个指向API.experimental的metricsMiddleware块,让 experimental 流量被度量。指标是后续判断"端点是否够格提升到/v1"的**金丝雀(canary)**依据。因为所有块共享同一个/api挂载点,每一块都需要自己的守卫,否则一个请求会被采样多次:
- 带版本号的块通过
basePathRegexopt-in(只采样自己前缀下的路径); - 兜底块负责
API.default(/api/info、/api/docs/json以及未匹配任何版本的/api/*),通过excludePathRegexopt-out掉自己不属于的版本前缀。若缺少这个兜底块,守卫会静默丢弃过去一直被采样的 default 路由流量。
在仓库当前代码中,startRestAPI(api.ts 第 113–162 行)可以看到三块并排的metricsMiddleware:
.use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\/v1\//), api: API.v1, ... })) .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental\//), api: API.experimental, ... })) .use( metricsMiddleware({ excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//), api: { version: 'default' }, ... }), )注意:实际实现中excludePathRegex额外包含了apps前缀(Apps Engine 的/api/apps),并且 default 兜底块的version被显式标为'default',避免标签为空。metricsMiddleware的守卫逻辑可以在 apps/meteor/server/api/v1/middlewares/metrics.ts 中看到:先按basePathRegex放行、再按excludePathRegex跳过,采样完成后用api.version写入 Prometheus 标签。
验收标准:experimental 请求出现在 REST API Prometheus 指标中,且标签必须是version=experimental这个具体值,而不是某个"可区分的值"。如果只是调整路径正则、却仍让 experimental 流量落入v1版本标签,则不算达标。/api/v1/*与 default 路由流量各自仍应在其标签下被精确采样恰好一次。
提交:feat(api): mount experimental router and metrics
Step 3 — 运行时"unstable"信号(镜像弃用头)
新建文件:apps/meteor/server/api/v1/middlewares/experimental.ts,与既有中间件同目录存放。
1. 编写一个中间件,在来自 experimental 实例的每个响应上设置如下头:
Warning: 299 - "experimental: endpoint is unstable and may change without notice" x-experimental: true两者定位不同,务必区分:
x-experimental: true是被支持的编程式信号(supported programmatic signal)——客户端应通过它来识别 experimental 响应;Warning: 299仅是遗留兼容信号:warn code 299 来自 RFC 7234,该 RFC 连同Warning头本身已被 RFC 9111 废弃,现代客户端预期既不生成也不解释它。它仅为那些仍然展示该头的工具而保留,未来直接去掉也不算破坏性变更。
头部写入风格应参考writeDeprecationHeader(apps/meteor/server/lib/deprecationWarningLogger.ts)。
仓库中该中间件已经实现(见 experimental.ts),其注释精确记录了上述设计取舍:
const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"'; export const experimentalWarningMiddleware = ({ basePathRegex }: { basePathRegex: RegExp }): MiddlewareHandler => async (c, next) => { if (!basePathRegex.test(c.req.path)) { return next(); } c.res.headers.set('x-experimental', 'true'); c.res.headers.set('Warning', WARNING_HEADER); await next(); };2. 把中间件注册在共享的/api挂载点上、cors之前,通过basePathRegex限定到/api/experimental(与 metrics 中间件守卫形状一致)。它不能挂在API.experimental.router上:因为cors在拒绝预检请求时会直接以 403/405 应答而不调用next(),挂在路由器上的中间件永远不会覆盖那些响应。
当前 api.ts 第 155 行的注册方式为:
.use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) })) .use(cors(settings))中间件把响应头预先设置在c.res.headers上,下游 handler 无论最终产出什么响应(包括 404 与 CORS 拒绝),Hono 都会合并这些头——这正是"连 404 和预检拒绝都带信号头"的实现细节。
验收标准:每个/api/experimental/*响应都携带这两个头——包括 404 与 CORS 预检拒绝;而/api/v1/*响应不携带。
提交:feat(api): add experimental unstable-signal middleware
Step 4 — 独立、可选的 SDK 类型
改动文件:packages/rest-typings/src/index.ts(+ 新建声明文件)
1. 新建packages/rest-typings/src/experimental/index.ts(新目录),声明实验类型。命名风格参照既有 per-resource 端点类型(如 packages/rest-typings/src/v1/channels/channels.ts):
export type ExperimentalEndpoints = { '/experimental/<name>': { GET: (params: ...) => ...; }; // ... };2. 从包根导出ExperimentalEndpoints,但绝不把它并入interface Endpoints extends ...联合。这样PathPattern、Method、Path以及稳定类型化客户端就始终与 experimental 路径绝缘。当前 rest-typings/src/index.ts 第 275–277 行的导出方式即为此意:
// Opt-in experimental endpoint typings. Deliberately NOT part of the `Endpoints` // union above — see ./experimental for the rationale. export type * from './experimental';而 packages/rest-typings/src/experimental/index.ts 顶部的注释亦声明:这些类型有意不并入包根导出的Endpoints联合,使稳定类型化客户端表面积不沾染不稳定路径;需要类型化 experimental 调用的消费方必须显式导入ExperimentalEndpoints,且每个路径键必须以/experimental/开头。
3. 想要获得实验端点类型安全的消费者,显式导入ExperimentalEndpoints即可。
验收标准:import type { Endpoints } from '@rocket.chat/rest-typings'不包含 experimental 路径;import type { ExperimentalEndpoints }包含。
提交:feat(rest-typings): add opt-in ExperimentalEndpoints
Step 5 — 护栏(因为这是一套通用机制)
1. 任何路径不得同时存在于两个联合类型中。类型层面的 CI 守卫曾被考虑但被否决:联合类型的键是完整路径,/experimental/x与/v1/x永远不会碰撞;下面描述的过渡窗口也不会产生碰撞。因此该规则保留在文档中即可——"提升(promotion)"意味着把声明移动到稳定的*Endpoints类型中,而不是在旧位置留一份副本。
2. 提升路径(Promotion path):文档中需要写明——把一个端点稳定化,等于把它复制到/v1(可选地在过渡窗口期保留 experimental 路径的转发)。移除则无需弃用周期,但出于礼节应记录移除日志。
3. Docs/CONTRIBUTING 说明:在文档中陈述 no-semver 保证以及如何新增一个 experimental 端点,让机制可被发现。仓库中与计划配套的 docs/experimental-api-endpoints.md 即承担了这一职责。
4. OpenAPI/文档生成决策:需审慎决定生成的 API 文档是只扫描Endpoints(experimental 端点被隐藏——通常是期望的)还是也扫描ExperimentalEndpoints。
提交:chore(api): add experimental guardrails and docs
五、测试清单
计划文档给出了一份可用于验收整个机制的测试核对清单,共 5 项:
GET /api/experimental/<name>可解析,并返回x-experimental+Warning响应头/api/v1/*的响应保持不变(不带 experimental 头)- experimental 路由上的 Auth / 权限 / 限流,与
/v1完全一致地生效 Endpoints类型不包含 experimental 路径;ExperimentalEndpoints包含- experimental 请求出现在 REST API 指标中
六、涉及文件一览
| 文件 | 变更 |
|---|---|
| apps/meteor/server/api/api.ts | 新增experimental实例、类型条目;在startRestAPI中挂载;新增 metrics 块 |
apps/meteor/server/api/v1/middlewares/experimental.ts(新) | x-experimental/Warning响应头中间件 |
| apps/meteor/server/api/v1/middlewares/metrics.ts | basePathRegex/excludePathRegex采样守卫 |
packages/rest-typings/src/experimental/index.ts(新) | ExperimentalEndpoints类型,不并入Endpoints |
| packages/rest-typings/src/index.ts | 导出ExperimentalEndpoints |
| docs / CONTRIBUTING | 记录契约与提升路径 |
七、仓库当前落地状态与真实示例端点
这份计划在 Rocket.Chat 当前代码库中已经基本落地,是理解"计划→实现"映射的最佳案例。除了上文各步骤对应的代码外,仓库已有一个真实的 experimental 端点可供对照——apps/meteor/server/api/experimental/rooms.setCategory.ts。
该端点完整演示了计划的每一条规则:
- 使用
API.experimental.post('rooms.setCategory', ...)注册,而非.addRoute(); - 携带 AJV 编译的
body校验器(isRoomsSetCategoryParamsPOST),对roomIds: string[](minItems: 1、uniqueItems: true)与可空的category(通过not: { enum: [...SIDEBAR_SYSTEM_GROUP_KEYS] }排除系统分组)做精确约束; - 声明
response的 200 / 400 / 401 / 403 多状态码校验(复用了validateBadRequestErrorResponse等标准错误响应校验器); - 开启
authRequired: true并挂载 license 要求(experimental-enterprise-features),证明认证与授权在 experimental 路由上原样生效; - handler 内部通过
this.bodyParams/this.userId访问上下文,并使用API.experimental.success()/API.experimental.failure()返回结果——与/v1的APIClass辅助方法完全同源。
与其配套的ExperimentalEndpoints声明位于 packages/rest-typings/src/experimental/index.ts,其中定义了/experimental/rooms.setCategory的 POST 签名(params: { roomIds: string[]; category: string | null }) => { success: true },与实现一一对应。
这套机制的价值在于:新功能的 API 可以在不影响任何稳定消费者的情况下尽早暴露给真实用户与内部团队试用,用生产流量和指标(version=experimental标签)作为"是否足够成熟、可提升至/v1"的客观依据——既保持了 Rocket.Chat/v1对外部生态的 semver 承诺,又避免了新功能在发布前长期憋在内部、缺少真实反馈的窘境。
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考