11 KiB
Plan: Experimental REST API endpoints
Goal
Allow REST endpoints to ship to production but change or be removed in any release without a major-version bump. The mechanism must be general-purpose (any team can use it), not specific to one feature.
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.
The namespace is the contract. A caller hitting /api/experimental/* has opted
into instability by the URL alone. /v1 keeps its implicit semver-stability promise,
untouched.
Only the new typed API is allowed on experimental routes. Endpoints must be
registered with .get() / .post() / .put() / .delete() (with AJV body / query
/ response validators). The deprecated .addRoute() must not be used — new surface
area should not be born on the legacy registration path. This is a documented rule, not a
compiler-enforced one: API.experimental is a plain APIClass, and .addRoute() already
carries @deprecated everywhere it is reachable.
Why this design (key findings from the current code)
createApi({ version })turns theversionstring into the URL path segment, so a new instance withversion: 'experimental'mounts at/api/experimental/<name>without changing any router internals — the new router still has to be mounted instartRestAPI(see Step 2). Seeapps/meteor/server/api/api.ts(theAPIobject andcreateApi) andapps/meteor/server/api/ApiClass.ts(apiPathcomposition in theAPIClassconstructor).- The typed route methods
.get()/.post()/.put()/.delete()are generic overTSubPathPattern extends string(ApiClass.ts,APIClass.method()and its per-verb wrappers) — they are not gated onkeyof Endpoints. Routes accumulate outward into the instance'sTOperations, read back byExtractApiClassEndpoints(apps/meteor/server/api/api.ts). So a separate experimental instance works with the type system. PathPattern,Method,Path, and the typed client are all derived from theEndpointsinterface (packages/rest-typings/src/index.ts). Keeping experimental paths out of that interface keeps the stable client surface clean and forces explicit opt-in for experimental ones.- The deprecation framework already writes
x-deprecation-*response headers (writeDeprecationHeaderinapps/meteor/server/lib/deprecationWarningLogger.ts). We mirror that pattern for anx-experimental/Warningsignal. - Auth, permissions, rate limiting, CORS, AJV validation, and metrics all come from
createApi+ the middleware chain instartRestAPI— experimental endpoints get them for free.
Commit constraint
Each implementation step below ships as exactly one commit. No step is split across multiple commits, and no commit spans more than one step. This keeps the history bisectable, makes each phase independently reviewable and revertable, and maps the PR review 1:1 onto the plan.
- A step's commit must leave the tree in a compiling, lint-clean state (
yarn lint --quietpasses) — partial work is squashed before committing. - Commit message subject names the step, e.g.
feat(api): add experimental API instance (step 1). - If a step turns out to require a prerequisite not in the plan, add it to that step's single commit rather than introducing an out-of-band commit.
Implementation steps
Step 1 — Add the experimental API instance
File: apps/meteor/server/api/api.ts
-
In the
APIobject literal, add:experimental: createApi({ version: 'experimental', useDefaultAuth: true }),Place it between
v1anddefault. -
Add an
experimentalentry to theAPItype annotation so it is typed:APIClass<'/experimental'>. HidingaddRoute()from that type was considered and dropped: the typed methods returnthis, so a restricted surface only holds until the first chained registration, and closing that hole means duplicating every typed signature and widening the route-extraction types that pattern-matchAPIClass. Not worth it for a rule@deprecatedalready signals. -
Refreshing experimental routes when settings change is a required parity condition, not an optional extra — the contract above promises experimental endpoints get rate limiting "for free", which only holds if the refresh callbacks cover them. The
settings.watch(...)callbacks in this file that must also updateAPI.experimental:API_Enable_Rate_Limiter_Limit_Time_Default→reloadRoutesToRefreshRateLimiter()API_Enable_Rate_Limiter_Limit_Calls_Default→reloadRoutesToRefreshRateLimiter()Accounts_CustomFields→setLimitedCustomFields()
Known gap: the rate-limiter watchers are at parity; the
Accounts_CustomFieldswatcher still updatesAPI.v1only. That is currently harmless — no experimental endpoint returns user objects — but it must be closed before one does.
Acceptance: API.experimental.get('ping', { ... }, handler) compiles and serves at
GET /api/experimental/ping.
Commit (1 of 5): feat(api): add experimental API instance
Step 2 — Mount it in the request pipeline
File: apps/meteor/server/api/api.ts, startRestAPI
- Insert
.use(API.experimental.router)into the chain, before.use(API.default.router). Order matters:defaultis the catch-all. - Add a second
metricsMiddlewareblock pointed atAPI.experimentalso experimental traffic is measured. Metrics are the canary used later to decide whether an endpoint is ready for promotion to/v1. Because every block shares the same/apimount, each one needs a guard or a request is sampled more than once: the versioned blocks opt in viabasePathRegex, and a catch-all block forAPI.default(/api/info,/api/docs/json, unmatched/api/*) opts out viaexcludePathRegex. Without that catch-all block the guards silently drop default-router traffic that used to be sampled.
Acceptance: experimental requests appear in the REST API Prometheus metrics labelled
version=experimental — specifically that label, not merely a distinguishable one. A
change that only adjusted the path regex while leaving experimental traffic under the
v1 version label does not satisfy this. /api/v1/* and default-router traffic each
still record exactly one sample under their own label.
Commit (2 of 5): feat(api): mount experimental router and metrics
Step 3 — Runtime "unstable" signal (mirror deprecation headers)
New file: apps/meteor/server/api/v1/middlewares/experimental.ts, colocated with the
existing middlewares.
- Write a middleware that sets, on every response from the experimental instance:
Warning: 299 - "experimental: endpoint is unstable and may change without notice" x-experimental: truex-experimental: trueis the supported programmatic signal — clients should detect experimental responses with it.Warning: 299is a legacy compatibility signal only: warn code 299 came from RFC 7234, which RFC 9111 has since obsoleted along with theWarningheader itself, so modern clients are not expected to generate or interpret it. It is emitted for the benefit of tooling that still surfaces it, and may be dropped without it being a breaking change. Model the header-writing onwriteDeprecationHeaderinapps/meteor/server/lib/deprecationWarningLogger.ts. - Register the middleware on the shared
/apimount instartRestAPI, ahead ofcors, scoped to/api/experimentalby abasePathRegex(same shape as the metrics middleware guard). It cannot live onAPI.experimental.router:corsanswers rejected preflights with 403/405 without callingnext(), so a router-scoped middleware would never run for those responses.
Acceptance: every /api/experimental/* response carries both headers — including 404s
and CORS preflight rejections; /api/v1/* responses do not.
Commit (3 of 5): feat(api): add experimental unstable-signal middleware
Step 4 — Separate, opt-in SDK typings
File: packages/rest-typings/src/index.ts (+ a new file for the declarations)
- Create
packages/rest-typings/src/experimental/index.ts(new folder) and declare:Follow the existing per-resource endpoint style (e.g.export type ExperimentalEndpoints = { '/experimental/<name>': { GET: (params: ...) => ...; }; // ... };packages/rest-typings/src/v1/channels/channels.ts). - Export
ExperimentalEndpointsfrom the package root, but do NOT add it to theinterface Endpoints extends ...union. This keepsPathPattern,Method,Path, and the stable typed client free of experimental paths. - Consumers who want typed experimental calls import
ExperimentalEndpointsexplicitly.
Acceptance: import type { Endpoints } from '@rocket.chat/rest-typings' does NOT
include experimental paths; import type { ExperimentalEndpoints } does.
Commit (4 of 5): feat(rest-typings): add opt-in ExperimentalEndpoints
Step 5 — Guardrails (because it is a general mechanism)
- No path in both unions. A type-level CI guard for this was considered and dropped:
union keys are full paths, so
/experimental/xand/v1/xnever collide, and the transition window described below does not produce a collision either. Keep the rule in the docs instead — promotion means moving the declaration to a stable*Endpointstype, not leaving a copy behind. - Promotion path: document that stabilizing an endpoint means copying it to
/v1(optionally keeping the experimental path forwarding for a transition window). Removal needs no deprecation cycle — but log removals for courtesy. - Docs/CONTRIBUTING note: state the no-semver guarantee and how to add an experimental endpoint, so the mechanism is discoverable.
- OpenAPI/doc generation: decide deliberately whether generated API docs scan only
Endpoints(experimental endpoints hidden — probably desirable) or alsoExperimentalEndpoints.
Commit (5 of 5): chore(api): add experimental guardrails and docs
Test checklist
GET /api/experimental/<name>resolves and returns thex-experimental+Warningheaders./api/v1/*responses are unchanged (no experimental headers).- Auth / permissions / rate limiting enforced on an experimental route exactly as on
/v1. Endpointstype does not include experimental paths;ExperimentalEndpointsdoes.- Experimental requests show up in REST API metrics.
Files touched (summary)
| File | Change |
|---|---|
apps/meteor/server/api/api.ts |
Add experimental instance, type entry, mount in startRestAPI, metrics blocks |
apps/meteor/server/api/v1/middlewares/experimental.ts (new) |
x-experimental / Warning header middleware |
apps/meteor/server/api/v1/middlewares/metrics.ts |
basePathRegex / excludePathRegex sampling guards |
packages/rest-typings/src/experimental/index.ts (new) |
ExperimentalEndpoints type, NOT merged into Endpoints |
packages/rest-typings/src/index.ts |
Export ExperimentalEndpoints |
| docs / CONTRIBUTING | Document the contract + promotion path |