From 15cdd41f1ae773faceb2b602e210ffdb0d7220a5 Mon Sep 17 00:00:00 2001 From: smirk Date: Sat, 21 Feb 2026 17:31:18 +0000 Subject: [PATCH 1/2] refactor: migrate statistics endpoints to new chained API pattern Migrates statistics (GET), statistics.list (GET), and statistics.telemetry (POST) from the legacy addRoute() pattern to the new chained .get()/.post() API pattern with typed AJV response schemas and query parameter validation using existing isStatisticsProps and isStatisticsListProps validators. Part of #38876 --- .../refactor-stats-api-chained-pattern.md | 5 + apps/meteor/app/api/server/v1/stats.ts | 172 ++++++++++++++---- 2 files changed, 139 insertions(+), 38 deletions(-) create mode 100644 .changeset/refactor-stats-api-chained-pattern.md diff --git a/.changeset/refactor-stats-api-chained-pattern.md b/.changeset/refactor-stats-api-chained-pattern.md new file mode 100644 index 0000000000000..c55c8437012bc --- /dev/null +++ b/.changeset/refactor-stats-api-chained-pattern.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrated `statistics`, `statistics.list`, and `statistics.telemetry` REST API endpoints from legacy `addRoute` pattern to the new chained `.get()`/`.post()` API pattern with typed response schemas and AJV query parameter validation. diff --git a/apps/meteor/app/api/server/v1/stats.ts b/apps/meteor/app/api/server/v1/stats.ts index 27cea2c310574..f23aeca002a7c 100644 --- a/apps/meteor/app/api/server/v1/stats.ts +++ b/apps/meteor/app/api/server/v1/stats.ts @@ -1,62 +1,158 @@ +import type { TelemetryEvents, TelemetryMap } from '@rocket.chat/core-services'; +import type { IStats } from '@rocket.chat/core-typings'; +import { ajv, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; + import { getStatistics, getLastStatistics } from '../../../statistics/server'; import telemetryEvent from '../../../statistics/server/lib/telemetryEvents'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( +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: ajv.compile<{ refresh?: 'true' | 'false' }>({ + type: 'object', + properties: { + refresh: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + }), + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { refresh = 'false' } = this.queryParams; + + const stats = await getLastStatistics({ + userId: this.userId, + refresh: refresh === 'true', + }); + + if (!stats) { + throw new Error('No statistics found'); + } + + return API.v1.success(stats); + }, ); -API.v1.addRoute( +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: ajv.compile<{ fields?: string; count?: number; offset?: number; sort?: string; query?: string }>({ + type: 'object', + properties: { + fields: { type: 'string', nullable: true }, + count: { type: 'number', nullable: true }, + offset: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + }, + required: [], + additionalProperties: false, + }), + response: { + 200: ajv.compile<{ + statistics: unknown[]; + count: number; + offset: number; + total: number; + }>({ + type: 'object', + properties: { + statistics: { type: 'array' }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { + type: 'boolean', + enum: [true], }, - }), - ); + }, + required: ['statistics', 'count', 'offset', 'total', 'success'], + }), + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + 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, + }, + }), + ); + }, ); -API.v1.addRoute( +API.v1.post( 'statistics.telemetry', - { authRequired: true }, { - post() { - const events = this.bodyParams; + authRequired: true, + body: ajv.compile<{ params: { eventName: string; [key: string]: unknown }[] }>({ + type: 'object', + properties: { + params: { + type: 'array', + items: { + type: 'object', + properties: { + eventName: { type: 'string' }, + }, + required: ['eventName'], + }, + }, + }, + required: ['params'], + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + 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) => { + const { eventName, ...params } = event; + void telemetryEvent.call(eventName as TelemetryEvents, params as TelemetryMap[TelemetryEvents]); + }); - return API.v1.success(); - }, + return API.v1.success(); }, ); From cb07e5422619d09c4159cde57d39d9eb4a6942bc Mon Sep 17 00:00:00 2001 From: smirk Date: Sun, 22 Feb 2026 06:56:09 +0000 Subject: [PATCH 2/2] fix: add 400 response validator to prevent 500 in TEST_MODE When _internalRouteActionHandler catches errors and returns status 400, the Router's response validation in TEST_MODE requires a matching response validator. Without a 400 validator, it throws 'Missing response validator' which becomes HTTP 500. Also remove unnecessary null check that wasn't in original code. --- apps/meteor/app/api/server/v1/stats.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/meteor/app/api/server/v1/stats.ts b/apps/meteor/app/api/server/v1/stats.ts index f23aeca002a7c..4721e2c345d9d 100644 --- a/apps/meteor/app/api/server/v1/stats.ts +++ b/apps/meteor/app/api/server/v1/stats.ts @@ -1,6 +1,6 @@ import type { TelemetryEvents, TelemetryMap } from '@rocket.chat/core-services'; import type { IStats } from '@rocket.chat/core-typings'; -import { ajv, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; +import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; import { getStatistics, getLastStatistics } from '../../../statistics/server'; import telemetryEvent from '../../../statistics/server/lib/telemetryEvents'; @@ -33,22 +33,19 @@ API.v1.get( }, required: ['success'], }), + 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { refresh = 'false' } = this.queryParams; - const stats = await getLastStatistics({ - userId: this.userId, - refresh: refresh === 'true', - }); - - if (!stats) { - throw new Error('No statistics found'); - } - - return API.v1.success(stats); + return API.v1.success( + await getLastStatistics({ + userId: this.userId, + refresh: refresh === 'true', + }), + ); }, ); @@ -88,6 +85,7 @@ API.v1.get( }, required: ['statistics', 'count', 'offset', 'total', 'success'], }), + 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, @@ -142,6 +140,7 @@ API.v1.post( required: ['success'], additionalProperties: false, }), + 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, },