diff --git a/.changeset/healthy-dolls-pretend.md b/.changeset/healthy-dolls-pretend.md new file mode 100644 index 0000000000000..22182e97df817 --- /dev/null +++ b/.changeset/healthy-dolls-pretend.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat chat.postMessage API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 6c5e5cad48a31..9cde47e457ae6 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1,5 +1,5 @@ import { Message } from '@rocket.chat/core-services'; -import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IUser, IThreadMainMessage, MessageAttachment, RequiredField } from '@rocket.chat/core-typings'; import { MessageTypes } from '@rocket.chat/message-types'; import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; import { @@ -11,7 +11,6 @@ import { isChatDeleteProps, isChatSyncMessagesProps, isChatGetMessageProps, - isChatPostMessageProps, isChatSearchProps, isChatSendMessageProps, isChatStarMessageProps, @@ -32,6 +31,7 @@ import { validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; +import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; @@ -284,43 +284,219 @@ const chatEndpoints = API.v1 }, ); -API.v1.addRoute( - 'chat.postMessage', - { authRequired: true, validateParams: isChatPostMessageProps }, - { - async post() { - const { text, attachments } = this.bodyParams; - const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0; +type ChatPostMessage = + | { + roomId: string | string[]; + text?: string; + alias?: string; + emoji?: string; + avatar?: string; + attachments?: MessageAttachment[]; + customFields?: IMessage['customFields']; + } + | { + channel: string | string[]; + text?: string; + alias?: string; + emoji?: string; + avatar?: string; + attachments?: MessageAttachment[]; + customFields?: IMessage['customFields']; + }; + +const ChatPostMessageSchema = { + type: 'object', + properties: { + roomId: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + }, + ], + }, + channel: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + }, + ], + }, + text: { type: 'string' }, + alias: { type: 'string' }, + emoji: { type: 'string' }, + avatar: { type: 'string' }, + attachments: { + type: 'array', + items: { type: 'object' }, + }, + customFields: { type: 'object' }, + }, + additionalProperties: false, + required: [], +}; - if (text && text.length > maxAllowedSize) { - return API.v1.failure('error-message-size-exceeded'); - } +const isChatPostMessageProps = ajv.compile(ChatPostMessageSchema); - if (attachments && attachments.length > 0) { - for (const attachment of attachments) { - if (attachment.text && attachment.text.length > maxAllowedSize) { - return API.v1.failure('error-message-size-exceeded'); - } +const chatPostMessageEndpoints = API.v1.post( + 'chat.postMessage', + { + authRequired: true, + body: isChatPostMessageProps, + response: { + 400: ajv.compile<{ + error?: string; + errorType?: string; + stack?: string; + details?: string; + }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + stack: { type: 'string' }, + error: { type: 'string' }, + errorType: { type: 'string' }, + details: { type: 'string' }, + }, + required: ['success'], + additionalProperties: false, + }), + 401: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + status: { type: 'string' }, + message: { type: 'string' }, + error: { type: 'string' }, + errorType: { type: 'string' }, + }, + required: ['success'], + additionalProperties: false, + }), + 200: ajv.compile<{ + ts: number; + channel: string; + message: IMessage; + success: boolean; + }>({ + type: 'object', + properties: { + ts: { type: 'number' }, + channel: { type: 'string' }, + message: { + // ? Accepts any type for message + type: 'object', + properties: { + alias: { type: 'string' }, + msg: { type: 'string' }, + attachments: { type: 'array' }, + parseUrls: { type: 'boolean' }, + groupable: { type: 'boolean' }, + // ? Set this as a string type with date-time as a format, instead of using an object type + ts: { + oneOf: [{ type: 'string', format: 'date-time' }, { type: 'object' }], + }, + u: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + }, + required: ['_id', 'name', 'username'], + additionalProperties: false, + }, + rid: { type: 'string' }, + _id: { type: 'string' }, + // ? Set this as a string type with date-time as a format, instead of using an object type + _updatedAt: { + oneOf: [{ type: 'string', format: 'date-time' }, { type: 'object' }], + }, + urls: { type: 'array', items: { type: 'string' } }, + mentions: { + type: 'array', + items: { + type: 'object', + }, + }, + channels: { + type: 'array', + items: { + type: 'object', + }, + }, + md: { type: 'array' }, + tmid: { type: 'string' }, + tshow: { type: 'boolean' }, + emoji: { type: 'string' }, + // ? Should customFields have properties implement in the IMessageCustomFields interface and ensure it is not empty? + customFields: { + type: 'object', + properties: {}, + additionalProperties: true, + }, + }, + required: ['msg', '_updatedAt'], + additionalProperties: false, + }, + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['ts', 'channel', 'message', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { text, attachments } = this.bodyParams; + const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0; + + if (text && text.length > maxAllowedSize) { + return API.v1.failure('error-message-size-exceeded'); + } + + if (attachments && attachments.length > 0) { + for (const attachment of attachments) { + if (attachment.text && attachment.text.length > maxAllowedSize) { + return API.v1.failure('error-message-size-exceeded'); } } - - const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; - - if (!messageReturn?.message) { - return API.v1.failure('unknown-error'); - } - - const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId); - - return API.v1.success({ - ts: Date.now(), - channel: messageReturn.channel, - message, - }); - }, + } + console.log('this:', this.bodyParams); + const messageReturn = ( + await applyAirGappedRestrictionsValidation(() => + processWebhookMessage(this.bodyParams, this.user as IUser & { username: RequiredField }), + ) + )[0]; + console.log('messageReturn:', messageReturn); + if (!messageReturn) { + return API.v1.failure('unknown-error'); + } + + const [message] = (await normalizeMessagesForUser([messageReturn.message], this.userId)) as IMessage[]; + console.log('message:', message); + return API.v1.success({ + ts: Date.now(), + channel: messageReturn.channel, + message, + }); }, ); +export type ChatPostMessageEndpoints = ExtractRoutesFromAPI; + +// TODO: Need to remove the ChatEndpoints packages/rest-typings/src/index.ts file, but only after implementing all the endpoints. +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends ChatPostMessageEndpoints {} +} + API.v1.addRoute( 'chat.search', { authRequired: true, validateParams: isChatSearchProps }, diff --git a/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts b/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts index e1aeabe1b46a5..495eeb1eb5c78 100644 --- a/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts +++ b/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts @@ -1,9 +1,9 @@ import { Room } from '@rocket.chat/core-services'; import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; import { Rooms, Users } from '@rocket.chat/models'; +import { isObject } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; -import { isObject } from '../../../../lib/utils/isObject'; import { createDirectMessage } from '../../../../server/methods/createDirectMessage'; export const getRoomByNameOrIdWithOptionToJoin = async ({ diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index c73aba40d1d48..bb72282866a4c 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -1,12 +1,4 @@ -import type { - IMessage, - IRoom, - MessageAttachment, - ReadReceipt, - OtrSystemMessages, - MessageUrl, - IThreadMainMessage, -} from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, ReadReceipt, OtrSystemMessages, MessageUrl, IThreadMainMessage } from '@rocket.chat/core-typings'; import { ajv } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -728,132 +720,6 @@ const ChatGetDeletedMessagesSchema = { export const isChatGetDeletedMessagesProps = ajv.compile(ChatGetDeletedMessagesSchema); -type ChatPostMessage = - | { - roomId: string | string[]; - text?: string; - alias?: string; - emoji?: string; - avatar?: string; - attachments?: MessageAttachment[]; - customFields?: IMessage['customFields']; - } - | { - channel: string | string[]; - text?: string; - alias?: string; - emoji?: string; - avatar?: string; - attachments?: MessageAttachment[]; - customFields?: IMessage['customFields']; - }; - -const ChatPostMessageSchema = { - oneOf: [ - { - type: 'object', - properties: { - roomId: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { - type: 'string', - }, - }, - ], - }, - text: { - type: 'string', - nullable: true, - }, - alias: { - type: 'string', - nullable: true, - }, - emoji: { - type: 'string', - nullable: true, - }, - avatar: { - type: 'string', - nullable: true, - }, - attachments: { - type: 'array', - items: { - type: 'object', - }, - nullable: true, - }, - tmid: { - type: 'string', - }, - customFields: { - type: 'object', - nullable: true, - }, - parseUrls: { - type: 'boolean', - }, - }, - required: ['roomId'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - channel: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { - type: 'string', - }, - }, - ], - }, - text: { - type: 'string', - nullable: true, - }, - alias: { - type: 'string', - nullable: true, - }, - emoji: { - type: 'string', - nullable: true, - }, - avatar: { - type: 'string', - nullable: true, - }, - attachments: { - type: 'array', - items: { - type: 'object', - }, - nullable: true, - }, - customFields: { - type: 'object', - nullable: true, - }, - parseUrls: { - type: 'boolean', - }, - }, - required: ['channel'], - additionalProperties: false, - }, - ], -}; - -export const isChatPostMessageProps = ajv.compile(ChatPostMessageSchema); - type ChatGetURLPreview = { roomId: IRoom['_id']; url: string; @@ -1001,13 +867,6 @@ export type ChatEndpoints = { }; }; }; - '/v1/chat.postMessage': { - POST: (params: ChatPostMessage) => { - ts: number; - channel: IRoom; - message: IMessage; - }; - }; '/v1/chat.syncThreadMessages': { GET: (params: ChatSyncThreadMessages) => { messages: {