# API Endpoint Migration Guide Migration from the legacy `API.v1.addRoute()` pattern to the new `API.v1.get()` / `.post()` / `.put()` / `.delete()` pattern with request/response validation. ## Why - **Response validation** in test mode catches mismatches between code and types - **Type safety** with AJV-compiled schemas for request and response - **OpenAPI docs** generated automatically from schemas - **Consistent error format** across all endpoints ## Legacy Pattern (BEFORE) ```typescript import { isChannelsAddAllProps } from '@rocket.chat/rest-typings'; import { API } from '../api'; API.v1.addRoute( 'channels.addAll', { authRequired: true, validateParams: isChannelsAddAllProps, }, { async post() { const { activeUsersOnly, ...params } = this.bodyParams; const findResult = await findChannelByIdOrName({ params, userId: this.userId }); await addAllUserToRoomFn(this.userId, findResult._id, activeUsersOnly === 'true' || activeUsersOnly === 1); return API.v1.success({ channel: await findChannelByIdOrName({ params, userId: this.userId }), }); }, }, ); ``` Source: `apps/meteor/app/api/server/v1/channels.ts` ## New Pattern (AFTER) ```typescript import { ajv, isReportHistoryProps, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; // See "Known Pitfall: Date | string unions" — IModerationAudit uses a relaxed inline schema const paginatedReportsResponseSchema = ajv.compile<{ reports: IModerationAudit[]; count: number; offset: number; total: number }>({ type: 'object', properties: { reports: { type: 'array', items: { type: 'object', properties: { userId: { type: 'string' }, username: { type: 'string' }, name: { type: 'string' }, message: { type: 'string' }, msgId: { type: 'string' }, ts: { type: 'string' }, rooms: { type: 'array', items: { type: 'object' } }, roomIds: { type: 'array', items: { type: 'string' } }, count: { type: 'number' }, isUserDeleted: { type: 'boolean' }, }, required: ['userId', 'ts', 'rooms', 'roomIds', 'count', 'isUserDeleted'], additionalProperties: false, }, }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['reports', 'count', 'offset', 'total', 'success'], additionalProperties: false, }); API.v1.get( 'moderation.reportsByUsers', { authRequired: true, permissionsRequired: ['view-moderation-console'], query: isReportHistoryProps, response: { 200: paginatedReportsResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, }, }, async function action() { const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); const { sort } = await this.parseJsonQuery(); const latest = _latest ? new Date(_latest) : new Date(); const oldest = _oldest ? new Date(_oldest) : new Date(0); const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapeRegExp(selector), { offset, count, sort, }).toArray(); return API.v1.success({ reports, count: reports.length, offset, total: reports.length === 0 ? 0 : await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapeRegExp(selector), true), }); }, ); ``` Source: `apps/meteor/app/api/server/v1/moderation.ts` ## Step-by-Step Migration ### 1. Identify the HTTP method Look at the handler keys inside the `addRoute` call: | Handler key | New method | | ---------------- | -------------------- | | `async get()` | `API.v1.get(...)` | | `async post()` | `API.v1.post(...)` | | `async put()` | `API.v1.put(...)` | | `async delete()` | `API.v1.delete(...)` | ### 2. Replace `addRoute` with the HTTP method ```typescript // BEFORE API.v1.addRoute('endpoint.name', options, { async get() { ... } }); // AFTER API.v1.get('endpoint.name', options, async function action() { ... }); ``` The handler becomes a standalone `async function action()` (named function, not arrow function). ### 3. Move `validateParams` to `query` or `body` | HTTP method | Option name | | ----------- | -------------------- | | GET, DELETE | `query: validatorFn` | | POST, PUT | `body: validatorFn` | ```typescript // BEFORE { validateParams: isSomeEndpointProps; } // AFTER (GET) { query: isSomeEndpointProps; } // AFTER (POST) { body: isSomeEndpointProps; } ``` The `validateParams` option is deprecated. Do not use it in new code. ### 4. Create response schemas Define response schemas using `ajv.compile()` **before** the endpoint registration. Every response schema must include the `success` field. When the response contains complex types from `@rocket.chat/core-typings`, prefer using `$ref` instead of `{ type: 'object' }` (see [Using Typia `$ref` for Complex Types](#using-typia-ref-for-complex-types)). ```typescript const myResponseSchema = ajv.compile<{ items: SomeType[]; count: number }>({ type: 'object', properties: { items: { type: 'array', items: { $ref: '#/components/schemas/SomeType' } }, count: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['items', 'count', 'success'], additionalProperties: false, }); ``` For endpoints that return only `{ success: true }`: ```typescript const successResponseSchema = ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'], additionalProperties: false, }); ``` ### 5. Add the `response` object Always include error schemas for relevant status codes: ```typescript response: { 200: myResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, } ``` Add `403: validateForbiddenErrorResponse` when the endpoint has `permissionsRequired`. ### 6. Update imports ```typescript import { ajv, isMyEndpointProps, // request validator (from rest-typings) validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; ``` ## Using Typia `$ref` for Complex Types For response fields that use complex types already defined in `@rocket.chat/core-typings` (like `IMessage`, `ISubscription`, `ICustomSound`, `IPermission`, etc.), **do not rewrite the JSON schema manually**. Instead, use `$ref` to link to the typia-generated schema. ### How it works 1. **`packages/core-typings/src/Ajv.ts`** generates JSON schemas from TypeScript types at compile time using typia: ```typescript import typia from 'typia'; import type { ICustomSound } from './ICustomSound'; // ... export const schemas = typia.json.schemas< [ ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IMediaCall, CallHistoryItem, ICustomUserStatus, SlashCommand, ], '3.0' >(); ``` 2. **`apps/meteor/app/api/server/ajv.ts`** registers all generated schemas into the shared AJV instance: ```typescript import { schemas } from '@rocket.chat/core-typings'; import { ajv } from '@rocket.chat/rest-typings'; const components = schemas.components?.schemas; if (components) { for (const key in components) { if (Object.prototype.hasOwnProperty.call(components, key)) { ajv.addSchema(components[key], `#/components/schemas/${key}`); } } } ``` 3. **Endpoints** reference the schema by `$ref` instead of writing it inline: ```typescript const customSoundsResponseSchema = ajv.compile>({ type: 'object', properties: { sounds: { type: 'array', items: { $ref: '#/components/schemas/ICustomSound' }, }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['sounds', 'count', 'offset', 'total', 'success'], additionalProperties: false, }); ``` Source: `apps/meteor/app/api/server/v1/custom-sounds.ts` ### Available `$ref` schemas These types are already registered and available via `$ref`: - `#/components/schemas/ISubscription` - `#/components/schemas/IInvite` - `#/components/schemas/ICustomSound` - `#/components/schemas/IMessage` - `#/components/schemas/IOAuthApps` - `#/components/schemas/IPermission` - `#/components/schemas/IMediaCall` - `#/components/schemas/IEmailInbox` - `#/components/schemas/IImport` - `#/components/schemas/IIntegrationHistory` - `#/components/schemas/ICalendarEvent` - `#/components/schemas/IRole` - `#/components/schemas/IRoom` - `#/components/schemas/IUser` - `#/components/schemas/IModerationAudit` - `#/components/schemas/IModerationReport` - `#/components/schemas/IBanner` - `#/components/schemas/CallHistoryItem` - `#/components/schemas/ICustomUserStatus` - `#/components/schemas/SlashCommand` - `#/components/schemas/VideoConferenceCapabilities` - `#/components/schemas/CloudRegistrationIntentData` - `#/components/schemas/CloudRegistrationStatus` **Union types** — these TypeScript types are unions, so typia generates individual schemas for each variant instead of a single named schema. Use `oneOf` to reference them (see below): | TypeScript type | Generated schemas | | ----------------------------- | ----------------------------------------------------------------------------------------------------- | | `IIntegration` | `IIncomingIntegration`, `IOutgoingIntegration` | | `VideoConference` | `IDirectVideoConference`, `IGroupVideoConference`, `ILivechatVideoConference`, `IVoIPVideoConference` | | `VideoConferenceInstructions` | `DirectCallInstructions`, `ConferenceInstructions`, `LivechatInstructions` | | `CloudConfirmationPollData` | `CloudConfirmationPollDataPending`, `CloudConfirmationPollDataSuccess` | Plus any sub-types that these reference internally (e.g., `MessageMention`, `IVideoConferenceUser`, `VideoConferenceStatus`, etc.). ### Handling union types with `oneOf` When a TypeScript type is a union (e.g., `IIntegration = IIncomingIntegration | IOutgoingIntegration`), typia generates separate schemas for each variant but **no single named schema** for the union itself. Use `oneOf` to reference the variants: ```typescript // Single field integration: { oneOf: [ { $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }, ], }, // Array of union type integrations: { type: 'array', items: { oneOf: [ { $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }, ], }, }, ``` ### Handling nullable types When a field can be `null`, combine `nullable: true` with `$ref`: ```typescript // Nullable $ref report: { nullable: true, $ref: '#/components/schemas/IModerationReport' }, // Nullable union integration: { nullable: true, oneOf: [ { $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }, ], }, ``` ### Handling intersection types with `allOf` When a response intersects a type with additional properties (e.g., `VideoConferenceInstructions & { providerName: string }`), use `allOf`: ```typescript data: { allOf: [ { oneOf: [ { $ref: '#/components/schemas/DirectCallInstructions' }, { $ref: '#/components/schemas/ConferenceInstructions' }, { $ref: '#/components/schemas/LivechatInstructions' }, ], }, { type: 'object', properties: { providerName: { type: 'string' } }, required: ['providerName'] }, ], }, ``` This also applies when the API spreads a type at root level alongside `success` (e.g., `API.v1.success(emailInbox)` producing `{ ...emailInbox, success: true }`): ```typescript { allOf: [ { $ref: '#/components/schemas/IEmailInbox' }, { type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] }, ], } ``` ### Types that are NOT good `$ref` candidates Some TypeScript types cannot (or should not) be represented as a single `$ref`: - **Complex union types with many variants**: `ISetting` (union of `ISettingBase | ISettingEnterprise | ISettingColor | ...`) generates too many individual schemas without a clean single reference. - **`Pick<>` / `Omit<>` types**: `Pick` is not registered in typia as a standalone schema. Leave these as `{ type: 'object' }` or `{ type: ['object', 'null'] }`. - **Intersection + union types**: `LoginServiceConfiguration` and similar complex composed types. - **Types with `Date | string` fields**: See [Known Pitfall: `Date | string` unions](#known-pitfall-date--string-unions) below. For these cases, keep using `{ type: 'object' }` as a placeholder. ### Known Pitfall: `Date | string` unions Some core-typings define timestamp fields as `Date | string` (e.g., `IModerationAudit.ts`, `IModerationReport.ts`). When typia generates the JSON Schema for these fields, it creates a `oneOf` with two branches: one for `Date` (which maps to `{ type: "string", format: "date-time" }`) and one for `string` (which maps to `{ type: "string" }`). The problem: an ISO date string like `"2026-03-11T16:07:21.755Z"` satisfies **both** schemas simultaneously, causing AJV to fail with: ``` must match exactly one schema in oneOf (passingSchemas: 0,1) ``` This happens because `oneOf` requires **exactly one** match, but the value is both a valid `date-time` string and a valid `string`. **Workaround**: Use a relaxed inline schema instead of `$ref` for these types, defining `ts` as `{ type: 'string' }`. Add a `TODO` comment referencing this issue so it can be tracked: ```typescript // TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. // When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") // and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". // Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB // aggregation actually returns, or adjusting the AJV schema generation for union types), we use a // relaxed inline schema here that accepts `ts` as a string. ``` **Long-term fix**: Revise the core-typings to narrow `ts` to `string` (which is what MongoDB aggregation pipelines and `JSON.stringify` actually return), or adjust the AJV/typia schema generation to handle `Date | string` unions correctly (e.g., using `anyOf` instead of `oneOf`, or collapsing `Date` into `string`). ### Adding a new type to typia If you need a `$ref` for a type that is not yet registered: 1. Edit `packages/core-typings/src/Ajv.ts` 2. Import the type and add it to the `typia.json.schemas<[...]>()` type parameter list 3. Rebuild `core-typings`: `yarn workspace @rocket.chat/core-typings run build` 4. The new schema will be automatically registered at startup via `apps/meteor/app/api/server/ajv.ts` ## Chaining Endpoints and Type Augmentation Migrated endpoints **must always be chained** when a file registers multiple endpoints. Store the result in a variable, then use `ExtractRoutesFromAPI` to extract the route types and augment the `Endpoints` interface in `rest-typings`. This is what makes the endpoints fully typed across the entire codebase (SDK, client, tests). ### Full example ```typescript import type { IInvite } from '@rocket.chat/core-typings'; import { ajv, isFindOrCreateInviteParams, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; // Chain all endpoints from this file into a single variable const invites = API.v1 .get( 'listInvites', { authRequired: true, response: { 200: ajv.compile({ type: 'array', items: { $ref: '#/components/schemas/IInvite' }, additionalProperties: false, }), 401: validateUnauthorizedErrorResponse, }, }, async function action() { const result = await listInvites(this.userId); return API.v1.success(result); }, ) .post( 'findOrCreateInvite', { authRequired: true, body: isFindOrCreateInviteParams, response: { 200: findOrCreateInviteResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { rid, days, maxUses } = this.bodyParams; return API.v1.success((await findOrCreateInvite(this.userId, { rid, days, maxUses })) as IInvite); }, ); // Extract route types from the chained result type InvitesEndpoints = ExtractRoutesFromAPI; // Augment the Endpoints interface so the SDK, client hooks, and tests see these routes declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface interface Endpoints extends InvitesEndpoints {} } ``` Source: `apps/meteor/app/api/server/v1/invites.ts` ### Rules 1. **Always chain**: Every `.get()` / `.post()` / `.put()` / `.delete()` call in the same file should be chained on the same variable (e.g., `const myEndpoints = API.v1.get(...).post(...).get(...)`). 2. **Store in a `const`**: The chained result must be stored in a variable so `typeof` can extract its type. 3. **Extract with `ExtractRoutesFromAPI`**: Use `type MyEndpoints = ExtractRoutesFromAPI` to get the typed route definitions. 4. **Augment `Endpoints`**: Use `declare module '@rocket.chat/rest-typings'` to merge the extracted types into the global `Endpoints` interface. This is what makes `useEndpoint('listInvites')` and similar SDK calls type-safe. 5. **Import `ExtractRoutesFromAPI`** from `'../ApiClass'` (relative to the endpoint file in `v1/`). ### What augmentation enables Once the `Endpoints` interface is augmented, the entire stack benefits: - **Client SDK**: `useEndpoint('listInvites')` gets typed params and response - **REST client**: `api.get('/v1/listInvites')` is type-checked - **Tests**: response shape is inferred from the endpoint definition - **OpenAPI**: routes appear in the generated documentation ## Endpoints with Multiple HTTP Methods When an `addRoute` registers both GET and POST (or other combinations), split them into separate calls: ```typescript // BEFORE API.v1.addRoute('endpoint', { authRequired: true, validateParams: { GET: isGetProps, POST: isPostProps } }, { async get() { ... }, async post() { ... }, }); // AFTER API.v1.get('endpoint', { authRequired: true, query: isGetProps, response: { 200: getResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse }, }, async function action() { ... }); API.v1.post('endpoint', { authRequired: true, body: isPostProps, response: { 200: postResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse }, }, async function action() { ... }); ``` ## Test Changes Migrating an endpoint changes how validation errors are returned. Tests must be updated accordingly. ### `errorType` changes for query parameter validation The new router returns a different `errorType` for query parameter validation errors: ```typescript // BEFORE (addRoute with validateParams) expect(res.body).to.have.property('errorType', 'invalid-params'); // AFTER (.get() with query) expect(res.body).to.have.property('errorType', 'error-invalid-params'); ``` This only affects **query** parameter validation (GET/DELETE). Body parameter validation (POST/PUT) keeps `'invalid-params'`. ### Error message format changes The `[invalid-params]` suffix is removed from error messages: ```typescript // BEFORE expect(res.body).to.have.property('error', "must have required property 'platform' [invalid-params]"); // AFTER expect(res.body).to.have.property('error', "must have required property 'platform'"); ``` ### Summary of test changes per endpoint When migrating an endpoint, search for its tests and update: 1. `errorType` from `'invalid-params'` to `'error-invalid-params'` (for query params only) 2. Remove `' [invalid-params]'` suffix from `error` message assertions 3. Verify that status codes remain the same (400 for validation errors) ## Tracking Migration Progress ```bash # Summary by file node scripts/list-unmigrated-api-endpoints.mjs # Full list with line numbers (JSON) node scripts/list-unmigrated-api-endpoints.mjs --json ``` The script scans for `API.v1.addRoute` and `API.default.addRoute` calls in `apps/meteor/app/api/`. ## Reference Files | Pattern | File | | -------------------------------- | ------------------------------------------------ | | Chaining + augmentation | `apps/meteor/app/api/server/v1/invites.ts` | | Chaining + augmentation + `$ref` | `apps/meteor/app/api/server/v1/custom-sounds.ts` | | GET with `$ref` to typia schemas | `apps/meteor/app/api/server/v1/custom-sounds.ts` | | GET with pagination | `apps/meteor/app/api/server/v1/moderation.ts` | | POST endpoint | `apps/meteor/app/api/server/v1/import.ts` | | Multiple endpoints (misc) | `apps/meteor/app/api/server/v1/misc.ts` | | GET with permissions | `apps/meteor/app/api/server/v1/permissions.ts` | | Typia schema generation | `packages/core-typings/src/Ajv.ts` | | AJV schema registration | `apps/meteor/app/api/server/ajv.ts` | | Error response validators | `packages/rest-typings/src/v1/Ajv.ts` | | Request validators (examples) | `packages/rest-typings/src/v1/moderation/` | | Router implementation | `packages/http-router/src/Router.ts` | | Unmigrated endpoints script | `scripts/list-unmigrated-api-endpoints.mjs` |