Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/late-buttons-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/rest-typings": minor
---

feat: Adds OpenAPI support
221 changes: 204 additions & 17 deletions apps/meteor/app/api/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import type { JoinPathPattern, Method } from '@rocket.chat/rest-typings';
import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv';
import { wrapExceptions } from '@rocket.chat/tools';
import type { ValidateFunction } from 'ajv';
import express from 'express';
import type { Request, Response } from 'express';
import { Accounts } from 'meteor/accounts-base';
Expand All @@ -29,6 +31,8 @@ import type {
PartialThis,
SuccessResult,
TypedThis,
TypedAction,
TypedOptions,
UnauthorizedResult,
} from './definition';
import { getUserInfo } from './helpers/getUserInfo';
Expand All @@ -37,6 +41,7 @@ import { cors } from './middlewares/cors';
import { loggerMiddleware } from './middlewares/logger';
import { metricsMiddleware } from './middlewares/metrics';
import { tracerSpanMiddleware } from './middlewares/tracer';
import type { Route } from './router';
import { Router } from './router';
import { isObject } from '../../../lib/utils/isObject';
import { getNestedProp } from '../../../server/lib/getNestedProp';
Expand Down Expand Up @@ -141,7 +146,14 @@ const generateConnection = (
clientAddress: ipAddress,
});

export class APIClass<TBasePath extends string = ''> {
export class APIClass<
TBasePath extends string = '',
TOperations extends {
[x: string]: unknown;
} = {},
> {
public typedRoutes: Record<string, Record<string, Route>> = {};

protected apiPath?: string;

readonly version?: string;
Expand Down Expand Up @@ -496,6 +508,182 @@ export class APIClass<TBasePath extends string = ''> {
return routeActions.map((action) => this.getFullRouteName(route, action));
}

private registerTypedRoutesLegacy<TSubPathPattern extends string, TOptions extends Options>(
method: Method,
subpath: TSubPathPattern,
options: TOptions,
): void {
const { authRequired, validateParams } = options;

const opt = {
authRequired,
...(validateParams &&
method.toLowerCase() === 'get' &&
('GET' in validateParams
? { query: validateParams.GET }
: {
query: validateParams as ValidateFunction<any>,
})),

...(validateParams &&
method.toLowerCase() === 'post' &&
('POST' in validateParams ? { query: validateParams.POST } : { body: validateParams as ValidateFunction<any> })),

...(validateParams &&
method.toLowerCase() === 'put' &&
('PUT' in validateParams ? { query: validateParams.PUT } : { body: validateParams as ValidateFunction<any> })),
...(validateParams &&
method.toLowerCase() === 'delete' &&
('DELETE' in validateParams ? { query: validateParams.DELETE } : { body: validateParams as ValidateFunction<any> })),

tags: ['Missing Documentation'],
response: {
200: ajv.compile({
type: 'object',
properties: {
success: { type: 'boolean' },
error: { type: 'string' },
},
required: ['success'],
}),
},
};

this.registerTypedRoutes(method, subpath, opt);
}

private registerTypedRoutes<
TSubPathPattern extends string,
TOptions extends TypedOptions,
TPathPattern extends `${TBasePath}/${TSubPathPattern}`,
>(method: Method, subpath: TSubPathPattern, options: TOptions): void {
const path = `/${this.apiPath}/${subpath}`.replaceAll('//', '/') as TPathPattern;
this.typedRoutes = this.typedRoutes || {};
this.typedRoutes[path] = this.typedRoutes[subpath] || {};
const { query, authRequired, response, body, tags, ...rest } = options;
this.typedRoutes[path][method.toLowerCase()] = {
...(response && {
responses: Object.fromEntries(
Object.entries(response).map(([status, schema]) => [
status,
{
description: '',
content: {
'application/json': 'schema' in schema ? { schema: schema.schema } : schema,
},
},
]),
),
}),
...(query && {
parameters: [
{
schema: query.schema,
in: 'query',
name: 'query',
required: true,
},
],
}),
...(body && {
requestBody: {
required: true,
content: {
'application/json': { schema: body.schema },
},
},
}),
...(authRequired && {
...rest,
security: [
{
userId: [],
authToken: [],
},
],
}),
tags,
};
}

private method<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
method: Method,
subpath: TSubPathPattern,
options: TOptions,
action: TypedAction<TOptions>,
): APIClass<
TBasePath,
| TOperations
| ({
method: Method;
path: TPathPattern;
} & Omit<TOptions, 'response'>)
> {
this.addRoute([subpath], { ...options, typed: true }, { [method.toLowerCase()]: { action } } as any);
this.registerTypedRoutes(method, subpath, options);
return this;
}

get<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
subpath: TSubPathPattern,
options: TOptions,
action: TypedAction<TOptions>,
): APIClass<
TBasePath,
| TOperations
| ({
method: 'GET';
path: TPathPattern;
} & Omit<TOptions, 'response'>)
> {
return this.method('GET', subpath, options, action);
}

