mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add list Webhooks API (#1802)
Closes HDX-3450 # Summary This PR adds a list webhooks endpoint to the external API, to better support alert creation via the API (which requires a webhook ID). <img width="2275" height="1048" alt="Screenshot 2026-02-25 at 11 54 54 AM" src="https://github.com/user-attachments/assets/a9a4f1cb-d3e9-4d18-bb9c-6647b784adf9" /> <img width="1463" height="1057" alt="Screenshot 2026-02-25 at 11 55 45 AM" src="https://github.com/user-attachments/assets/b7864e67-b8d0-4f71-aedf-04d2d13e27f1" />
This commit is contained in:
parent
3e8cc729d1
commit
34c9afebe2
9 changed files with 737 additions and 0 deletions
6
.changeset/sour-lies-kick.md
Normal file
6
.changeset/sour-lies-kick.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
---
|
||||
|
||||
feat: Add list webhooks API
|
||||
|
|
@ -75,9 +75,11 @@ yarn test:e2e:ci # Run end-to-end tests in CI
|
|||
**packages/api** (integration tests only):
|
||||
|
||||
```bash
|
||||
docker compose -f ./docker-compose.ci.yml up -d # Start the integration test docker services
|
||||
cd packages/api
|
||||
yarn ci:int # Run integration tests
|
||||
yarn dev:int # Watch mode for integration tests
|
||||
cd ../.. && docker compose -f ./docker-compose.ci.yml down # Stop the integration test docker services
|
||||
```
|
||||
|
||||
**packages/common-utils** (both unit and integration tests):
|
||||
|
|
|
|||
|
|
@ -2230,6 +2230,187 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SlackWebhook": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"service",
|
||||
"updatedAt",
|
||||
"createdAt"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Webhook ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Webhook name"
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"slack"
|
||||
],
|
||||
"description": "Webhook service type"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Slack incoming webhook URL"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Webhook description, shown in the UI"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Last update timestamp"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Creation timestamp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"IncidentIOWebhook": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"service",
|
||||
"updatedAt",
|
||||
"createdAt"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Webhook ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Webhook name"
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"incidentio"
|
||||
],
|
||||
"description": "Webhook service type"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "incident.io alert event HTTP source URL"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Webhook description, shown in the UI"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Last update timestamp"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Creation timestamp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GenericWebhook": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"service",
|
||||
"updatedAt",
|
||||
"createdAt"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Webhook ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Webhook name"
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"generic"
|
||||
],
|
||||
"description": "Webhook service type"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Webhook destination URL"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Webhook description, shown in the UI"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Optional headers to include in the webhook request"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Optional request body template"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Last update timestamp"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Creation timestamp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Webhook": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SlackWebhook"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/IncidentIOWebhook"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GenericWebhook"
|
||||
}
|
||||
],
|
||||
"discriminator": {
|
||||
"propertyName": "service",
|
||||
"mapping": {
|
||||
"slack": "#/components/schemas/SlackWebhook",
|
||||
"incidentio": "#/components/schemas/IncidentIOWebhook",
|
||||
"generic": "#/components/schemas/GenericWebhook"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebhooksListResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -3601,6 +3782,51 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/webhooks": {
|
||||
"get": {
|
||||
"summary": "List Webhooks",
|
||||
"description": "Retrieves a list of all webhooks for the authenticated team",
|
||||
"operationId": "listWebhooks",
|
||||
"tags": [
|
||||
"Webhooks"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully retrieved webhooks",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WebhooksListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
},
|
||||
"example": {
|
||||
"message": "Unauthorized access. API key is missing or invalid."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,8 @@ const WebhookSchema = new Schema<IWebhook>(
|
|||
{ timestamps: true },
|
||||
);
|
||||
|
||||
export type WebhookDocument = mongoose.HydratedDocument<IWebhook>;
|
||||
|
||||
WebhookSchema.index({ team: 1, service: 1, name: 1 }, { unique: true });
|
||||
|
||||
export default mongoose.model<IWebhook>('Webhook', WebhookSchema);
|
||||
|
|
|
|||
240
packages/api/src/routers/external-api/__tests__/webhooks.test.ts
Normal file
240
packages/api/src/routers/external-api/__tests__/webhooks.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { WebhookService } from '@hyperdx/common-utils/dist/types';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import request, { SuperAgentTest } from 'supertest';
|
||||
|
||||
import { getLoggedInAgent, getServer } from '../../../fixtures';
|
||||
import { ITeam } from '../../../models/team';
|
||||
import { IUser } from '../../../models/user';
|
||||
import Webhook from '../../../models/webhook';
|
||||
|
||||
const WEBHOOKS_BASE_URL = '/api/v2/webhooks';
|
||||
|
||||
const MOCK_SLACK_WEBHOOK = {
|
||||
name: 'Test Slack Webhook',
|
||||
service: WebhookService.Slack,
|
||||
url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX',
|
||||
description: 'Test webhook for Slack',
|
||||
};
|
||||
|
||||
const MOCK_INCIDENT_IO_WEBHOOK = {
|
||||
name: 'Test IncidentIO Webhook',
|
||||
service: WebhookService.IncidentIO,
|
||||
url: 'https://api.incident.io/v2/alert_events/http/ZZZZZZZZ?token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
|
||||
description: 'Test webhook for incident.io',
|
||||
};
|
||||
|
||||
const MOCK_GENERIC_WEBHOOK = {
|
||||
name: 'Test Generic Webhook',
|
||||
service: WebhookService.Generic,
|
||||
url: 'https://example.com/webhook',
|
||||
description: 'Test generic webhook',
|
||||
headers: { 'X-Custom-Header': 'Header Value', Authorization: 'Bearer token' },
|
||||
body: '{"text": "{{title}} | {{body}} | {{link}}"}',
|
||||
};
|
||||
|
||||
describe('External API v2 Webhooks', () => {
|
||||
const server = getServer();
|
||||
let agent: SuperAgentTest;
|
||||
let team: ITeam;
|
||||
let user: IUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
await server.start();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const result = await getLoggedInAgent(server);
|
||||
agent = result.agent;
|
||||
team = result.team;
|
||||
user = result.user;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.clearDBs();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
const authRequest = (
|
||||
method: 'get' | 'post' | 'put' | 'delete',
|
||||
url: string,
|
||||
) => {
|
||||
return agent[method](url).set('Authorization', `Bearer ${user?.accessKey}`);
|
||||
};
|
||||
|
||||
describe('GET /api/v2/webhooks', () => {
|
||||
it('should return an empty list when no webhooks exist', async () => {
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.headers['content-type']).toMatch(/application\/json/);
|
||||
expect(response.body).toEqual({ data: [] });
|
||||
});
|
||||
|
||||
it('should list a Slack webhook with only Slack-allowed fields', async () => {
|
||||
await Webhook.create({ ...MOCK_SLACK_WEBHOOK, team: team._id });
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
expect(response.body.data[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: MOCK_SLACK_WEBHOOK.name,
|
||||
service: WebhookService.Slack,
|
||||
url: MOCK_SLACK_WEBHOOK.url,
|
||||
description: MOCK_SLACK_WEBHOOK.description,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(response.body.data[0]).not.toHaveProperty('headers');
|
||||
expect(response.body.data[0]).not.toHaveProperty('body');
|
||||
});
|
||||
|
||||
it('should strip headers and body stored on a Slack webhook', async () => {
|
||||
await Webhook.create({
|
||||
...MOCK_SLACK_WEBHOOK,
|
||||
headers: { 'X-Secret': 'secret' },
|
||||
body: '{"text": "hello"}',
|
||||
team: team._id,
|
||||
});
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data[0]).not.toHaveProperty('headers');
|
||||
expect(response.body.data[0]).not.toHaveProperty('body');
|
||||
});
|
||||
|
||||
it('should list an IncidentIO webhook with only IncidentIO-allowed fields', async () => {
|
||||
await Webhook.create({ ...MOCK_INCIDENT_IO_WEBHOOK, team: team._id });
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
expect(response.body.data[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: MOCK_INCIDENT_IO_WEBHOOK.name,
|
||||
service: WebhookService.IncidentIO,
|
||||
url: MOCK_INCIDENT_IO_WEBHOOK.url,
|
||||
description: MOCK_INCIDENT_IO_WEBHOOK.description,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(response.body.data[0]).not.toHaveProperty('headers');
|
||||
expect(response.body.data[0]).not.toHaveProperty('body');
|
||||
});
|
||||
|
||||
it('should strip headers and body stored on an IncidentIO webhook', async () => {
|
||||
await Webhook.create({
|
||||
...MOCK_INCIDENT_IO_WEBHOOK,
|
||||
headers: { 'X-Secret': 'secret' },
|
||||
body: '{"title": "{{title}}"}',
|
||||
team: team._id,
|
||||
});
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data[0]).not.toHaveProperty('headers');
|
||||
expect(response.body.data[0]).not.toHaveProperty('body');
|
||||
});
|
||||
|
||||
it('should list a Generic webhook with headers and body', async () => {
|
||||
await Webhook.create({ ...MOCK_GENERIC_WEBHOOK, team: team._id });
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
expect(response.body.data[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: MOCK_GENERIC_WEBHOOK.name,
|
||||
service: WebhookService.Generic,
|
||||
url: MOCK_GENERIC_WEBHOOK.url,
|
||||
description: MOCK_GENERIC_WEBHOOK.description,
|
||||
headers: MOCK_GENERIC_WEBHOOK.headers,
|
||||
body: MOCK_GENERIC_WEBHOOK.body,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return multiple webhooks of different service types', async () => {
|
||||
await Webhook.create({ ...MOCK_SLACK_WEBHOOK, team: team._id });
|
||||
await Webhook.create({ ...MOCK_INCIDENT_IO_WEBHOOK, team: team._id });
|
||||
await Webhook.create({ ...MOCK_GENERIC_WEBHOOK, team: team._id });
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(3);
|
||||
const names = response.body.data.map((w: { name: string }) => w.name);
|
||||
expect(names).toContain(MOCK_SLACK_WEBHOOK.name);
|
||||
expect(names).toContain(MOCK_INCIDENT_IO_WEBHOOK.name);
|
||||
expect(names).toContain(MOCK_GENERIC_WEBHOOK.name);
|
||||
});
|
||||
|
||||
it('should not return webhooks belonging to another team', async () => {
|
||||
await Webhook.create({ ...MOCK_SLACK_WEBHOOK, team: team._id });
|
||||
|
||||
const otherTeamId = new ObjectId();
|
||||
await Webhook.create({
|
||||
...MOCK_SLACK_WEBHOOK,
|
||||
name: 'Other Team Webhook',
|
||||
team: otherTeamId,
|
||||
});
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
expect(response.body.data[0].name).toBe(MOCK_SLACK_WEBHOOK.name);
|
||||
});
|
||||
|
||||
it('should work with a minimal Slack webhook (no optional fields)', async () => {
|
||||
await Webhook.create({
|
||||
name: 'Minimal Slack Webhook',
|
||||
service: WebhookService.Slack,
|
||||
team: team._id,
|
||||
});
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
expect(response.body.data[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: 'Minimal Slack Webhook',
|
||||
service: WebhookService.Slack,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(response.body.data[0]).not.toHaveProperty('url');
|
||||
expect(response.body.data[0]).not.toHaveProperty('description');
|
||||
expect(response.body.data[0]).not.toHaveProperty('headers');
|
||||
expect(response.body.data[0]).not.toHaveProperty('body');
|
||||
});
|
||||
|
||||
it('should work with a minimal Generic webhook (no optional fields)', async () => {
|
||||
await Webhook.create({
|
||||
name: 'Minimal Generic Webhook',
|
||||
service: WebhookService.Generic,
|
||||
team: team._id,
|
||||
});
|
||||
|
||||
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
expect(response.body.data[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: 'Minimal Generic Webhook',
|
||||
service: WebhookService.Generic,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(response.body.data[0]).not.toHaveProperty('url');
|
||||
expect(response.body.data[0]).not.toHaveProperty('description');
|
||||
expect(response.body.data[0]).not.toHaveProperty('headers');
|
||||
expect(response.body.data[0]).not.toHaveProperty('body');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
await request(server.getHttpServer()).get(WEBHOOKS_BASE_URL).expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import alertsRouter from '@/routers/external-api/v2/alerts';
|
|||
import chartsRouter from '@/routers/external-api/v2/charts';
|
||||
import dashboardRouter from '@/routers/external-api/v2/dashboards';
|
||||
import sourcesRouter from '@/routers/external-api/v2/sources';
|
||||
import webhooksRouter from '@/routers/external-api/v2/webhooks';
|
||||
import rateLimiter from '@/utils/rateLimiter';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -46,4 +47,11 @@ router.use(
|
|||
sourcesRouter,
|
||||
);
|
||||
|
||||
router.use(
|
||||
'/webhooks',
|
||||
defaultRateLimiter,
|
||||
validateUserAccessKey,
|
||||
webhooksRouter,
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
213
packages/api/src/routers/external-api/v2/webhooks.ts
Normal file
213
packages/api/src/routers/external-api/v2/webhooks.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import express from 'express';
|
||||
|
||||
import { WebhookDocument } from '@/models/webhook';
|
||||
import Webhook from '@/models/webhook';
|
||||
import logger from '@/utils/logger';
|
||||
import { ExternalWebhook, externalWebhookSchema } from '@/utils/zod';
|
||||
|
||||
function formatExternalWebhook(
|
||||
webhook: WebhookDocument,
|
||||
): ExternalWebhook | undefined {
|
||||
// Convert to JSON so that any ObjectIds are converted to strings ("_id" is also converted to "id")
|
||||
const json = JSON.stringify(webhook.toJSON({ getters: true }));
|
||||
|
||||
// Parse using the externalWebhookSchema to strip out any fields not defined in the schema
|
||||
const parseResult = externalWebhookSchema.safeParse(JSON.parse(json));
|
||||
if (parseResult.success) {
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
// If parsing fails, log the error and return undefined
|
||||
logger.error(
|
||||
{ webhook, error: parseResult.error },
|
||||
'Failed to parse webhook using externalWebhookSchema:',
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* components:
|
||||
* schemas:
|
||||
* SlackWebhook:
|
||||
* type: object
|
||||
* required:
|
||||
* - id
|
||||
* - name
|
||||
* - service
|
||||
* - updatedAt
|
||||
* - createdAt
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: Webhook ID
|
||||
* name:
|
||||
* type: string
|
||||
* description: Webhook name
|
||||
* service:
|
||||
* type: string
|
||||
* enum: [slack]
|
||||
* description: Webhook service type
|
||||
* url:
|
||||
* type: string
|
||||
* description: Slack incoming webhook URL
|
||||
* description:
|
||||
* type: string
|
||||
* description: Webhook description, shown in the UI
|
||||
* updatedAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Last update timestamp
|
||||
* createdAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Creation timestamp
|
||||
* IncidentIOWebhook:
|
||||
* type: object
|
||||
* required:
|
||||
* - id
|
||||
* - name
|
||||
* - service
|
||||
* - updatedAt
|
||||
* - createdAt
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: Webhook ID
|
||||
* name:
|
||||
* type: string
|
||||
* description: Webhook name
|
||||
* service:
|
||||
* type: string
|
||||
* enum: [incidentio]
|
||||
* description: Webhook service type
|
||||
* url:
|
||||
* type: string
|
||||
* description: incident.io alert event HTTP source URL
|
||||
* description:
|
||||
* type: string
|
||||
* description: Webhook description, shown in the UI
|
||||
* updatedAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Last update timestamp
|
||||
* createdAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Creation timestamp
|
||||
* GenericWebhook:
|
||||
* type: object
|
||||
* required:
|
||||
* - id
|
||||
* - name
|
||||
* - service
|
||||
* - updatedAt
|
||||
* - createdAt
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: Webhook ID
|
||||
* name:
|
||||
* type: string
|
||||
* description: Webhook name
|
||||
* service:
|
||||
* type: string
|
||||
* enum: [generic]
|
||||
* description: Webhook service type
|
||||
* url:
|
||||
* type: string
|
||||
* description: Webhook destination URL
|
||||
* description:
|
||||
* type: string
|
||||
* description: Webhook description, shown in the UI
|
||||
* headers:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: string
|
||||
* description: Optional headers to include in the webhook request
|
||||
* body:
|
||||
* type: string
|
||||
* description: Optional request body template
|
||||
* updatedAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Last update timestamp
|
||||
* createdAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Creation timestamp
|
||||
* Webhook:
|
||||
* oneOf:
|
||||
* - $ref: '#/components/schemas/SlackWebhook'
|
||||
* - $ref: '#/components/schemas/IncidentIOWebhook'
|
||||
* - $ref: '#/components/schemas/GenericWebhook'
|
||||
* discriminator:
|
||||
* propertyName: service
|
||||
* mapping:
|
||||
* slack: '#/components/schemas/SlackWebhook'
|
||||
* incidentio: '#/components/schemas/IncidentIOWebhook'
|
||||
* generic: '#/components/schemas/GenericWebhook'
|
||||
* WebhooksListResponse:
|
||||
* type: object
|
||||
* required:
|
||||
* - data
|
||||
* properties:
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Webhook'
|
||||
*/
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v2/webhooks:
|
||||
* get:
|
||||
* summary: List Webhooks
|
||||
* description: Retrieves a list of all webhooks for the authenticated team
|
||||
* operationId: listWebhooks
|
||||
* tags: [Webhooks]
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Successfully retrieved webhooks
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/WebhooksListResponse'
|
||||
* '401':
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* example:
|
||||
* message: "Unauthorized access. API key is missing or invalid."
|
||||
* '403':
|
||||
* description: Forbidden
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
|
||||
const webhooks: WebhookDocument[] = await Webhook.find({
|
||||
team: teamId.toString(),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
data: webhooks.map(formatExternalWebhook).filter(s => s !== undefined),
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
MetricsDataType,
|
||||
NumberFormatSchema,
|
||||
SearchConditionLanguageSchema as whereLanguageSchema,
|
||||
WebhookService,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { Types } from 'mongoose';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -405,3 +406,41 @@ export const alertSchema = z
|
|||
message: z.string().min(1).max(4096).nullish(),
|
||||
})
|
||||
.and(zSavedSearchAlert.or(zTileAlert));
|
||||
|
||||
// ==============================
|
||||
// Webhooks
|
||||
// ==============================
|
||||
|
||||
const baseWebhookSchema = {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
updatedAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
};
|
||||
|
||||
const slackWebhookSchema = z.object({
|
||||
...baseWebhookSchema,
|
||||
service: z.literal(WebhookService.Slack),
|
||||
});
|
||||
|
||||
const incidentIOWebhookSchema = z.object({
|
||||
...baseWebhookSchema,
|
||||
service: z.literal(WebhookService.IncidentIO),
|
||||
});
|
||||
|
||||
const genericWebhookSchema = z.object({
|
||||
...baseWebhookSchema,
|
||||
service: z.literal(WebhookService.Generic),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.string().optional(),
|
||||
});
|
||||
|
||||
export const externalWebhookSchema = z.discriminatedUnion('service', [
|
||||
slackWebhookSchema,
|
||||
incidentIOWebhookSchema,
|
||||
genericWebhookSchema,
|
||||
]);
|
||||
|
||||
export type ExternalWebhook = z.infer<typeof externalWebhookSchema>;
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ export enum WebhookService {
|
|||
}
|
||||
|
||||
// Base webhook interface (matches backend IWebhook but with JSON-serialized types)
|
||||
// When making changes here, consider if they need to be made to the external API schema as well (packages/api/src/utils/zod.ts).
|
||||
export interface IWebhook {
|
||||
_id: string;
|
||||
createdAt: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue