mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat + fix: add webhook endpoints validators + parse webhook JSON body (#353)
This commit is contained in:
parent
5fc7c21c6e
commit
3b1fe088d1
4 changed files with 153 additions and 81 deletions
5
.changeset/itchy-chicken-confess.md
Normal file
5
.changeset/itchy-chicken-confess.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hyperdx/api': patch
|
||||
---
|
||||
|
||||
feat + fix: add webhook endpoints validators + parse webhook JSON body
|
||||
|
|
@ -1,81 +1,118 @@
|
|||
import express from 'express';
|
||||
import mongoose from 'mongoose';
|
||||
import { z } from 'zod';
|
||||
import { validateRequest } from 'zod-express-middleware';
|
||||
|
||||
import Webhook from '@/models/webhook';
|
||||
import Webhook, { WebhookService } from '@/models/webhook';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
const { service } = req.query;
|
||||
const webhooks = await Webhook.find(
|
||||
{ team: teamId, service },
|
||||
{ __v: 0, team: 0 },
|
||||
);
|
||||
res.json({
|
||||
data: webhooks,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
const { name, service, url, description, queryParams, headers, body } =
|
||||
req.body;
|
||||
if (!service || !url || !name) return res.sendStatus(400);
|
||||
const totalWebhooks = await Webhook.countDocuments({
|
||||
team: teamId,
|
||||
service,
|
||||
});
|
||||
if (totalWebhooks >= 5) {
|
||||
return res.status(400).json({
|
||||
message: 'You can only have 5 webhooks per team per service',
|
||||
router.get(
|
||||
'/',
|
||||
validateRequest({
|
||||
query: z.object({
|
||||
service: z.union([
|
||||
z.nativeEnum(WebhookService),
|
||||
z.nativeEnum(WebhookService).array(),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
const { service } = req.query;
|
||||
const webhooks = await Webhook.find(
|
||||
{ team: teamId, service },
|
||||
{ __v: 0, team: 0 },
|
||||
);
|
||||
res.json({
|
||||
data: webhooks,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
if (await Webhook.findOne({ team: teamId, service, url })) {
|
||||
return res.status(400).json({
|
||||
message: 'Webhook already exists',
|
||||
});
|
||||
}
|
||||
const webhook = new Webhook({
|
||||
team: teamId,
|
||||
service,
|
||||
url,
|
||||
name,
|
||||
description,
|
||||
queryParams,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
await webhook.save();
|
||||
res.json({
|
||||
data: webhook,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest({
|
||||
body: z.object({
|
||||
body: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
name: z.string(),
|
||||
queryParams: z.record(z.string()).optional(),
|
||||
service: z.nativeEnum(WebhookService),
|
||||
url: z.string().url(),
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
const { name, service, url, description, queryParams, headers, body } =
|
||||
req.body;
|
||||
const totalWebhooks = await Webhook.countDocuments({
|
||||
team: teamId,
|
||||
service,
|
||||
});
|
||||
if (totalWebhooks >= 5) {
|
||||
return res.status(400).json({
|
||||
message: 'You can only have 5 webhooks per team per service',
|
||||
});
|
||||
}
|
||||
if (await Webhook.findOne({ team: teamId, service, url })) {
|
||||
return res.status(400).json({
|
||||
message: 'Webhook already exists',
|
||||
});
|
||||
}
|
||||
const webhook = new Webhook({
|
||||
team: teamId,
|
||||
service,
|
||||
url,
|
||||
name,
|
||||
description,
|
||||
queryParams,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
await webhook.save();
|
||||
res.json({
|
||||
data: webhook,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
await Webhook.findOneAndDelete({ _id: req.params.id, team: teamId });
|
||||
res.json({});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateRequest({
|
||||
params: z.object({
|
||||
id: z.string().refine(val => {
|
||||
return mongoose.Types.ObjectId.isValid(val);
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
await Webhook.findOneAndDelete({ _id: req.params.id, team: teamId });
|
||||
res.json({});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1294,7 +1294,9 @@ describe('checkAlerts', () => {
|
|||
// check if generic webhook was triggered, injected, and parsed, and sent correctly
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://webhook.site/123', {
|
||||
method: 'POST',
|
||||
body: `{"text":"http://localhost:9090/search/${logView.id}?from=1700172600000&to=1700172900000&q=level%3Aerror+span_name%3A%22HyperDX%22 | Alert for "My Log View" - 11 lines found"}`,
|
||||
body: JSON.stringify({
|
||||
text: `http://localhost:9090/search/${logView.id}?from=1700172600000&to=1700172900000&q=level%3Aerror+span_name%3A%22HyperDX%22 | Alert for "My Log View" - 11 lines found`,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-HyperDX-Signature': 'XXXXX-XXXXX',
|
||||
|
|
@ -1450,7 +1452,9 @@ describe('checkAlerts', () => {
|
|||
// check if generic webhook was triggered, injected, and parsed, and sent correctly
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://webhook.site/123', {
|
||||
method: 'POST',
|
||||
body: `{"text":"http://localhost:9090/dashboards/${dashboard.id}?from=1700170200000&granularity=5+minute&to=1700174700000 | Alert for "Max Duration" in "My Dashboard" - 102 exceeds 10"}`,
|
||||
body: JSON.stringify({
|
||||
text: `http://localhost:9090/dashboards/${dashboard.id}?from=1700170200000&granularity=5+minute&to=1700174700000 | Alert for "Max Duration" in "My Dashboard" - 102 exceeds 10`,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
|
@ -1677,7 +1681,9 @@ describe('checkAlerts', () => {
|
|||
// check if generic webhook was triggered, injected, and parsed, and sent correctly
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://webhook.site/123', {
|
||||
method: 'POST',
|
||||
body: `{"text":"http://localhost:9090/dashboards/${dashboard.id}?from=1700170200000&granularity=5+minute&to=1700174700000 | Alert for "Redis Memory" in "My Dashboard" - 395.3421052631579 exceeds 10"}`,
|
||||
body: JSON.stringify({
|
||||
text: `http://localhost:9090/dashboards/${dashboard.id}?from=1700170200000&granularity=5+minute&to=1700174700000 | Alert for "Redis Memory" in "My Dashboard" - 395.3421052631579 exceeds 10`,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -273,13 +273,36 @@ export const handleSendGenericWebhook = async (
|
|||
let body = '';
|
||||
if (webhook.body) {
|
||||
const handlebars = Handlebars.create();
|
||||
body = handlebars.compile(webhook.body, {
|
||||
noEscape: true,
|
||||
})({
|
||||
body: message.body,
|
||||
link: message.hdxLink,
|
||||
title: message.title,
|
||||
});
|
||||
let isJsonBody = false;
|
||||
try {
|
||||
const jsonBody = JSON.parse(webhook.body);
|
||||
isJsonBody = true;
|
||||
for (const [_key, _val] of Object.entries(jsonBody)) {
|
||||
jsonBody[_key] = handlebars.compile(_val, {
|
||||
noEscape: true,
|
||||
})({
|
||||
body: message.body,
|
||||
link: message.hdxLink,
|
||||
title: message.title,
|
||||
});
|
||||
}
|
||||
body = JSON.stringify(jsonBody);
|
||||
} catch (e) {
|
||||
logger.error({
|
||||
message: 'Webhook body is not a valid JSON',
|
||||
error: serializeError(e),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isJsonBody) {
|
||||
body = handlebars.compile(webhook.body, {
|
||||
noEscape: true,
|
||||
})({
|
||||
body: message.body,
|
||||
link: message.hdxLink,
|
||||
title: message.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -291,7 +314,8 @@ export const handleSendGenericWebhook = async (
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send generic webhook message');
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error({
|
||||
|
|
|
|||
Loading…
Reference in a new issue