post<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
subpath: TSubPathPattern,
options: TOptions,
action: TypedAction<TOptions>,
): APIClass<
TBasePath,
| TOperations
| ({
method: 'POST';
path: TPathPattern;
} & Omit<TOptions, 'response'>)
> {
return this.method('POST', subpath, options, action);
}

put<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
subpath: TSubPathPattern,
options: TOptions,
action: TypedAction<TOptions>,
): APIClass<
TBasePath,
| TOperations
| ({
method: 'PUT';
path: TPathPattern;
} & Omit<TOptions, 'response'>)
> {
return this.method('PUT', subpath, options, action);
}

delete<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
subpath: TSubPathPattern,
options: TOptions,
action: TypedAction<TOptions>,
): APIClass<
TBasePath,
| TOperations
| ({
method: 'DELETE';
path: TPathPattern;
} & Omit<TOptions, 'response'>)
> {
return this.method('DELETE', subpath, options, action);
}

addRoute<TSubPathPattern extends string>(
subpath: TSubPathPattern,
operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>>,
Expand Down Expand Up @@ -699,14 +887,19 @@ export class APIClass<TBasePath extends string = ''> {
(operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).logger = logger;
this.router[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](
`/${route}`.replaceAll('//', '/'),
{} as any,
_options as TypedOptions,
(operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).action as any,
);
this._routes.push({
path: route,
options: _options,
endpoints: operations[method as keyof Operations<TPathPattern, TOptions>] as unknown as Record<string, string>,
});

this.registerTypedRoutesLegacy(method as Method, route, {
...options,
...operations[method as keyof Operations<TPathPattern, TOptions>],
});
});
});
}
Expand Down Expand Up @@ -938,17 +1131,13 @@ export class APIClass<TBasePath extends string = ''> {
}
}

