SIGN IN SIGN UP
RocketChat / Rocket.Chat UNCLAIMED

The Secure CommsOS™ for mission-critical operations

45034 0 0 TypeScript
# 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<T>()` **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<void>({
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<PaginatedResult<{ sounds: ICustomSound[] }>>({
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<IUser, 'username' | 'name' | '_id'>` 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<IInvite[]>({
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<typeof invites>;
// 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<typeof myEndpoints>` 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` |