snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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
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
This commit is contained in:
637
docs/api-endpoint-migration.md
Normal file
637
docs/api-endpoint-migration.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# 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() { ... });
|
||||
```
|
||||
|
||||
## 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` → `429`
|
||||
- `unauthorized` / `error-unauthorized` → `401` (or `403` pre-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`.
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```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)
|
||||
|
||||
## 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` |
|
||||
Reference in New Issue
Block a user