From dcd514313805687d0fe6caefec97e72b07fe479a Mon Sep 17 00:00:00 2001 From: smirk Date: Fri, 27 Feb 2026 16:37:47 +0000 Subject: [PATCH] refactor(api): migrate statistics endpoints to chained API pattern Migrates all 3 statistics API endpoints from the legacy API.v1.addRoute() pattern to the new chained API.v1.get()/API.v1.post() pattern: - GET statistics: with query validation (isStatisticsProps) and IStats response schema - GET statistics.list: with query validation (isStatisticsListProps) and paginated response schema - POST statistics.telemetry: with body validation (TelemetryPayload) and empty success response schema Changes: - apps/meteor/app/api/server/v1/stats.ts: Full endpoint migration with OpenAPI-compatible AJV response schemas, query/body validators, and declare module augmentation for type-safe route registration - packages/rest-typings/src/index.ts: Removed StatisticsEndpoints from Endpoints interface (now provided via module augmentation), added barrel re-export for statistics validators - Added 401/403 error response validators to all endpoints This is part of the ongoing REST API migration effort toward full OpenAPI spec compliance. --- ...refactor-statistics-api-chained-pattern.md | 5 + apps/meteor/app/api/server/v1/stats.ts | 200 ++++++++++++++---- packages/rest-typings/src/index.ts | 3 +- 3 files changed, 168 insertions(+), 40 deletions(-) create mode 100644 .changeset/refactor-statistics-api-chained-pattern.md diff --git a/.changeset/refactor-statistics-api-chained-pattern.md b/.changeset/refactor-statistics-api-chained-pattern.md new file mode 100644 index 0000000000000..cd1e11b1cc4db --- /dev/null +++ b/.changeset/refactor-statistics-api-chained-pattern.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrated statistics API endpoints (`statistics`, `statistics.list`, `statistics.telemetry`) from legacy `API.v1.addRoute` to the new chained `API.v1.get()`/`API.v1.post()` pattern with OpenAPI-compatible AJV response schemas, query/body validation, and `declare module` augmentation for type-safe route registration. diff --git a/apps/meteor/app/api/server/v1/stats.ts b/apps/meteor/app/api/server/v1/stats.ts index 27cea2c310574..da0437f2d1948 100644 --- a/apps/meteor/app/api/server/v1/stats.ts +++ b/apps/meteor/app/api/server/v1/stats.ts @@ -1,62 +1,186 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import { + ajv, + isStatisticsProps, + isStatisticsListProps, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; + import { getStatistics, getLastStatistics } from '../../../statistics/server'; import telemetryEvent from '../../../statistics/server/lib/telemetryEvents'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( +const statisticsEndpoints = API.v1.get( 'statistics', - { authRequired: true }, { - async get() { - const { refresh = 'false' } = this.queryParams; - - return API.v1.success( - await getLastStatistics({ - userId: this.userId, - refresh: refresh === 'true', - }), - ); + authRequired: true, + query: isStatisticsProps, + response: { + 200: ajv.compile({ + type: 'object', + additionalProperties: true, + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { refresh = 'false' } = this.queryParams; + + return API.v1.success( + await getLastStatistics({ + userId: this.userId, + refresh: refresh === 'true', + }), + ); + }, ); -API.v1.addRoute( +const statisticsListEndpoints = API.v1.get( 'statistics.list', - { authRequired: true }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - return API.v1.success( - await getStatistics({ - userId: this.userId, - query, - pagination: { - offset, - count, - sort, - fields, + authRequired: true, + query: isStatisticsListProps, + response: { + 200: ajv.compile<{ + statistics: IStats[]; + count: number; + offset: number; + total: number; + }>({ + type: 'object', + additionalProperties: false, + properties: { + statistics: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + }, + count: { + type: 'number', + description: 'The number of statistics items returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of statistics items that were skipped in this response.', }, - }), - ); + total: { + type: 'number', + description: 'The total number of statistics items that match the query.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['statistics', 'count', 'offset', 'total', 'success'], + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort, fields, query } = await this.parseJsonQuery(); + + return API.v1.success( + await getStatistics({ + userId: this.userId, + query, + pagination: { + offset, + count, + sort, + fields, + }, + }), + ); + }, ); -API.v1.addRoute( +type TelemetryParam = { + eventName: string; + timestamp?: number; + [key: string]: unknown; +}; + +type TelemetryPayload = { + params: TelemetryParam[]; +}; + +const isTelemetryPayload = ajv.compile({ + type: 'object', + properties: { + params: { + type: 'array', + items: { + type: 'object', + properties: { + eventName: { type: 'string' }, + timestamp: { type: 'number', nullable: true }, + }, + required: ['eventName'], + additionalProperties: true, + }, + }, + }, + required: ['params'], + additionalProperties: false, +}); + +const statisticsTelemetryEndpoints = API.v1.post( 'statistics.telemetry', - { authRequired: true }, { - post() { - const events = this.bodyParams; + authRequired: true, + validateParams: isTelemetryPayload, + body: isTelemetryPayload, + response: { + 200: ajv.compile>({ + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + function action() { + const events = this.bodyParams; - events?.params?.forEach((event) => { - const { eventName, ...params } = event; - void telemetryEvent.call(eventName, params); - }); + events?.params?.forEach((event: TelemetryParam) => { + const { eventName, ...params } = event; + void telemetryEvent.call(eventName as Parameters[0], params as Parameters[1]); + }); - return API.v1.success(); - }, + return API.v1.success(); }, ); + +type StatisticsGetEndpoints = ExtractRoutesFromAPI; + +type StatisticsListGetEndpoints = ExtractRoutesFromAPI; + +type StatisticsTelemetryPostEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends StatisticsGetEndpoints {} + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends StatisticsListGetEndpoints {} + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends StatisticsTelemetryPostEndpoints {} +} diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index b0e2dacff7a85..2d5126495e900 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -38,7 +38,6 @@ import type { RolesEndpoints } from './v1/roles'; import type { RoomsEndpoints } from './v1/rooms'; import type { ServerEventsEndpoints } from './v1/server-events'; import type { SettingsEndpoints } from './v1/settings'; -import type { StatisticsEndpoints } from './v1/statistics'; import type { SubscriptionsEndpoints } from './v1/subscriptionsEndpoints'; import type { TeamsEndpoints } from './v1/teams'; import type { UsersEndpoints } from './v1/users'; @@ -69,7 +68,6 @@ export interface Endpoints UsersEndpoints, AppsEndpoints, OmnichannelEndpoints, - StatisticsEndpoints, LicensesEndpoints, MiscEndpoints, PresenceEndpoints, @@ -255,6 +253,7 @@ export * from './v1/chat'; export * from './v1/auth'; export * from './v1/cloud'; export * from './v1/banners'; +export * from './v1/statistics'; export * from './default'; // Export the ajv instance for use in other packages