diff --git a/.changeset/heavy-spies-move.md b/.changeset/heavy-spies-move.md new file mode 100644 index 00000000..bd651a88 --- /dev/null +++ b/.changeset/heavy-spies-move.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": patch +--- + +fix: Fix inaccurate openapi docs for external alerts API diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 3555b426..e636fecd 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -47,10 +47,12 @@ }, "name": { "type": "string", + "nullable": true, "example": "High Error Rate" }, "message": { "type": "string", + "nullable": true, "example": "Error rate exceeds threshold" }, "threshold": { @@ -59,6 +61,16 @@ }, "interval": { "type": "string", + "enum": [ + "1m", + "5m", + "15m", + "30m", + "1h", + "6h", + "12h", + "1d" + ], "example": "15m" }, "thresholdType": { @@ -73,19 +85,28 @@ "type": "string", "enum": [ "tile", - "search" + "saved_search" ], "example": "tile" }, "state": { "type": "string", - "example": "inactive" + "enum": [ + "OK", + "ALERT", + "INSUFFICIENT_DATA", + "DISABLED" + ], + "example": "ALERT" }, "channel": { "type": "object", "properties": { "type": { "type": "string", + "enum": [ + "webhook" + ], "example": "webhook" }, "webhookId": { @@ -100,10 +121,12 @@ }, "tileId": { "type": "string", + "nullable": true, "example": "65f5e4a3b9e77c001a901234" }, "dashboard": { "type": "string", + "nullable": true, "example": "65f5e4a3b9e77c001a567890" }, "savedSearch": { @@ -115,16 +138,32 @@ "nullable": true }, "silenced": { - "type": "boolean", - "nullable": true + "type": "object", + "nullable": true, + "properties": { + "by": { + "type": "string", + "nullable": true + }, + "at": { + "type": "string", + "format": "date-time" + }, + "until": { + "type": "string", + "format": "date-time" + } + } }, "createdAt": { "type": "string", + "nullable": true, "format": "date-time", "example": "2023-01-01T00:00:00.000Z" }, "updatedAt": { "type": "string", + "nullable": true, "format": "date-time", "example": "2023-01-01T00:00:00.000Z" } @@ -142,25 +181,47 @@ "properties": { "dashboardId": { "type": "string", + "nullable": true, "example": "65f5e4a3b9e77c001a567890" }, "tileId": { "type": "string", + "nullable": true, "example": "65f5e4a3b9e77c001a901234" }, + "savedSearchId": { + "type": "string", + "nullable": true, + "example": "65f5e4a3b9e77c001a345678" + }, + "groupBy": { + "type": "string", + "nullable": true, + "example": "ServiceName" + }, "threshold": { "type": "number", "example": 100 }, "interval": { "type": "string", + "enum": [ + "1m", + "5m", + "15m", + "30m", + "1h", + "6h", + "12h", + "1d" + ], "example": "1h" }, "source": { "type": "string", "enum": [ "tile", - "search" + "saved_search" ], "example": "tile" }, @@ -177,6 +238,9 @@ "properties": { "type": { "type": "string", + "enum": [ + "webhook" + ], "example": "webhook" }, "webhookId": { @@ -187,10 +251,12 @@ }, "name": { "type": "string", + "nullable": true, "example": "Test Alert" }, "message": { "type": "string", + "nullable": true, "example": "Test Alert Message" } } @@ -198,14 +264,52 @@ "UpdateAlertRequest": { "type": "object", "properties": { + "dashboardId": { + "type": "string", + "nullable": true, + "example": "65f5e4a3b9e77c001a567890" + }, + "tileId": { + "type": "string", + "nullable": true, + "example": "65f5e4a3b9e77c001a901234" + }, + "savedSearchId": { + "type": "string", + "nullable": true, + "example": "65f5e4a3b9e77c001a345678" + }, + "groupBy": { + "type": "string", + "nullable": true, + "example": "ServiceName" + }, "threshold": { "type": "number", - "example": 500 + "example": 100 }, "interval": { "type": "string", + "enum": [ + "1m", + "5m", + "15m", + "30m", + "1h", + "6h", + "12h", + "1d" + ], "example": "1h" }, + "source": { + "type": "string", + "enum": [ + "tile", + "saved_search" + ], + "example": "tile" + }, "thresholdType": { "type": "string", "enum": [ @@ -214,27 +318,14 @@ ], "example": "above" }, - "source": { - "type": "string", - "enum": [ - "tile", - "search" - ], - "example": "tile" - }, - "dashboardId": { - "type": "string", - "example": "65f5e4a3b9e77c001a567890" - }, - "tileId": { - "type": "string", - "example": "65f5e4a3b9e77c001a901234" - }, "channel": { "type": "object", "properties": { "type": { "type": "string", + "enum": [ + "webhook" + ], "example": "webhook" }, "webhookId": { @@ -245,11 +336,13 @@ }, "name": { "type": "string", - "example": "Updated Alert Name" + "nullable": true, + "example": "Test Alert" }, "message": { "type": "string", - "example": "Updated message" + "nullable": true, + "example": "Test Alert Message" } } }, @@ -655,13 +748,11 @@ "value": { "data": { "id": "65f5e4a3b9e77c001a123456", - "name": "CPU Usage Alert", - "message": "CPU usage is above 80%", "threshold": 80, "interval": "5m", "thresholdType": "above", "source": "tile", - "state": "active", + "state": "ALERT", "channel": { "type": "webhook", "webhookId": "65f5e4a3b9e77c001a789012" @@ -868,13 +959,11 @@ "data": [ { "id": "65f5e4a3b9e77c001a123456", - "name": "High Error Rate", - "message": "Error rate exceeds threshold", "threshold": 100, "interval": "15m", "thresholdType": "above", "source": "tile", - "state": "inactive", + "state": "OK", "channel": { "type": "webhook", "webhookId": "65f5e4a3b9e77c001a789012" diff --git a/packages/api/src/routers/external-api/__tests__/alerts.test.ts b/packages/api/src/routers/external-api/__tests__/alerts.test.ts index 0ce3e963..648c5dd6 100644 --- a/packages/api/src/routers/external-api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/external-api/__tests__/alerts.test.ts @@ -242,10 +242,10 @@ describe('External API Alerts', () => { message: 'This should fail validation', }; - // API returns 500 for validation errors + // API returns 400 for validation errors const response = await authRequest('post', ALERTS_BASE_URL) .send(invalidInput) - .expect(500); + .expect(400); expect(response.body).toHaveProperty('message'); diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index 2503ffee..6be35ec0 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -30,15 +30,18 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; * example: "65f5e4a3b9e77c001a123456" * name: * type: string + * nullable: true * example: "High Error Rate" * message: * type: string + * nullable: true * example: "Error rate exceeds threshold" * threshold: * type: number * example: 100 * interval: * type: string + * enum: [1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d] * example: "15m" * thresholdType: * type: string @@ -46,16 +49,18 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; * example: "above" * source: * type: string - * enum: [tile, search] + * enum: [tile, saved_search] * example: "tile" * state: * type: string - * example: "inactive" + * enum: [OK, ALERT, INSUFFICIENT_DATA, DISABLED] + * example: "ALERT" * channel: * type: object * properties: * type: * type: string + * enum: [webhook] * example: "webhook" * webhookId: * type: string @@ -65,9 +70,11 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; * example: "65f5e4a3b9e77c001a345678" * tileId: * type: string + * nullable: true * example: "65f5e4a3b9e77c001a901234" * dashboard: * type: string + * nullable: true * example: "65f5e4a3b9e77c001a567890" * savedSearch: * type: string @@ -76,14 +83,26 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; * type: string * nullable: true * silenced: - * type: boolean + * type: object * nullable: true + * properties: + * by: + * type: string + * nullable: true + * at: + * type: string + * format: date-time + * until: + * type: string + * format: date-time * createdAt: * type: string + * nullable: true * format: date-time * example: "2023-01-01T00:00:00.000Z" * updatedAt: * type: string + * nullable: true * format: date-time * example: "2023-01-01T00:00:00.000Z" * @@ -98,19 +117,30 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; * properties: * dashboardId: * type: string + * nullable: true * example: "65f5e4a3b9e77c001a567890" * tileId: * type: string + * nullable: true * example: "65f5e4a3b9e77c001a901234" + * savedSearchId: + * type: string + * nullable: true + * example: "65f5e4a3b9e77c001a345678" + * groupBy: + * type: string + * nullable: true + * example: "ServiceName" * threshold: * type: number * example: 100 * interval: * type: string + * enum: [1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d] * example: "1h" * source: * type: string - * enum: [tile, search] + * enum: [tile, saved_search] * example: "tile" * thresholdType: * type: string @@ -121,55 +151,72 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; * properties: * type: * type: string + * enum: [webhook] * example: "webhook" * webhookId: * type: string * example: "65f5e4a3b9e77c001a789012" * name: * type: string + * nullable: true * example: "Test Alert" * message: * type: string + * nullable: true * example: "Test Alert Message" * * UpdateAlertRequest: * type: object * properties: + * dashboardId: + * type: string + * nullable: true + * example: "65f5e4a3b9e77c001a567890" + * tileId: + * type: string + * nullable: true + * example: "65f5e4a3b9e77c001a901234" + * savedSearchId: + * type: string + * nullable: true + * example: "65f5e4a3b9e77c001a345678" + * groupBy: + * type: string + * nullable: true + * example: "ServiceName" * threshold: * type: number - * example: 500 + * example: 100 * interval: * type: string + * enum: [1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d] * example: "1h" + * source: + * type: string + * enum: [tile, saved_search] + * example: "tile" * thresholdType: * type: string * enum: [above, below] * example: "above" - * source: - * type: string - * enum: [tile, search] - * example: "tile" - * dashboardId: - * type: string - * example: "65f5e4a3b9e77c001a567890" - * tileId: - * type: string - * example: "65f5e4a3b9e77c001a901234" * channel: * type: object * properties: * type: * type: string + * enum: [webhook] * example: "webhook" * webhookId: * type: string * example: "65f5e4a3b9e77c001a789012" * name: * type: string - * example: "Updated Alert Name" + * nullable: true + * example: "Test Alert" * message: * type: string - * example: "Updated message" + * nullable: true + * example: "Test Alert Message" * * AlertResponse: * type: object @@ -191,6 +238,7 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; */ const router = express.Router(); + /** * @openapi * /api/v2/alerts/{id}: @@ -220,13 +268,11 @@ const router = express.Router(); * value: * data: * id: "65f5e4a3b9e77c001a123456" - * name: "CPU Usage Alert" - * message: "CPU usage is above 80%" * threshold: 80 * interval: "5m" * thresholdType: "above" * source: "tile" - * state: "active" + * state: "ALERT" * channel: * type: "webhook" * webhookId: "65f5e4a3b9e77c001a789012" @@ -298,13 +344,11 @@ router.get( * value: * data: * - id: "65f5e4a3b9e77c001a123456" - * name: "High Error Rate" - * message: "Error rate exceeds threshold" * threshold: 100 * interval: "15m" * thresholdType: "above" * source: "tile" - * state: "inactive" + * state: "OK" * channel: * type: "webhook" * webhookId: "65f5e4a3b9e77c001a789012" @@ -388,23 +432,29 @@ router.get('/', async (req, res, next) => { * schema: * $ref: '#/components/schemas/Error' */ -router.post('/', async (req, res, next) => { - const teamId = req.user?.team; - const userId = req.user?._id; - if (teamId == null || userId == null) { - return res.sendStatus(403); - } - try { - const alertInput = req.body; - const createdAlert = await createAlert(teamId, alertInput, userId); +router.post( + '/', + validateRequest({ + body: alertSchema, + }), + async (req, res, next) => { + const teamId = req.user?.team; + const userId = req.user?._id; + if (teamId == null || userId == null) { + return res.sendStatus(403); + } + try { + const alertInput = req.body; + const createdAlert = await createAlert(teamId, alertInput, userId); - return res.json({ - data: translateAlertDocumentToExternalAlert(createdAlert), - }); - } catch (e) { - next(e); - } -}); + return res.json({ + data: translateAlertDocumentToExternalAlert(createdAlert), + }); + } catch (e) { + next(e); + } + }, +); /** * @openapi diff --git a/packages/api/src/utils/externalApi.ts b/packages/api/src/utils/externalApi.ts index 081f5aae..09f644cc 100644 --- a/packages/api/src/utils/externalApi.ts +++ b/packages/api/src/utils/externalApi.ts @@ -1,4 +1,5 @@ // @ts-nocheck TODO: Fix When Restoring Alerts +import { FlattenMaps, LeanDocument } from 'mongoose'; import { z } from 'zod'; import { AlertDocument } from '@/models/alert'; @@ -151,48 +152,6 @@ export const translateExternalChartToInternalChart = ( }; }; -const translateChartDocumentToExternalChart = ( - chart: z.infer, -): z.infer => { - const { id, x, name, y, w, h, series, seriesReturnType } = chart; - return { - id, - name, - x, - y, - w, - h, - asRatio: seriesReturnType === 'ratio', - series: series.map(s => { - const { - type, - table, - aggFn, - level, - field, - where, - groupBy, - sortOrder, - content, - numberFormat, - } = s; - - return { - type, - dataSource: table === 'metrics' ? 'metrics' : 'events', - aggFn, - level, - field, - where, - groupBy, - sortOrder, - content, - numberFormat, - }; - }), - }; -}; - export type ExternalDashboard = { id: string; name: string; @@ -220,12 +179,12 @@ export function translateDashboardDocumentToExternalDashboard( // Alert related types and transformations export type ExternalAlert = { id: string; - name: string | null; - message: string | null; + name?: string | null; + message?: string | null; threshold: number; interval: string; thresholdType: string; - source: string; + source?: string; state: string; channel: any; team: string; @@ -233,16 +192,50 @@ export type ExternalAlert = { dashboard?: string; savedSearch?: string; groupBy?: string; - silenced?: any; - createdAt: string; - updatedAt: string; + silenced?: { + by?: string; + at: string; + until: string; + }; + createdAt?: string; + updatedAt?: string; }; +type AlertDocumentObject = + | AlertDocument + | FlattenMaps>; + +function hasCreatedAt( + alert: AlertDocumentObject, +): alert is AlertDocument & { createdAt: Date } { + return 'createdAt' in alert && alert.createdAt instanceof Date; +} + +function hasUpdatedAt( + alert: AlertDocumentObject, +): alert is AlertDocument & { updatedAt: Date } { + return 'updatedAt' in alert && alert.updatedAt instanceof Date; +} + +function transformSilencedToExternalSilenced( + silenced: AlertDocumentObject['silenced'], +): ExternalAlert['silenced'] { + return silenced + ? { + by: silenced.by?.toString(), + at: silenced.at.toISOString(), + until: silenced.until.toISOString(), + } + : undefined; +} + export function translateAlertDocumentToExternalAlert( alert: AlertDocument, ): ExternalAlert { // Convert to plain object if it's a Mongoose document - const alertObj = alert.toJSON ? alert.toJSON() : { ...alert }; + const alertObj: AlertDocumentObject = alert.toJSON + ? alert.toJSON() + : { ...alert }; // Copy all fields, renaming _id to id, ensuring ObjectId's are strings const result = { @@ -260,9 +253,13 @@ export function translateAlertDocumentToExternalAlert( dashboard: alertObj.dashboard?.toString(), savedSearch: alertObj.savedSearch?.toString(), groupBy: alertObj.groupBy, - silenced: alertObj.silenced, - createdAt: alertObj.createdAt.toISOString(), - updatedAt: alertObj.updatedAt.toISOString(), + silenced: transformSilencedToExternalSilenced(alertObj.silenced), + createdAt: hasCreatedAt(alertObj) + ? alertObj.createdAt.toISOString() + : undefined, + updatedAt: hasUpdatedAt(alertObj) + ? alertObj.updatedAt.toISOString() + : undefined, }; return result;