diff --git a/.changeset/light-donkeys-poke.md b/.changeset/light-donkeys-poke.md new file mode 100644 index 00000000..2f065349 --- /dev/null +++ b/.changeset/light-donkeys-poke.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': minor +--- + +Concat zod errors into a single message field diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index fa924f38..2503ffee 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -1,7 +1,6 @@ import express from 'express'; import _ from 'lodash'; import { z } from 'zod'; -import { validateRequest } from 'zod-express-middleware'; import { createAlert, @@ -10,6 +9,7 @@ import { getAlerts, updateAlert, } from '@/controllers/alerts'; +import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors'; import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi'; import { alertSchema, objectIdSchema } from '@/utils/zod'; diff --git a/packages/api/src/routers/external-api/v2/charts.ts b/packages/api/src/routers/external-api/v2/charts.ts index 80fd19d9..419bfec3 100644 --- a/packages/api/src/routers/external-api/v2/charts.ts +++ b/packages/api/src/routers/external-api/v2/charts.ts @@ -10,13 +10,13 @@ import opentelemetry, { SpanStatusCode } from '@opentelemetry/api'; import express from 'express'; import _ from 'lodash'; import { z } from 'zod'; -import { validateRequest } from 'zod-express-middleware'; import { getConnectionById } from '@/controllers/connection'; import { getSource } from '@/controllers/sources'; import { getTeam } from '@/controllers/team'; import { IConnection } from '@/models/connection'; import { ISource } from '@/models/source'; +import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors'; import { translateExternalSeriesToInternalSeries } from '@/utils/externalApi'; import { externalQueryChartSeriesSchema, diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 264a3233..b3ca3621 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -3,7 +3,6 @@ import express from 'express'; import { uniq } from 'lodash'; import { ObjectId } from 'mongodb'; import { z } from 'zod'; -import { validateRequest } from 'zod-express-middleware'; import { deleteDashboard, @@ -11,6 +10,7 @@ import { updateDashboard, } from '@/controllers/dashboard'; import Dashboard, { IDashboard } from '@/models/dashboard'; +import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors'; import { translateDashboardDocumentToExternalDashboard, translateExternalChartToInternalChart, diff --git a/packages/api/src/utils/__tests__/enhancedErrors.test.ts b/packages/api/src/utils/__tests__/enhancedErrors.test.ts new file mode 100644 index 00000000..c21b856f --- /dev/null +++ b/packages/api/src/utils/__tests__/enhancedErrors.test.ts @@ -0,0 +1,173 @@ +import express, { Express } from 'express'; +import request from 'supertest'; +import { z } from 'zod'; + +import { validateRequestWithEnhancedErrors as validateRequest } from '../enhancedErrors'; +import { + alertSchema, + externalChartSchema, + externalQueryChartSeriesSchema, + objectIdSchema, + tagsSchema, +} from '../zod'; + +describe('enhancedErrors', () => { + let app: Express; + + beforeEach(() => { + app = express(); + app.use(express.json()); + }); + + describe('validateRequestWithEnhancedErrors', () => { + it('should report validation errors for dashboard with invalid chart', async () => { + app.post( + '/dashboards', + validateRequest({ + body: z.object({ + name: z.string().max(1024), + tiles: z.array(externalChartSchema), + tags: tagsSchema, + }), + }), + (_, res) => res.json({ success: true }), + ); + + const response = await request(app) + .post('/dashboards') + .send({ + name: 'Test Dashboard', + tiles: [ + { + name: 'Invalid Chart', + x: 0, + y: 0, + w: 'not-a-number', // Invalid: should be number + h: 3, + series: [], + }, + ], + tags: ['test'], + }); + + expect(response.status).toBe(400); + expect(response.body.message).toEqual( + 'Body validation failed: tiles.0.w: Expected number, received string', + ); + }); + + it('should validate chart series query with chart series schema', async () => { + const millisecondTimestampSchema = z + .number() + .int({ message: 'Timestamp must be an integer' }) + .positive({ message: 'Timestamp must be positive' }) + .refine(val => val.toString().length >= 13, { + message: 'Timestamp must be in milliseconds', + }); + + app.post( + '/charts/series', + validateRequest({ + body: z.object({ + series: z.array(externalQueryChartSeriesSchema).min(1).max(5), + startTime: millisecondTimestampSchema, + endTime: millisecondTimestampSchema, + granularity: z.enum(['30s', '1m', '5m', '1h']).optional(), + }), + }), + (_, res) => res.json({ success: true }), + ); + + const response = await request(app) + .post('/charts/series') + .send({ + series: [ + { + sourceId: '507f1f77bcf86cd799439011', + aggFn: 'count', + where: 'level:error', + groupBy: [], + }, + ], + startTime: 1647014400000, + endTime: 1647100800000, + granularity: '1h', + }); + + expect(response.status).toBe(200); + }); + + it('should report validation errors for invalid timestamps in chart query', async () => { + const millisecondTimestampSchema = z + .number() + .int({ message: 'Timestamp must be an integer' }) + .positive({ message: 'Timestamp must be positive' }) + .refine(val => val.toString().length >= 13, { + message: 'Timestamp must be in milliseconds', + }); + + app.post( + '/charts/series', + validateRequest({ + body: z.object({ + series: z.array(externalQueryChartSeriesSchema).min(1).max(5), + startTime: millisecondTimestampSchema, + endTime: millisecondTimestampSchema, + }), + }), + (_, res) => res.json({ success: true }), + ); + + const response = await request(app) + .post('/charts/series') + .send({ + series: [ + { + sourceId: '507f1f77bcf86cd799439011', + aggFn: 'count', + where: 'level:error', + groupBy: [], + }, + ], + startTime: 1647014, // Too short - not in milliseconds + endTime: 1647100800000, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toEqual( + 'Body validation failed: startTime: Timestamp must be in milliseconds', + ); + }); + + it('should report validation errors for invalid alert configuration', async () => { + app.put( + '/alerts/:id', + validateRequest({ + params: z.object({ id: objectIdSchema }), + body: alertSchema, + }), + (_, res) => res.json({ success: true }), + ); + + const response = await request(app) + .put('/alerts/not-a-valid-id') + .send({ + source: 'tile', + tileId: '507f1f77bcf86cd799439011', + dashboardId: '507f1f77bcf86cd799439011', + threshold: -5, // Invalid: negative threshold + interval: '99m', // Invalid: not a valid interval + thresholdType: 'above', + channel: { + type: 'webhook', + webhookId: '507f1f77bcf86cd799439011', + }, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toEqual( + "Body validation failed: interval: Invalid enum value. Expected '1m' | '5m' | '15m' | '30m' | '1h' | '6h' | '12h' | '1d', received '99m'; threshold: Number must be greater than or equal to 0; Params validation failed: id: Invalid input", + ); + }); + }); +}); diff --git a/packages/api/src/utils/enhancedErrors.ts b/packages/api/src/utils/enhancedErrors.ts new file mode 100644 index 00000000..c1ba9ca1 --- /dev/null +++ b/packages/api/src/utils/enhancedErrors.ts @@ -0,0 +1,63 @@ +import type { NextFunction, Request, Response } from 'express'; +import { z, ZodError } from 'zod'; + +/** + * Formats a Zod error into a single concatenated error message + * @param error - The Zod error to format + * @returns A single string with all validation errors + */ +function formatZodError(error: ZodError): string { + return error.issues + .map(issue => { + const path = issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''; + return `${path}${issue.message}`; + }) + .join('; '); +} + +/** + * Custom validation middleware that validates multiple parts of the request + * and sends concatenated error message + * @param schemas - Object containing schemas for body, params, and/or query + * @returns Express middleware function + */ +export function validateRequestWithEnhancedErrors(schemas: { + body?: z.ZodSchema; + params?: z.ZodSchema; + query?: z.ZodSchema; +}) { + return (req: Request, res: Response, next: NextFunction) => { + const errors: string[] = []; + + if (schemas.body) { + const result = schemas.body.safeParse(req.body); + if (!result.success) { + errors.push(`Body validation failed: ${formatZodError(result.error)}`); + } + } + + if (schemas.params) { + const result = schemas.params.safeParse(req.params); + if (!result.success) { + errors.push( + `Params validation failed: ${formatZodError(result.error)}`, + ); + } + } + + if (schemas.query) { + const result = schemas.query.safeParse(req.query); + if (!result.success) { + errors.push(`Query validation failed: ${formatZodError(result.error)}`); + } + } + + if (errors.length > 0) { + return res.status(400).json({ + message: errors.join('; '), + }); + } + + next(); + }; +}