Skip to content

Commit 1651678

Browse files
committed
Add webhook step
1 parent 3bfbf90 commit 1651678

File tree

4 files changed

+482
-6
lines changed

4 files changed

+482
-6
lines changed

src/rules/requests/request-rule-builder.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
JsonRpcResponseStep,
2121
ResetConnectionStep,
2222
CallbackResponseMessageResult,
23-
DelayStep
23+
DelayStep,
24+
WebhookStep,
25+
WaitForRequestBodyStep,
26+
RequestWebhookEvents
2427
} from "./request-step-definitions";
2528
import { byteLength } from "../../util/util";
2629
import { BaseRuleBuilder } from "../base-rule-builder";
@@ -99,6 +102,32 @@ export class RequestRuleBuilder extends BaseRuleBuilder {
99102
return this;
100103
}
101104

105+
/**
106+
* Wait until the request body has been fully received before continuing.
107+
*
108+
* Without this, other handlers like `thenReply` will react immediately, e.g. sending a
109+
* response as soon as the headers are received, before the body has arrived. That is
110+
* perfectly valid and will probably work fine, but could cause strange behaviour
111+
* in some edge cases, and is not representative of how real server responses would
112+
* generally behave.
113+
*/
114+
waitForRequestBody(): this {
115+
this.steps.push(new WaitForRequestBodyStep());
116+
return this;
117+
}
118+
119+
/**
120+
* Register a webhook for the given events. The provided URL will receive a POST request
121+
* with a JSON body containing the details of the configured events when they occur. If
122+
* no event list is specified then it defaults to `['request', 'response']`.
123+
*
124+
* The JSON body will contain `{ eventType: string, eventData: object }`.
125+
*/
126+
addWebhook(url: string, events?: RequestWebhookEvents[]): this {
127+
this.steps.push(new WebhookStep(url, events ?? ['request', 'response']));
128+
return this;
129+
}
130+
102131
/**
103132
* Reply to matched requests with a given status code and (optionally) status message,
104133
* body, headers & trailers.

src/rules/requests/request-step-definitions.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
Operation as JsonPatchOperation,
77
validate as validateJsonPatch
88
} from 'fast-json-patch';
9-
import { MaybePromise } from '@httptoolkit/util';
9+
import { MaybePromise, joinAnd } from '@httptoolkit/util';
1010

1111
import {
1212
Headers,
@@ -19,6 +19,7 @@ import {
1919

2020
import { Replace } from '../../util/type-utils';
2121
import { asBuffer } from '../../util/buffer-utils';
22+
import { isAbsoluteUrl } from '../../util/url';
2223
import {
2324
MatchReplacePairs,
2425
SerializedMatchReplacePairs,
@@ -1077,6 +1078,47 @@ export class DelayStep extends Serializable implements RequestStepDefinition {
10771078

10781079
}
10791080

1081+
export class WaitForRequestBodyStep extends Serializable implements RequestStepDefinition {
1082+
1083+
readonly type = 'wait-for-request-body'
1084+
static readonly isFinal = false;
1085+
1086+
explain(): string {
1087+
return 'wait for the full request body to be received';
1088+
}
1089+
1090+
}
1091+
1092+
export type RequestWebhookEvents =
1093+
| 'request'
1094+
| 'response';
1095+
1096+
export class WebhookStep extends Serializable implements RequestStepDefinition {
1097+
1098+
readonly type = 'webhook';
1099+
static readonly isFinal = false;
1100+
1101+
constructor(
1102+
public readonly url: string,
1103+
public readonly events: RequestWebhookEvents[]
1104+
) {
1105+
super();
1106+
1107+
if (!isAbsoluteUrl(url)) {
1108+
throw new Error(`Webhook URL "${url}" must be absolute`);
1109+
}
1110+
}
1111+
1112+
explain(): string {
1113+
// We actively support sending no events to make it easier to quickly toggle
1114+
// settings here during debugging without breaking anything unnecessarily.
1115+
return `use ${this.url} as a webhook for ${
1116+
this.events?.length ? joinAnd(this.events) : 'no'
1117+
} events`;
1118+
}
1119+
1120+
}
1121+
10801122
export const StepDefinitionLookup = {
10811123
'simple': FixedResponseStep,
10821124
'callback': CallbackStep,
@@ -1087,5 +1129,7 @@ export const StepDefinitionLookup = {
10871129
'reset-connection': ResetConnectionStep,
10881130
'timeout': TimeoutStep,
10891131
'json-rpc-response': JsonRpcResponseStep,
1090-
'delay': DelayStep
1132+
'delay': DelayStep,
1133+
'wait-for-request-body': WaitForRequestBodyStep,
1134+
'webhook': WebhookStep
10911135
}

src/rules/requests/request-step-impls.ts

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import {
2929
shouldKeepAlive,
3030
isHttp2,
3131
writeHead,
32-
encodeBodyBuffer
32+
encodeBodyBuffer,
33+
waitForCompletedResponse
3334
} from '../../util/request-utils';
3435
import {
3536
h1HeadersToH2,
@@ -115,7 +116,9 @@ import {
115116
FixedResponseStep,
116117
StreamStep,
117118
TimeoutStep,
118-
DelayStep
119+
DelayStep,
120+
WebhookStep,
121+
WaitForRequestBodyStep
119122
} from './request-step-definitions';
120123

121124
// Re-export various type definitions. This is mostly for compatibility with external
@@ -1386,6 +1389,94 @@ export class DelayStepImpl extends DelayStep {
13861389
}
13871390
}
13881391

1392+
export class WaitForRequestBodyImpl extends WaitForRequestBodyStep {
1393+
async handle(request: OngoingRequest): Promise<{ continue: true }> {
1394+
await request.body.asBuffer();
1395+
return { continue: true };
1396+
}
1397+
}
1398+
1399+
const encodeWebhookBody = (body: Buffer) => {
1400+
return {
1401+
format: 'base64',
1402+
data: body.toString('base64')
1403+
};
1404+
};
1405+
1406+
export class WebhookStepImpl extends WebhookStep {
1407+
1408+
private sendEvent(data: {
1409+
eventType: string;
1410+
eventData: {};
1411+
}) {
1412+
const content = JSON.stringify(data);
1413+
const req = http.request(this.url, {
1414+
method: 'POST',
1415+
headers: {
1416+
'Content-Type': 'application/json',
1417+
'Content-Length': Buffer.byteLength(content)
1418+
}
1419+
}).end(content);
1420+
1421+
req.on('error', (e) => {
1422+
console.warn(`Error sending webhook to ${this.url}:`, e);
1423+
});
1424+
1425+
req.on('response', (res) => {
1426+
if (res.statusCode !== 200) {
1427+
console.warn(`Received unexpected ${res.statusCode} response from webhook ${this.url} for ${data.eventType}`);
1428+
}
1429+
1430+
res.on('error', () => {});
1431+
res.resume();
1432+
});
1433+
}
1434+
1435+
async handle(request: OngoingRequest, response: OngoingResponse) {
1436+
if (this.events.includes('request')) {
1437+
waitForCompletedRequest(request).then((completedReq) => {
1438+
const eventData = {
1439+
..._.pick(completedReq, [
1440+
'id',
1441+
'method',
1442+
'url',
1443+
'headers',
1444+
'trailers'
1445+
]),
1446+
body: encodeWebhookBody(completedReq.body.buffer)
1447+
}
1448+
1449+
this.sendEvent({
1450+
eventType: 'request',
1451+
eventData: eventData
1452+
});
1453+
}).catch(() => {});
1454+
}
1455+
1456+
if (this.events.includes('response')) {
1457+
waitForCompletedResponse(response).then((completedRes) => {
1458+
const eventData = {
1459+
..._.pick(completedRes, [
1460+
'id',
1461+
'statusCode',
1462+
'statusMessage',
1463+
'headers',
1464+
'trailers'
1465+
]),
1466+
body: encodeWebhookBody(completedRes.body.buffer)
1467+
}
1468+
1469+
this.sendEvent({
1470+
eventType: 'response',
1471+
eventData: eventData
1472+
});
1473+
}).catch(() => {});
1474+
}
1475+
1476+
return { continue: true };
1477+
}
1478+
}
1479+
13891480
export const StepLookup: typeof StepDefinitionLookup = {
13901481
'simple': FixedResponseStepImpl,
13911482
'callback': CallbackStepImpl,
@@ -1396,5 +1487,7 @@ export const StepLookup: typeof StepDefinitionLookup = {
13961487
'reset-connection': ResetConnectionStepImpl,
13971488
'timeout': TimeoutStepImpl,
13981489
'json-rpc-response': JsonRpcResponseStepImpl,
1399-
'delay': DelayStepImpl
1490+
'delay': DelayStepImpl,
1491+
'wait-for-request-body': WaitForRequestBodyImpl,
1492+
'webhook': WebhookStepImpl
14001493
}

0 commit comments

Comments
 (0)