diff --git a/.changeset/flat-candles-walk.md b/.changeset/flat-candles-walk.md new file mode 100644 index 0000000000000..41e169237e6a0 --- /dev/null +++ b/.changeset/flat-candles-walk.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat subscriptions.getOne 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/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index b6e406bbfc969..5e46f5544db4c 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -1,7 +1,10 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; import { + ajv, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, isSubscriptionsGetProps, - isSubscriptionsGetOneProps, isSubscriptionsReadProps, isSubscriptionsUnreadProps, } from '@rocket.chat/rest-typings'; @@ -10,6 +13,7 @@ import { Meteor } from 'meteor/meteor'; import { readMessages } from '../../../../server/lib/readMessages'; import { getSubscriptions } from '../../../../server/publications/subscription'; import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; API.v1.addRoute( @@ -44,24 +48,53 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +type SubscriptionsGetOne = { roomId: IRoom['_id'] }; + +const SubscriptionsGetOneSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + minLength: 1, + }, + }, + required: ['roomId'], + additionalProperties: false, +}; + +const isSubscriptionsGetOneProps = ajv.compile(SubscriptionsGetOneSchema); + +const subscriptionsEndpoints = API.v1.get( 'subscriptions.getOne', { authRequired: true, - validateParams: isSubscriptionsGetOneProps, + query: isSubscriptionsGetOneProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ subscription: ISubscription | null }>({ + type: 'object', + properties: { + subscription: { + anyOf: [{ type: 'null' }, { $ref: '#/components/schemas/ISubscription' }], + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['subscription', 'success'], + additionalProperties: false, + }), + }, }, - { - async get() { - const { roomId } = this.queryParams; - if (!roomId) { - return API.v1.failure("The 'roomId' param is required"); - } + async function action() { + const { roomId } = this.queryParams; - return API.v1.success({ - subscription: await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId), - }); - }, + return API.v1.success({ + subscription: await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId), + }); }, ); @@ -115,3 +148,10 @@ API.v1.addRoute( }, }, ); + +export type SubscriptionsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends SubscriptionsEndpoints {} +} diff --git a/packages/http-router/src/Router.spec.ts b/packages/http-router/src/Router.spec.ts index cedaa083925fb..5c32dfba2db60 100644 --- a/packages/http-router/src/Router.spec.ts +++ b/packages/http-router/src/Router.spec.ts @@ -552,7 +552,7 @@ describe('Router', () => { app.use(api.use(test).router); const response = await request(app).get('/api/test'); expect(response.statusCode).toBe(400); - expect(response.body).toHaveProperty('error', "must have required property 'customProperty'"); + expect(response.body).toHaveProperty('error', "must have required property 'customProperty' [invalid-params]"); }); it('should fail if the body request is not valid', async () => { const ajv = new Ajv(); diff --git a/packages/http-router/src/Router.ts b/packages/http-router/src/Router.ts index a266295847d4b..4c119d96817d9 100644 --- a/packages/http-router/src/Router.ts +++ b/packages/http-router/src/Router.ts @@ -215,7 +215,7 @@ export class Router< { success: false, errorType: 'error-invalid-params', - error: validatorFn.errors?.map((error: any) => error.message).join('\n '), + error: `${validatorFn.errors?.map((error: any) => error.message).join('\n ')} [invalid-params]`, }, 400, ); diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index f85013fd4bce4..5cf3f494acd30 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -1848,13 +1848,17 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri // INSERT async createWithRoomAndUser(room: IRoom, user: IUser, extraData: Partial = {}): Promise> { + const now = new Date(); const subscription = { open: false, alert: false, unread: 0, userMentions: 0, groupMentions: 0, - ts: room.ts, + ts: room.ts ?? now, + ls: extraData.ls ?? extraData.lr ?? room.ts ?? now, + lr: extraData.lr ?? extraData.ls ?? room.ts ?? now, + _updatedAt: extraData._updatedAt ?? now, rid: room._id, name: room.name, fname: room.fname, @@ -1885,13 +1889,17 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri room: IRoom, users: { user: AtLeast; extraData: Record }[] = [], ): Promise> { + const now = new Date(); const subscriptions = users.map(({ user, extraData }) => ({ open: false, alert: false, unread: 0, userMentions: 0, groupMentions: 0, - ts: room.ts, + ts: room.ts ?? now, + ls: extraData.ls ?? extraData.lr ?? room.ts ?? now, + lr: extraData.lr ?? extraData.ls ?? room.ts ?? now, + _updatedAt: extraData._updatedAt ?? now, rid: room._id, name: room.name, fname: room.fname, diff --git a/packages/rest-typings/src/v1/subscriptionsEndpoints.ts b/packages/rest-typings/src/v1/subscriptionsEndpoints.ts index ce820565673c7..082499345bec2 100644 --- a/packages/rest-typings/src/v1/subscriptionsEndpoints.ts +++ b/packages/rest-typings/src/v1/subscriptionsEndpoints.ts @@ -4,8 +4,6 @@ import { ajv } from './Ajv'; type SubscriptionsGet = { updatedSince?: string }; -type SubscriptionsGetOne = { roomId: IRoom['_id'] }; - type SubscriptionsRead = { rid: IRoom['_id']; readThreads?: boolean } | { roomId: IRoom['_id']; readThreads?: boolean }; type SubscriptionsUnread = { roomId: IRoom['_id'] } | { firstUnreadMessage: Pick }; @@ -24,19 +22,6 @@ const SubscriptionsGetSchema = { export const isSubscriptionsGetProps = ajv.compile(SubscriptionsGetSchema); -const SubscriptionsGetOneSchema = { - type: 'object', - properties: { - roomId: { - type: 'string', - }, - }, - required: ['roomId'], - additionalProperties: false, -}; - -export const isSubscriptionsGetOneProps = ajv.compile(SubscriptionsGetOneSchema); - const SubscriptionsReadSchema = { anyOf: [ { @@ -114,12 +99,6 @@ export type SubscriptionsEndpoints = { }; }; - '/v1/subscriptions.getOne': { - GET: (params: SubscriptionsGetOne) => { - subscription: ISubscription | null; - }; - }; - '/v1/subscriptions.read': { POST: (params: SubscriptionsRead) => void; };