diff --git a/.changeset/migrate-livechat-openapi-endpoints.md b/.changeset/migrate-livechat-openapi-endpoints.md new file mode 100644 index 0000000000000..f265b71736829 --- /dev/null +++ b/.changeset/migrate-livechat-openapi-endpoints.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/rest-typings": patch +--- + +Migrates livechat/config, livechat/webhook.test, and livechat/integrations.settings API endpoints to the OpenAPI chained route definition pattern with AJV response validation and shared $ref schemas. diff --git a/apps/meteor/app/livechat/imports/server/rest/integrations.ts b/apps/meteor/app/livechat/imports/server/rest/integrations.ts index 5d86202768b8d..9e06964eddf8a 100644 --- a/apps/meteor/app/livechat/imports/server/rest/integrations.ts +++ b/apps/meteor/app/livechat/imports/server/rest/integrations.ts @@ -1,12 +1,48 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { schemas } from '@rocket.chat/core-typings'; +import { ajv, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; + import { API } from '../../../../api/server'; +import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass'; import { findIntegrationSettings } from '../../../server/api/lib/integrations'; -API.v1.addRoute( +// Register ISetting schema for $ref resolution (livechat loads before api/server/ajv.ts) +const iSettingSchema = schemas.components?.schemas?.ISetting; +if (iSettingSchema && !ajv.getSchema('#/components/schemas/ISetting')) { + ajv.addSchema(iSettingSchema, '#/components/schemas/ISetting'); +} + +const livechatIntegrationsEndpoints = API.v1.get( 'livechat/integrations.settings', - { authRequired: true, permissionsRequired: ['view-livechat-manager'] }, { - async get() { - return API.v1.success(await findIntegrationSettings()); + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + query: undefined, + response: { + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile<{ settings: ISetting[]; success: boolean }>({ + type: 'object', + properties: { + settings: { + type: 'array', + items: { $ref: '#/components/schemas/ISetting' }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['settings', 'success'], + additionalProperties: false, + }), }, }, + async function action() { + return API.v1.success(await findIntegrationSettings()); + }, ); + +type LivechatIntegrationsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatIntegrationsEndpoints {} +} diff --git a/apps/meteor/app/livechat/server/api/v1/config.ts b/apps/meteor/app/livechat/server/api/v1/config.ts index 426f7c128882f..f71122fe3fead 100644 --- a/apps/meteor/app/livechat/server/api/v1/config.ts +++ b/apps/meteor/app/livechat/server/api/v1/config.ts @@ -1,20 +1,81 @@ -import { GETLivechatConfigRouting, isGETLivechatConfigParams } from '@rocket.chat/rest-typings'; +import type { ILivechatAgent, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { schemas } from '@rocket.chat/core-typings'; +import { ajv, GETLivechatConfigRouting, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; import mem from 'mem'; import { API } from '../../../../api/server'; import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass'; -import { settings as serverSettings } from '../../../../settings/server'; +import { settings as serverSettings } from '../../../../settings/server/index'; import { RoutingManager } from '../../lib/RoutingManager'; import { online } from '../../lib/service-status'; import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from '../lib/livechat'; +const schemaComponents = schemas.components?.schemas; +(['IOmnichannelRoom', 'ILivechatAgent', 'ILivechatVisitor'] as const).forEach((key) => { + const schema = schemaComponents?.[key]; + if (schema && !ajv.getSchema(`#/components/schemas/${key}`)) { + ajv.addSchema(schema, `#/components/schemas/${key}`); + } +}); + +type GETLivechatConfigParams = { + token?: string; + department?: string; + businessUnit?: string; +}; + +const GETLivechatConfigParamsSchema = { + type: 'object', + properties: { + token: { + type: 'string', + nullable: true, + }, + department: { + type: 'string', + nullable: true, + }, + businessUnit: { + type: 'string', + nullable: true, + }, + }, + additionalProperties: false, +}; + +const isGETLivechatConfigParams = ajv.compile(GETLivechatConfigParamsSchema); + const cachedSettings = mem(settings, { maxAge: process.env.TEST_MODE === 'true' ? 1 : 1000, cacheKey: JSON.stringify }); -API.v1.addRoute( - 'livechat/config', - { validateParams: isGETLivechatConfigParams }, - { - async get() { +const livechatConfigEndpoints = API.v1 + .get( + 'livechat/config', + { + query: isGETLivechatConfigParams, + response: { + 200: ajv.compile<{ + config: Record & { room?: IOmnichannelRoom; agent?: ILivechatAgent; guest?: ILivechatVisitor }; + success: boolean; + }>({ + type: 'object', + properties: { + config: { + type: 'object', + properties: { + room: { $ref: '#/components/schemas/IOmnichannelRoom' }, + agent: { $ref: '#/components/schemas/ILivechatAgent' }, + guest: { $ref: '#/components/schemas/ILivechatVisitor' }, + }, + additionalProperties: true, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['config', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { const enabled = serverSettings.get('Livechat_enabled'); if (!enabled) { @@ -38,27 +99,26 @@ API.v1.addRoute( config: { ...config, online: status, ...extraInfo, ...(guest && { guest }), ...(room && { room }), ...(agent && { agent }) }, }); }, - }, -); - -const livechatConfigEndpoints = API.v1.get( - 'livechat/config/routing', - { - response: { - 200: GETLivechatConfigRouting, + ) + .get( + 'livechat/config/routing', + { + authRequired: true, + response: { + 200: GETLivechatConfigRouting, + 401: validateUnauthorizedErrorResponse, + }, }, - authRequired: true, - }, - async function action() { - const config = RoutingManager.getConfig(); + async function action() { + const config = RoutingManager.getConfig(); - return API.v1.success({ config }); - }, -); + return API.v1.success({ config }); + }, + ); type LivechatConfigEndpoints = ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { - // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface interface Endpoints extends LivechatConfigEndpoints {} } diff --git a/apps/meteor/app/livechat/server/api/v1/webhooks.ts b/apps/meteor/app/livechat/server/api/v1/webhooks.ts index 276a910502d69..c35e1237110a0 100644 --- a/apps/meteor/app/livechat/server/api/v1/webhooks.ts +++ b/apps/meteor/app/livechat/server/api/v1/webhooks.ts @@ -1,95 +1,115 @@ import { Logger } from '@rocket.chat/logger'; +import { ajv, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { API } from '../../../../api/server'; +import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass'; import { settings } from '../../../../settings/server'; const logger = new Logger('WebhookTest'); -API.v1.addRoute( +const livechatWebhookEndpoints = API.v1.post( 'livechat/webhook.test', - { authRequired: true, permissionsRequired: ['view-livechat-webhooks'] }, { - async post() { - const sampleData = { - type: 'LivechatSession', - _id: 'fasd6f5a4sd6f8a4sdf', - label: 'title', - topic: 'asiodojf', - createdAt: new Date(), - lastMessageAt: new Date(), - tags: ['tag1', 'tag2', 'tag3'], + authRequired: true, + permissionsRequired: ['view-livechat-webhooks'], + response: { + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ success: boolean }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const sampleData = { + type: 'LivechatSession', + _id: 'fasd6f5a4sd6f8a4sdf', + label: 'title', + topic: 'asiodojf', + createdAt: new Date(), + lastMessageAt: new Date(), + tags: ['tag1', 'tag2', 'tag3'], + customFields: { + productId: '123456', + }, + visitor: { + _id: '', + name: 'visitor name', + username: 'visitor-username', + department: 'department', + email: 'email@address.com', + phone: '192873192873', + ip: '123.456.7.89', + browser: 'Chrome', + os: 'Linux', customFields: { - productId: '123456', + customerId: '123456', }, - visitor: { - _id: '', - name: 'visitor name', + }, + agent: { + _id: 'asdf89as6df8', + username: 'agent.username', + name: 'Agent Name', + email: 'agent@email.com', + }, + messages: [ + { username: 'visitor-username', - department: 'department', - email: 'email@address.com', - phone: '192873192873', - ip: '123.456.7.89', - browser: 'Chrome', - os: 'Linux', - customFields: { - customerId: '123456', - }, + msg: 'message content', + ts: new Date(), }, - agent: { - _id: 'asdf89as6df8', + { username: 'agent.username', - name: 'Agent Name', - email: 'agent@email.com', + agentId: 'asdf89as6df8', + msg: 'message content from agent', + ts: new Date(), }, - messages: [ - { - username: 'visitor-username', - msg: 'message content', - ts: new Date(), - }, - { - username: 'agent.username', - agentId: 'asdf89as6df8', - msg: 'message content from agent', - ts: new Date(), - }, - ], - }; - const options = { - method: 'POST', - headers: { - 'X-RocketChat-Livechat-Token': settings.get('Livechat_secret_token'), - 'Accept': 'application/json', - }, - body: sampleData, - // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. - ignoreSsrfValidation: true, - size: 10 * 1024 * 1024, - } as ExtendedFetchOptions; - - const webhookUrl = settings.get('Livechat_webhookUrl'); + ], + }; + const options = { + method: 'POST', + headers: { + 'X-RocketChat-Livechat-Token': settings.get('Livechat_secret_token'), + 'Accept': 'application/json', + }, + body: sampleData, + ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, + } as ExtendedFetchOptions; - if (!webhookUrl) { - return API.v1.failure('Webhook_URL_not_set'); - } + const webhookUrl = settings.get('Livechat_webhookUrl'); - try { - logger.debug({ msg: 'Testing webhook', webhookUrl }); - const request = await fetch(webhookUrl, options); - const response = await request.text(); + if (!webhookUrl) { + return API.v1.failure('Webhook_URL_not_set'); + } - logger.debug({ msg: 'Webhook response', response }); - if (request.status === 200) { - return API.v1.success(); - } + try { + logger.debug({ msg: 'Testing webhook', host: new URL(webhookUrl).host }); + const request = await fetch(webhookUrl, options); + await request.text(); - throw new Error('Invalid status code'); - } catch (error) { - logger.error({ msg: 'Error testing webhook', err: error }); - throw new Error('error-invalid-webhook-response'); + logger.debug({ msg: 'Webhook response', status: request.status }); + if (request.status === 200) { + return API.v1.success(); } - }, + + throw new Error('Invalid status code'); + } catch (error) { + logger.error({ msg: 'Error testing webhook', err: error }); + throw new Error('error-invalid-webhook-response', { cause: error }); + } }, ); + +type LivechatWebhookEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type + interface Endpoints extends LivechatWebhookEndpoints {} +} diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index e33f94e99e2ae..41a04f8eabf00 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -8,10 +8,14 @@ import type { ICustomSound } from './ICustomSound'; import type { ICustomUserStatus } from './ICustomUserStatus'; import type { IEmailInbox } from './IEmailInbox'; import type { IInvite } from './IInvite'; +import type { ILivechatAgent } from './ILivechatAgent'; +import type { ILivechatVisitor } from './ILivechatVisitor'; import type { IMessage } from './IMessage'; import type { IModerationAudit, IModerationReport } from './IModerationReport'; import type { IOAuthApps } from './IOAuthApps'; +import type { IOmnichannelRoom } from './IRoom'; import type { IPermission } from './IPermission'; +import type { ISetting } from './ISetting'; import type { IRole } from './IRole'; import type { IRoom, IDirectoryChannelResult } from './IRoom'; import type { ISubscription } from './ISubscription'; @@ -49,10 +53,14 @@ export const schemas = typia.json.schemas< | IModerationAudit | IModerationReport | IBanner + | IOmnichannelRoom + | ILivechatAgent + | ILivechatVisitor ), CallHistoryItem, ICustomUserStatus, SlashCommand, + ISetting, ], '3.0' >(); diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index fe5988d3583c1..7138dec2cb781 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -2285,32 +2285,7 @@ const GETAgentNextTokenSchema = { export const isGETAgentNextToken = ajvQuery.compile(GETAgentNextTokenSchema); -type GETLivechatConfigParams = { - token?: string; - department?: string; - businessUnit?: string; -}; - -const GETLivechatConfigParamsSchema = { - type: 'object', - properties: { - token: { - type: 'string', - nullable: true, - }, - department: { - type: 'string', - nullable: true, - }, - businessUnit: { - type: 'string', - nullable: true, - }, - }, - additionalProperties: false, -}; -export const isGETLivechatConfigParams = ajvQuery.compile(GETLivechatConfigParamsSchema); export const GETLivechatConfigRoutingSchema = { type: 'object', @@ -2847,14 +2822,14 @@ type POSTLivechatRoomCloseByUserParams = { generateTranscriptPdf?: boolean; forceClose?: boolean; transcriptEmail?: - | { - // Note: if sendToVisitor is false, then any previously requested transcripts (like via livechat:requestTranscript) will be also cancelled - sendToVisitor: false; - } - | { - sendToVisitor: true; - requestData: Pick, 'email' | 'subject'>; - }; + | { + // Note: if sendToVisitor is false, then any previously requested transcripts (like via livechat:requestTranscript) will be also cancelled + sendToVisitor: false; + } + | { + sendToVisitor: true; + requestData: Pick, 'email' | 'subject'>; + }; }; const POSTLivechatRoomCloseByUserParamsSchema = { @@ -4359,8 +4334,8 @@ export const isLivechatTriggerWebhookCallParams = ajv.compile { agent: ILivechatAgent | { hiddenInfo: true } } | void; }; + '/v1/livechat/config': { - GET: (params: GETLivechatConfigParams) => { - config: { [k: string]: string | boolean } & { room?: IOmnichannelRoom; agent?: ILivechatAgent }; + GET: (params: { token?: string; department?: string; businessUnit?: string }) => { + config: Record & { room?: IOmnichannelRoom; agent?: ILivechatAgent; guest?: ILivechatVisitor }; }; }; '/v1/livechat/custom.field': { @@ -4995,9 +4971,7 @@ export type OmnichannelEndpoints = { }[]; }>; }; - '/v1/livechat/integrations.settings': { - GET: () => { settings: ISetting[]; success: boolean }; - }; + '/v1/livechat/upload/:rid': { POST: (params: { file: File }) => IMessage & { newRoom: boolean; showConnecting: boolean }; }; @@ -5199,7 +5173,5 @@ export type OmnichannelEndpoints = { '/v1/livechat/analytics/dashboards/conversations-by-agent': { GET: (params: GETDashboardConversationsByType) => ReportWithUnmatchingElements; }; - '/v1/livechat/webhook.test': { - POST: () => void; - }; + };