mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat(externalApi): concat zod errors into a single message field (#1673)
For the clickstack api integration work, we need to make sure if there's a 400 bad request on hyperdx side, the error is nicely presented in a single message field, like it is for other errors like 401 and 500. This will make our clickstack openapi show better errors instead of showing "unknown error" because the clickstack proxy in control plane doesn't have the functionality to decipher and format zod errors nicely. |Before|After| |--|--| |<img width="836" height="886" alt="CleanShot 2026-01-29 at 12 15 26@2x" src="https://github.com/user-attachments/assets/36e16371-1a1f-48de-88ac-e7c81ef238f0" />|<img width="1136" height="430" alt="CleanShot 2026-01-29 at 12 15 58@2x" src="https://github.com/user-attachments/assets/d3b70723-9049-4d32-8795-2ca4365ccf03" />| This will now be similar to other error responses in our v2 external API: <img width="938" height="676" alt="CleanShot 2026-01-29 at 12 16 33@2x" src="https://github.com/user-attachments/assets/50e9271e-62d8-44e6-b887-fae5dffc4f24" /> Fixes HDX-3309
This commit is contained in:
parent
f8685bfa9d
commit
3aa8be0ae7
6 changed files with 244 additions and 3 deletions
5
.changeset/light-donkeys-poke.md
Normal file
5
.changeset/light-donkeys-poke.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hyperdx/api': minor
|
||||
---
|
||||
|
||||
Concat zod errors into a single message field
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
173
packages/api/src/utils/__tests__/enhancedErrors.test.ts
Normal file
173
packages/api/src/utils/__tests__/enhancedErrors.test.ts
Normal file
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
packages/api/src/utils/enhancedErrors.ts
Normal file
63
packages/api/src/utils/enhancedErrors.ts
Normal file
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue