Skip to content

Commit 1f21746

Browse files
committed
feat(webhook): unify webhook delivery structure and remove deprecated templates
1 parent a4bb2cf commit 1f21746

9 files changed

Lines changed: 192 additions & 139 deletions

File tree

workers/webhook/src/deliverer.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import https from 'https';
22
import http from 'http';
33
import { createLogger, format, Logger, transports } from 'winston';
4+
import { WebhookDelivery } from '../types/template';
45

56
/**
67
* Timeout for webhook delivery in milliseconds
@@ -35,13 +36,14 @@ export default class WebhookDeliverer {
3536
});
3637

3738
/**
38-
* Sends JSON payload to the webhook endpoint via HTTP POST
39+
* Sends webhook delivery to the endpoint via HTTP POST.
40+
* Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event).
3941
*
4042
* @param endpoint - URL to POST to
41-
* @param payload - JSON body to send
43+
* @param delivery - webhook delivery { type, payload }
4244
*/
43-
public async deliver(endpoint: string, payload: Record<string, unknown>): Promise<void> {
44-
const body = JSON.stringify(payload);
45+
public async deliver(endpoint: string, delivery: WebhookDelivery): Promise<void> {
46+
const body = JSON.stringify(delivery);
4547
const url = new URL(endpoint);
4648
const transport = url.protocol === 'https:' ? https : http;
4749

@@ -53,6 +55,7 @@ export default class WebhookDeliverer {
5355
headers: {
5456
'Content-Type': 'application/json',
5557
'User-Agent': 'Hawk-Webhook/1.0',
58+
'X-Hawk-Notification': delivery.type,
5659
'Content-Length': Buffer.byteLength(body),
5760
},
5861
timeout: DELIVERY_TIMEOUT_MS,

workers/webhook/src/provider.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import NotificationsProvider from 'hawk-worker-sender/src/provider';
2-
import { Notification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables';
3-
import templates from './templates';
4-
import { WebhookTemplate } from '../types/template';
2+
import { Notification } from 'hawk-worker-sender/types/template-variables';
3+
import { toDelivery } from './templates';
54
import WebhookDeliverer from './deliverer';
65

76
/**
8-
* This class provides a 'send' method that renders and sends a webhook notification
7+
* Webhook notification provider.
8+
* Supports all notification types via a single generic serializer —
9+
* type comes from notification.type, payload is sanitized automatically.
910
*/
1011
export default class WebhookProvider extends NotificationsProvider {
1112
/**
@@ -26,16 +27,8 @@ export default class WebhookProvider extends NotificationsProvider {
2627
* @param notification - notification with payload and type
2728
*/
2829
public async send(to: string, notification: Notification): Promise<void> {
29-
let template: WebhookTemplate;
30+
const delivery = toDelivery(notification);
3031

31-
switch (notification.type) {
32-
case 'event': template = templates.EventTpl; break;
33-
case 'several-events': template = templates.SeveralEventsTpl; break;
34-
default: return;
35-
}
36-
37-
const payload = template(notification.payload as EventsTemplateVariables);
38-
39-
await this.deliverer.deliver(to, payload);
32+
await this.deliverer.deliver(to, delivery);
4033
}
4134
}

workers/webhook/src/templates/event.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Notification } from 'hawk-worker-sender/types/template-variables';
2+
import { WebhookDelivery } from '../../types/template';
3+
4+
/**
5+
* List of internal fields that should not be exposed in webhook payload
6+
*/
7+
const INTERNAL_FIELDS = new Set(['host', 'hostOfStatic']);
8+
9+
/**
10+
* Recursively converts MongoDB ObjectIds and other non-JSON-safe values to strings
11+
*
12+
* @param value - any value to sanitize
13+
*/
14+
function sanitize(value: unknown): unknown {
15+
if (value === null || value === undefined) {
16+
return value;
17+
}
18+
19+
if (typeof value === 'object' && '_bsontype' in (value as Record<string, unknown>)) {
20+
return String(value);
21+
}
22+
23+
if (Array.isArray(value)) {
24+
return value.map(sanitize);
25+
}
26+
27+
if (typeof value === 'object') {
28+
const result: Record<string, unknown> = {};
29+
30+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
31+
if (!INTERNAL_FIELDS.has(k)) {
32+
result[k] = sanitize(v);
33+
}
34+
}
35+
36+
return result;
37+
}
38+
39+
return value;
40+
}
41+
42+
/**
43+
* Generic webhook template — handles any notification type
44+
* by passing through the sanitized payload as-is.
45+
*
46+
* Used as a fallback when no curated template exists for the notification type.
47+
*
48+
* @param notification - notification with type and payload
49+
*/
50+
export default function render(notification: Notification): WebhookDelivery {
51+
return {
52+
type: notification.type,
53+
payload: sanitize(notification.payload) as Record<string, unknown>,
54+
};
55+
}
Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1 @@
1-
import EventTpl from './event';
2-
import SeveralEventsTpl from './several-events';
3-
4-
export default {
5-
EventTpl,
6-
SeveralEventsTpl,
7-
};
1+
export { default as toDelivery } from './generic';

workers/webhook/src/templates/several-events.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { AssigneeNotification } from 'hawk-worker-sender/types/template-variables';
2+
import { ObjectId } from 'mongodb';
3+
4+
/**
5+
* Example of assignee notify template variables
6+
*/
7+
export default {
8+
type: 'assignee',
9+
payload: {
10+
host: process.env.GARAGE_URL,
11+
hostOfStatic: process.env.API_STATIC_URL,
12+
project: {
13+
_id: new ObjectId('5d206f7f9aaf7c0071d64596'),
14+
token: 'project-token',
15+
name: 'Project',
16+
workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'),
17+
uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'),
18+
notifications: [],
19+
},
20+
event: {
21+
totalCount: 5,
22+
groupHash: 'abc123',
23+
payload: {
24+
title: 'TypeError: Cannot read property',
25+
},
26+
},
27+
whoAssigned: {
28+
name: 'John Doe',
29+
email: 'john@example.com',
30+
},
31+
daysRepeated: 3,
32+
},
33+
} as AssigneeNotification;
Lines changed: 81 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import templates from '../src/templates';
21
import EventNotifyMock from './__mocks__/event-notify';
32
import SeveralEventsNotifyMock from './__mocks__/several-events-notify';
3+
import AssigneeNotifyMock from './__mocks__/assignee-notify';
44
import WebhookProvider from '../src/provider';
55

66
/**
@@ -18,9 +18,6 @@ const deliver = jest.fn();
1818
*/
1919
jest.mock('./../src/deliverer.ts', () => {
2020
return jest.fn().mockImplementation(() => {
21-
/**
22-
* Now we can track calls to 'deliver'
23-
*/
2421
return {
2522
deliver: deliver,
2623
};
@@ -35,48 +32,92 @@ afterEach(() => {
3532
});
3633

3734
describe('WebhookProvider', () => {
38-
/**
39-
* Check that the 'send' method works without errors
40-
*/
41-
it('The "send" method should render and deliver message', async () => {
35+
it('should deliver a message with { type, payload } structure', async () => {
4236
const provider = new WebhookProvider();
4337

4438
await provider.send(webhookEndpointSample, EventNotifyMock);
4539

4640
expect(deliver).toHaveBeenCalledTimes(1);
47-
expect(deliver).toHaveBeenCalledWith(webhookEndpointSample, expect.anything());
41+
expect(deliver).toHaveBeenCalledWith(webhookEndpointSample, expect.objectContaining({
42+
type: 'event',
43+
payload: expect.any(Object),
44+
}));
4845
});
4946

50-
/**
51-
* Logic for select the template depended on events count
52-
*/
53-
describe('Select correct template', () => {
54-
/**
55-
* If there is a single event in payload, use the 'event' template
56-
*/
57-
it('Select the event template if there is a single event in notify payload', async () => {
58-
const provider = new WebhookProvider();
59-
const EventTpl = jest.spyOn(templates, 'EventTpl');
60-
const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl');
61-
62-
await provider.send(webhookEndpointSample, EventNotifyMock);
63-
64-
expect(EventTpl).toHaveBeenCalledTimes(1);
65-
expect(SeveralEventsTpl).toHaveBeenCalledTimes(0);
66-
});
67-
68-
/**
69-
* If there are several events in payload, use the 'several-events' template
70-
*/
71-
it('Select the several-events template if there are several events in notify payload', async () => {
72-
const provider = new WebhookProvider();
73-
const EventTpl = jest.spyOn(templates, 'EventTpl');
74-
const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl');
75-
76-
await provider.send(webhookEndpointSample, SeveralEventsNotifyMock);
77-
78-
expect(EventTpl).toHaveBeenCalledTimes(0);
79-
expect(SeveralEventsTpl).toHaveBeenCalledTimes(1);
80-
});
47+
it('should preserve notification type in delivery', async () => {
48+
const provider = new WebhookProvider();
49+
50+
await provider.send(webhookEndpointSample, EventNotifyMock);
51+
expect(deliver.mock.calls[0][1].type).toBe('event');
52+
53+
deliver.mockClear();
54+
55+
await provider.send(webhookEndpointSample, SeveralEventsNotifyMock);
56+
expect(deliver.mock.calls[0][1].type).toBe('several-events');
57+
58+
deliver.mockClear();
59+
60+
await provider.send(webhookEndpointSample, AssigneeNotifyMock);
61+
expect(deliver.mock.calls[0][1].type).toBe('assignee');
62+
});
63+
64+
it('should strip internal fields (host, hostOfStatic) from payload', async () => {
65+
const provider = new WebhookProvider();
66+
67+
await provider.send(webhookEndpointSample, {
68+
type: 'payment-failed',
69+
payload: {
70+
host: 'https://garage.hawk.so',
71+
hostOfStatic: 'https://api.hawk.so',
72+
workspace: { name: 'Workspace' },
73+
reason: 'Insufficient funds',
74+
},
75+
} as any);
76+
77+
const delivery = deliver.mock.calls[0][1];
78+
79+
expect(delivery.payload).not.toHaveProperty('host');
80+
expect(delivery.payload).not.toHaveProperty('hostOfStatic');
81+
expect(delivery.payload).toHaveProperty('reason', 'Insufficient funds');
82+
});
83+
84+
it('should handle all known notification types without throwing', async () => {
85+
const provider = new WebhookProvider();
86+
87+
const types = [
88+
'event',
89+
'several-events',
90+
'assignee',
91+
'block-workspace',
92+
'blocked-workspace-reminder',
93+
'payment-failed',
94+
'payment-success',
95+
'days-limit-almost-reached',
96+
'events-limit-almost-reached',
97+
'sign-up',
98+
'password-reset',
99+
'workspace-invite',
100+
];
101+
102+
for (const type of types) {
103+
await expect(
104+
provider.send(webhookEndpointSample, {
105+
type,
106+
payload: { host: 'h', hostOfStatic: 's' },
107+
} as any)
108+
).resolves.toBeUndefined();
109+
}
110+
111+
expect(deliver).toHaveBeenCalledTimes(types.length);
112+
});
113+
114+
it('should only have { type, payload } keys at root level', async () => {
115+
const provider = new WebhookProvider();
116+
117+
await provider.send(webhookEndpointSample, EventNotifyMock);
118+
119+
const delivery = deliver.mock.calls[0][1];
120+
121+
expect(Object.keys(delivery).sort()).toEqual(['payload', 'type']);
81122
});
82123
});

0 commit comments

Comments
 (0)