Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/webhook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
"build:clean": "shx rm -rf ./dist",
"docs": "npx typedoc --plugin typedoc-plugin-markdown",
"prepack": "npm run build",
"test": "npm run build && node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts",
"test:coverage": "npm run build && node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts"
"test": "npm run build && node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts src/WebhookTrigger.test.ts",
"test:coverage": "npm run build && node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts src/WebhookTrigger.test.ts"
},
"dependencies": {
"@slack/types": "^2.20.1",
Expand Down
128 changes: 128 additions & 0 deletions packages/webhook/src/WebhookTrigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import nock from 'nock';

import type { CodedError } from './errors';
import { ErrorCode } from './errors';
import { WebhookTrigger } from './WebhookTrigger';

const url = 'https://hooks.slack.com/triggers/FAKETRIGGER';

describe('WebhookTrigger', () => {
afterEach(() => {
nock.cleanAll();
});

describe('constructor()', () => {
it('should build a default webhook trigger given a URL', () => {
const trigger = new WebhookTrigger(url);
assert.ok(trigger instanceof WebhookTrigger);
});

it('should create a default webhook trigger with a default timeout', () => {
const trigger = new WebhookTrigger(url);
// biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion
assert.strictEqual((trigger as any).defaults.timeout, 0);
});

it('should create an axios instance that has the timeout passed by the user', () => {
const givenTimeout = 100;
const trigger = new WebhookTrigger(url, { timeout: givenTimeout });
// biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion
assert.strictEqual((trigger as any).axios.defaults.timeout, givenTimeout);
});
});

describe('send()', () => {
let trigger: WebhookTrigger;
beforeEach(() => {
trigger = new WebhookTrigger(url);
});

describe('when making a successful call', () => {
let scope: nock.Scope;
beforeEach(() => {
scope = nock('https://hooks.slack.com')
.post(/triggers/)
.reply(200, { ok: true });
});

it('should return results in a Promise', async () => {
const result = await trigger.send({ key: 'value' });
assert.strictEqual(result.ok, true);
assert.deepStrictEqual(result.body, { ok: true });
scope.done();
});
});

describe('when the response contains additional data', () => {
let scope: nock.Scope;
beforeEach(() => {
scope = nock('https://hooks.slack.com')
.post(/triggers/)
.reply(200, { ok: true, workflow_run_id: 'WFR123' });
});

it('should include the full response body', async () => {
const result = await trigger.send({ input: 'data' });
assert.strictEqual(result.ok, true);
assert.strictEqual(result.body.workflow_run_id, 'WFR123');
scope.done();
});
});

describe('when the call fails', () => {
let statusCode: number;
let scope: nock.Scope;
beforeEach(() => {
statusCode = 500;
scope = nock('https://hooks.slack.com')
.post(/triggers/)
.reply(statusCode);
});

it('should return a Promise which rejects on error', async () => {
try {
await trigger.send({ key: 'value' });
assert.fail('expected rejection');
} catch (error) {
assert.ok(error);
assert.ok(error instanceof Error);
assert.match((error as Error).message, new RegExp(String(statusCode)));
scope.done();
}
});

it('should fail with RequestError when the API request fails', async () => {
const trigger = new WebhookTrigger('https://localhost:8999/api/');
try {
await trigger.send({ key: 'value' });
assert.fail('expected rejection');
} catch (error) {
assert.ok(error instanceof Error);
assert.strictEqual((error as CodedError).code, ErrorCode.RequestError);
}
});
});

describe('User-Agent header', () => {
it('should send the User-Agent header with every request', async () => {
const scope = nock('https://hooks.slack.com', {
reqheaders: {
'User-Agent': (value) => {
return /@slack:webhook/.test(value);
},
},
})
.post(/triggers/)
.reply(200, { ok: true });
try {
const trigger = new WebhookTrigger(url);
await trigger.send({ key: 'value' });
} finally {
scope.done();
}
});
});
});
});
106 changes: 106 additions & 0 deletions packages/webhook/src/WebhookTrigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Agent } from 'node:http';

import axios, { type AxiosInstance, type AxiosResponse } from 'axios';

import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors';
import { getUserAgent } from './instrument';

/**
* A client for Slack's Workflow Builder webhook triggers
* @see {@link https://slack.com/help/articles/360041352714-Build-a-workflow--Create-a-workflow-that-starts-outside-of-Slack}
*/
export class WebhookTrigger {
/**
* The webhook trigger URL
*/
private url: string;

/**
* Default arguments for sending to this webhook trigger
*/
private defaults: WebhookTriggerDefaultArguments;

/**
* Axios HTTP client instance used by this client
*/
private axios: AxiosInstance;

public constructor(
url: string,
defaults: WebhookTriggerDefaultArguments = {
timeout: 0,
},
) {
if (url === undefined) {
throw new Error('Webhook trigger URL is required');
}

this.url = url;
this.defaults = defaults;

this.axios = axios.create({
baseURL: url,
httpAgent: defaults.agent,
httpsAgent: defaults.agent,
maxRedirects: 0,
proxy: false,
timeout: defaults.timeout,
headers: {
'Content-Type': 'application/json',
'User-Agent': getUserAgent(),
},
});

this.defaults.agent = undefined;
}

/**
* Send a payload to the webhook trigger
* @param payload - arbitrary key-value data to send to the trigger
*/
public async send(payload: WebhookTriggerSendArguments): Promise<WebhookTriggerResult> {
try {
const response = await this.axios.post(this.url, payload);
return this.buildResult(response);
// biome-ignore lint/suspicious/noExplicitAny: errors can be anything
} catch (error: any) {
if (error.response !== undefined) {
throw httpErrorWithOriginal(error);
}
if (error.request !== undefined) {
throw requestErrorWithOriginal(error);
}
throw error;
}
}

/**
* Processes an HTTP response into a WebhookTriggerResult.
*/
private buildResult(response: AxiosResponse): WebhookTriggerResult {
const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
return {
ok: body.ok ?? true,
body,
};
}
}

/*
* Exported types
*/

export interface WebhookTriggerDefaultArguments {
agent?: Agent;
timeout?: number;
}

export interface WebhookTriggerSendArguments {
[key: string]: string;
}

export interface WebhookTriggerResult {
ok: boolean;
// biome-ignore lint/suspicious/noExplicitAny: webhook trigger responses are untyped
body: Record<string, any>;
}
7 changes: 7 additions & 0 deletions packages/webhook/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ export {
IncomingWebhookResult,
IncomingWebhookSendArguments,
} from './IncomingWebhook';

export {
WebhookTrigger,
WebhookTriggerDefaultArguments,
WebhookTriggerResult,
WebhookTriggerSendArguments,
} from './WebhookTrigger';
Loading