feat + fix: add webhook endpoints validators + parse webhook JSON body (#353)

This commit is contained in:
Warren 2024-03-27 19:32:11 -07:00 committed by GitHub
parent 5fc7c21c6e
commit 3b1fe088d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 153 additions and 81 deletions

View file

@ -0,0 +1,5 @@
---
'@hyperdx/api': patch
---
feat + fix: add webhook endpoints validators + parse webhook JSON body

View file

@ -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;

View file

@ -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',
},

View file

@ -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({