Files
upstream-rocketchat/docs/experimental-api-endpoints-plan.md
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

216 lines
11 KiB
Markdown

# 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 the `version` string into the URL path segment, so a
new instance with `version: 'experimental'` mounts at `/api/experimental/<name>`
without changing any router internals — the new router still has to be mounted in
`startRestAPI` (see Step 2). See `apps/meteor/server/api/api.ts` (the `API` object and
`createApi`) and `apps/meteor/server/api/ApiClass.ts` (`apiPath` composition in the
`APIClass` constructor).
- The typed route methods `.get()/.post()/.put()/.delete()` are generic over
`TSubPathPattern extends string` (`ApiClass.ts`, `APIClass.method()` and its
per-verb wrappers) — they are **not** gated on `keyof Endpoints`. Routes accumulate
*outward* into the instance's `TOperations`, read back by `ExtractApiClassEndpoints`
(`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 the
`Endpoints` interface (`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
(`writeDeprecationHeader` in `apps/meteor/server/lib/deprecationWarningLogger.ts`). We
mirror that pattern for an `x-experimental` / `Warning` signal.
- Auth, permissions, rate limiting, CORS, AJV validation, and metrics all come from
`createApi` + the middleware chain in `startRestAPI` — 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
--quiet` passes) — 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`
1. In the `API` object literal, add:
```ts
experimental: createApi({ version: 'experimental', useDefaultAuth: true }),
```
Place it between `v1` and `default`.
2. Add an `experimental` entry to the `API` type annotation so it is typed:
`APIClass<'/experimental'>`. Hiding `addRoute()` from that type was considered and
dropped: the typed methods return `this`, 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-match `APIClass`. Not
worth it for a rule `@deprecated` already signals.
3. 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 update
`API.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_CustomFields`
watcher still updates `API.v1` only. 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`
1. Insert `.use(API.experimental.router)` into the chain, **before**
`.use(API.default.router)`. Order matters: `default` is the catch-all.
2. Add a second `metricsMiddleware` block pointed at `API.experimental` so 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 `/api` mount, each one
needs a guard or a request is sampled more than once: the versioned blocks opt in via
`basePathRegex`, and a catch-all block for `API.default` (`/api/info`, `/api/docs/json`,
unmatched `/api/*`) opts out via `excludePathRegex`. 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.
1. 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: true
```
`x-experimental: true` is the **supported programmatic signal** — clients should detect
experimental responses with it. `Warning: 299` is a legacy compatibility signal only:
warn code 299 came from RFC 7234, which RFC 9111 has since obsoleted along with the
`Warning` header 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 on `writeDeprecationHeader`
in `apps/meteor/server/lib/deprecationWarningLogger.ts`.
2. Register the middleware on the shared `/api` mount in `startRestAPI`, **ahead of**
`cors`, scoped to `/api/experimental` by a `basePathRegex` (same shape as the metrics
middleware guard). It cannot live on `API.experimental.router`: `cors` answers rejected
preflights with 403/405 without calling `next()`, 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)
1. Create `packages/rest-typings/src/experimental/index.ts` (new folder) and declare:
```ts
export type ExperimentalEndpoints = {
'/experimental/<name>': {
GET: (params: ...) => ...;
};
// ...
};
```
Follow the existing per-resource endpoint style (e.g.
`packages/rest-typings/src/v1/channels/channels.ts`).
2. Export `ExperimentalEndpoints` from the package root, but **do NOT** add it to the
`interface Endpoints extends ...` union. This keeps `PathPattern`,
`Method`, `Path`, and the stable typed client free of experimental paths.
3. Consumers who want typed experimental calls import `ExperimentalEndpoints`
explicitly.
**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)
1. **No path in both unions.** A type-level CI guard for this was considered and dropped:
union keys are full paths, so `/experimental/x` and `/v1/x` never 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 `*Endpoints`
type, not leaving a copy behind.
2. **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.
3. **Docs/CONTRIBUTING note:** state the no-semver guarantee and how to add an
experimental endpoint, so the mechanism is discoverable.
4. **OpenAPI/doc generation:** decide deliberately whether generated API docs scan only
`Endpoints` (experimental endpoints hidden — probably desirable) or also
`ExperimentalEndpoints`.
**Commit (5 of 5):** `chore(api): add experimental guardrails and docs`
---
## Test checklist
- [ ] `GET /api/experimental/<name>` resolves and returns the `x-experimental` + `Warning` headers.
- [ ] `/api/v1/*` responses are unchanged (no experimental headers).
- [ ] Auth / permissions / rate limiting enforced on an experimental route exactly as on `/v1`.
- [ ] `Endpoints` type does not include experimental paths; `ExperimentalEndpoints` does.
- [ ] 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 |