const createApi = function _createApi(options: { version?: string; apiPath?: string } = {}): APIClass {
return new APIClass(
Object.assign(
{
apiPath: 'api/',
useDefaultAuth: true,
prettyJson: process.env.NODE_ENV === 'development',
},
options,
) as IAPIProperties,
);
const createApi = function _createApi(options: { version?: string; useDefaultAuth?: true } = {}): APIClass {
return new APIClass({
apiPath: '',
useDefaultAuth: false,
prettyJson: process.env.NODE_ENV === 'development',
...options,
});
};

export const API: {
Expand Down Expand Up @@ -982,12 +1171,10 @@ export const API: {
ApiClass: APIClass,
api: new Router('/api'),
v1: createApi({
apiPath: '',
version: 'v1',
useDefaultAuth: true,
}),
default: createApi({
apiPath: '',
}),
default: createApi({}),
};

settings.watch<string>('Accounts_CustomFields', (value) => {
Expand Down
90 changes: 90 additions & 0 deletions apps/meteor/app/api/server/default/openApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings';
import express from 'express';
import { WebApp } from 'meteor/webapp';
import swaggerUi from 'swagger-ui-express';

import { settings } from '../../../settings/server';
import { Info } from '../../../utils/rocketchat.info';
import { API } from '../api';
import type { Route } from '../router';

const app = express();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-reviewSecurity

import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

const app = express();
app.use(helmet());
app.use(rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 500 // higher limit for API docs
}));

Lack of security measures such as input validation, origin checks, and security middleware, increasing the risk of security vulnerabilities.

This issue appears in multiple locations:

  • apps/meteor/app/api/server/default/openApi.ts: Lines 11-11
  • apps/meteor/client/lib/utils/fireGlobalEvent.ts: Lines 17-17
  • apps/meteor/client/lib/utils/fireGlobalEventBase.ts: Lines 8-14

Please add security measures such as input validation, origin checks, and security middleware to protect against potential security threats.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment on lines +8 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-reviewError Handling

const getTypedRoutes = (
	typedRoutes: Record<string, Record<string, Route>>,
	{ withUndocumented = false }: { withUndocumented?: boolean } = {},
): Record<string, Record<string, Route>> => {
	if (!typedRoutes || typeof typedRoutes !== 'object') {
		throw new Error('Invalid typedRoutes: Expected a non-null object with Route records');
	}

Add error handling for potential undefined or malformed typedRoutes input to prevent runtime errors.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


const getTypedRoutes = (
typedRoutes: Record<string, Record<string, Route>>,
{ withUndocumented = false }: { withUndocumented?: boolean } = {},
): Record<string, Record<string, Route>> => {
if (withUndocumented) {
return typedRoutes;
}

return Object.entries(typedRoutes).reduce(
(acc, [path, methods]) => {
const filteredMethods = Object.entries(methods)
.filter(([_, options]) => !options?.tags?.includes('Missing Documentation'))
.reduce(
(acc, [method, options]) => {
acc[method] = options;
return acc;
},
{} as Record<string, Route>,
);

if (Object.keys(filteredMethods).length > 0) {
acc[path] = filteredMethods;
}

return acc;
},
{} as Record<string, Record<string, Route>>,
);
};

const makeOpenAPIResponse = (paths: Record<string, Record<string, Route>>) => ({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-reviewMaintainability

const BASE_OPENAPI_CONFIG = {
	openapi: '3.0.3',
	info: {
		title: 'Rocket.Chat API',
		description: 'Rocket.Chat API',
		version: Info.version,
	},
	components: {
		securitySchemes: {
			userId: {
				type: 'apiKey',
				in: 'header',
				name: 'X-User-Id',
			},
			authToken: {
				type: 'apiKey',
				in: 'header',
				name: 'X-Auth-Token',
			},
		},
		schemas: {},
	},
};

const makeOpenAPIResponse = (paths: Record<string, Record<string, Route>>) => ({
	...BASE_OPENAPI_CONFIG,
	servers: [{ url: settings.get('Site_Url') }],
	paths,
});

Extract the OpenAPI configuration object into a separate constant to improve maintainability and reusability

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

openapi: '3.0.3',
info: {
title: 'Rocket.Chat API',
description: 'Rocket.Chat API',
version: Info.version,
},
servers: [
{
url: settings.get('Site_Url'),
},
],
components: {
securitySchemes: {
userId: {
type: 'apiKey',
in: 'header',
name: 'X-User-Id',
},
authToken: {
type: 'apiKey',
in: 'header',
name: 'X-Auth-Token',
},
},
schemas: {},
},
paths,
});

API.default.addRoute(
'docs/json',
{ authRequired: false, validateParams: isOpenAPIJSONEndpoint },
{
get() {
const { withUndocumented = false } = this.queryParams;

return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.v1.typedRoutes, { withUndocumented })));
},
},
);

app.use(
'/api-docs',
swaggerUi.serve,
swaggerUi.setup(makeOpenAPIResponse(getTypedRoutes(API.v1.typedRoutes, { withUndocumented: false }))),
);
WebApp.connectHandlers.use(app);
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export type TypedOptions = {
query?: ValidateFunction;
body?: ValidateFunction;
tags?: string[];
typed?: boolean;
} & Options;

export type TypedThis<TOptions extends TypedOptions, TPath extends string = ''> = {
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ import './v1/voip/omnichannel';
import './v1/voip';
import './v1/federation';
import './v1/moderation';
import './default/openApi';

export { API, APIClass, defaultRateLimiterOptions } from './api';
Loading
Loading