diff --git a/.changeset/sharp-lemons-refuse.md b/.changeset/sharp-lemons-refuse.md new file mode 100644 index 0000000000000..cc6f8af59fcce --- /dev/null +++ b/.changeset/sharp-lemons-refuse.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat dm.close/im.close 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/im.ts b/apps/meteor/app/api/server/v1/im.ts index 099d17141f8b4..6446ccfedd8c2 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -99,6 +99,21 @@ type DmDeleteProps = username: string; }; +type DmCloseProps = { + roomId: string; + userId: string; +}; + +const DmClosePropsSchema = { + type: 'object', + properties: { + roomId: { type: 'string' }, + userId: { type: 'string' }, + }, + required: ['roomId', 'userId'], + additionalProperties: false, +}; + const isDmDeleteProps = ajv.compile({ oneOf: [ { @@ -124,6 +139,8 @@ const isDmDeleteProps = ajv.compile({ ], }); +const isDmCloseProps = ajv.compile(DmClosePropsSchema); + const dmDeleteEndpointsProps = { authRequired: true, body: isDmDeleteProps, @@ -144,6 +161,41 @@ const dmDeleteEndpointsProps = { }, } as const; +const dmCloseEndpointsProps = { + authRequired: true, + body: isDmCloseProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + // TODO: The 403 Forbidden response is not handled as well as 400 responses. + // Currently using `never` as a placeholder type. Replace it with the correct + // schema once proper 403 error handling is implemented. + 403: 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({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; + const dmDeleteAction = (_path: Path): TypedAction => async function action() { const { room } = await findDirectMessageRoom(this.bodyParams, this.userId); @@ -160,51 +212,48 @@ const dmDeleteAction = (_path: Path): TypedAction(_path: Path): TypedAction => + async function action() { + const { roomId } = this.bodyParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + } -API.v1.addRoute( - ['dm.close', 'im.close'], - { authRequired: true }, - { - async post() { - const { roomId } = this.bodyParams; - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + let subscription; + + const roomExists = !!(await Rooms.findOneById(roomId)); + if (!roomExists) { + // even if the room doesn't exist, we should allow the user to close the subscription anyways + subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); + } else { + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden(); } - let subscription; + const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); - const roomExists = !!(await Rooms.findOneById(roomId)); - if (!roomExists) { - // even if the room doesn't exist, we should allow the user to close the subscription anyways - subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); - } else { - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } - - const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); + subscription = subs; + } - subscription = subs; - } + if (!subscription) { + return API.v1.failure(`The user is not subscribed to the room`); + } - if (!subscription) { - return API.v1.failure(`The user is not subscribed to the room`); - } + if (!subscription.open) { + return API.v1.failure(`The direct message room, is already closed to the sender`); + } - if (!subscription.open) { - return API.v1.failure(`The direct message room, is already closed to the sender`); - } + await hideRoomMethod(this.userId, roomId); - await hideRoomMethod(this.userId, roomId); + return API.v1.success(); + }; - return API.v1.success(); - }, - }, -); +const dmEndpoints = API.v1 + .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete')) + .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete')) + .post('dm.close', dmCloseEndpointsProps, dmCloseAction('dm.close')) + .post('im.close', dmCloseEndpointsProps, dmCloseAction('im.close')); // https://github.com/RocketChat/Rocket.Chat/pull/9679 as reference API.v1.addRoute( diff --git a/packages/rest-typings/src/v1/Ajv.ts b/packages/rest-typings/src/v1/Ajv.ts index 35e3d57878fd7..5072d398ade64 100644 --- a/packages/rest-typings/src/v1/Ajv.ts +++ b/packages/rest-typings/src/v1/Ajv.ts @@ -68,7 +68,7 @@ const UnauthorizedErrorResponseSchema = { export const validateUnauthorizedErrorResponse = ajv.compile(UnauthorizedErrorResponseSchema); -type ForbiddenErrorResponse = { +export type ForbiddenErrorResponse = { success: false; status?: string; message?: string; diff --git a/packages/rest-typings/src/v1/dm/DmCloseProps.ts b/packages/rest-typings/src/v1/dm/DmCloseProps.ts deleted file mode 100644 index 4a2d186896f1e..0000000000000 --- a/packages/rest-typings/src/v1/dm/DmCloseProps.ts +++ /dev/null @@ -1,22 +0,0 @@ -import Ajv from 'ajv'; - -const ajv = new Ajv({ - coerceTypes: true, -}); - -export type DmCloseProps = { - roomId: string; -}; - -const DmClosePropsSchema = { - type: 'object', - properties: { - roomId: { - type: 'string', - }, - }, - required: ['roomId'], - additionalProperties: false, -}; - -export const isDmCloseProps = ajv.compile(DmClosePropsSchema); diff --git a/packages/rest-typings/src/v1/dm/dm.ts b/packages/rest-typings/src/v1/dm/dm.ts index ccaf19c7810ef..bbc4af18659a8 100644 --- a/packages/rest-typings/src/v1/dm/dm.ts +++ b/packages/rest-typings/src/v1/dm/dm.ts @@ -2,7 +2,6 @@ import type { ImEndpoints } from './im'; export type DmEndpoints = { '/v1/dm.create': ImEndpoints['/v1/im.create']; - '/v1/dm.close': ImEndpoints['/v1/im.close']; '/v1/dm.counters': ImEndpoints['/v1/im.counters']; '/v1/dm.files': ImEndpoints['/v1/im.files']; '/v1/dm.history': ImEndpoints['/v1/im.history']; diff --git a/packages/rest-typings/src/v1/dm/im.ts b/packages/rest-typings/src/v1/dm/im.ts index 1aa9b60420098..96afcc470b321 100644 --- a/packages/rest-typings/src/v1/dm/im.ts +++ b/packages/rest-typings/src/v1/dm/im.ts @@ -1,6 +1,5 @@ import type { IMessage, IRoom, IUser, IUploadWithUser } from '@rocket.chat/core-typings'; -import type { DmCloseProps } from './DmCloseProps'; import type { DmCreateProps } from './DmCreateProps'; import type { DmFileProps } from './DmFileProps'; import type { DmHistoryProps } from './DmHistoryProps'; @@ -16,12 +15,6 @@ export type ImEndpoints = { room: IRoom & { rid: IRoom['_id'] }; }; }; - '/v1/im.close': { - POST: (params: DmCloseProps) => void; - }; - '/v1/im.kick': { - POST: (params: DmCloseProps) => void; - }; '/v1/im.leave': { POST: (params: DmLeaveProps) => void; };