Skip to content

Latest commit

 

History

History
637 lines (505 loc) · 23.2 KB

File metadata and controls

637 lines (505 loc) · 23.2 KB

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

  1. packages/core-typings/src/Ajv.ts generates 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'
    >();
  2. apps/meteor/app/api/server/ajv.ts registers 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}`);
    		}
    	}
    }
  3. Endpoints reference the schema by $ref instead 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 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 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:

  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

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:

// 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-requests429
  • unauthorized / error-unauthorized401 (or 403 pre-breaking-changes)
  • forbidden / error-forbidden403
  • 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:

  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)

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