23 KiB
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)
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)
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
// 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 |
// 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).
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 }:
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:
response: {
200: myResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
}
Add 403: validateForbiddenErrorResponse when the endpoint has permissionsRequired.
6. Update imports
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
-
packages/core-typings/src/Ajv.tsgenerates JSON schemas from TypeScript types at compile time using typia: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' >(); -
apps/meteor/app/api/server/ajv.tsregisters all generated schemas into the shared AJV instance: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}`); } } } -
Endpoints reference the schema by
$refinstead of writing it inline: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:
// 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:
// 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:
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 }):
{
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 ofISettingBase | 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:
LoginServiceConfigurationand similar complex composed types. - Types with
Date | stringfields: See Known Pitfall:Date | stringunions 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:
// 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:
- Edit
packages/core-typings/src/Ajv.ts - Import the type and add it to the
typia.json.schemas<[...]>()type parameter list - Rebuild
core-typings:yarn workspace @rocket.chat/core-typings run build - 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
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
- 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(...)). - Store in a
const: The chained result must be stored in a variable sotypeofcan extract its type. - Extract with
ExtractRoutesFromAPI: Usetype MyEndpoints = ExtractRoutesFromAPI<typeof myEndpoints>to get the typed route definitions. - Augment
Endpoints: Usedeclare module '@rocket.chat/rest-typings'to merge the extracted types into the globalEndpointsinterface. This is what makesuseEndpoint('listInvites')and similar SDK calls type-safe. - Import
ExtractRoutesFromAPIfrom'../ApiClass'(relative to the endpoint file inv1/).
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:
// 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() { ... });
Error Handling (thrown errors)
Every route registered through API.v1.get/post/put/delete is wrapped by ApiClass's internal handler (_internalRouteActionHandler), which runs the action inside a try/catch. A thrown Meteor.Error (or any core-services error carrying error/message by shape) is mapped to the matching HTTP failure:
error-too-many-requests→429unauthorized/error-unauthorized→401(or403pre-breaking-changes)forbidden/error-forbidden→403- anything else →
API.v1.failure(message, errorType, stack, details)→400
So a migrated handler should just throw to signal a client error — exactly as the legacy DDP methods and addRoute handlers did. Do not wrap every handler in a try/catch that returns API.v1.failure(...): it is redundant with the global wrapper and, worse, flattens 401/403/429 into 400.
async function action() {
const room = await findChannelByIdOrName({ params: this.bodyParams }); // throws error-room-not-found
// ...
return API.v1.success({ channel: room });
}
Declare in the response block every error status the handler (or the wrapper) can produce, so response validation under TEST_MODE accepts them — 400: validateBadRequestErrorResponse for thrown/failure errors, plus 401/403 when authRequired/permissionsRequired are set.
Add an explicit catch only when you need behaviour the global wrapper does not provide (e.g. returning a specific API.v1.failure(...) payload for a known condition, or mapping a status differently); otherwise let the error propagate.
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:
// 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:
// 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:
errorTypefrom'invalid-params'to'error-invalid-params'(for query params only)- Remove
' [invalid-params]'suffix fromerrormessage assertions - Verify that status codes remain the same (400 for validation errors)
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 |