-
Notifications
You must be signed in to change notification settings - Fork 682
feat(webhook): add WebhookTrigger class for Workflow Builder triggers #2615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7ce2c4a
89c7af2
0aab45b
2dcef0f
29ea38e
d6677bf
236d954
7d56341
cd50095
2724cd7
b8992d8
eab95b3
ceba283
eace3ba
b02a78e
e3626f9
7968168
dc3dc2d
3bed3dc
c0f5c7e
5d1a1ed
e6604b6
1684fa8
edd3ecb
5ddeb03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@slack/webhook": minor | ||
| --- | ||
|
|
||
| feat: add `WebhookTrigger` class for Workflow Builder webhook triggers |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { afterEach, beforeEach, describe, it } from 'node:test'; | ||
| import nock from 'nock'; | ||
|
|
||
| import type { CodedError, WebhookTriggerHTTPError } from './errors'; | ||
| import { ErrorCode } from './errors'; | ||
| import { addAppMetadata } from './instrument'; | ||
| 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); | ||
| }); | ||
|
|
||
| it('should throw when the URL is missing or empty', () => { | ||
| // biome-ignore lint/suspicious/noExplicitAny: exercising the runtime guard with invalid input | ||
| assert.throws(() => new WebhookTrigger(undefined as any), /URL is required/); | ||
| assert.throws(() => new WebhookTrigger(''), /URL is required/); | ||
| }); | ||
| }); | ||
|
|
||
| describe('send()', () => { | ||
| let trigger: WebhookTrigger; | ||
| beforeEach(() => { | ||
| trigger = new WebhookTrigger(url); | ||
| }); | ||
|
|
||
| describe('on success', () => { | ||
| it('should return results in a Promise', async () => { | ||
| const scope = nock('https://hooks.slack.com') | ||
| .post(/triggers/, (body) => { | ||
| assert.deepStrictEqual(body, { key: 'value' }); | ||
| return true; | ||
| }) | ||
| .reply(200, { ok: true }); | ||
| const result = await trigger.send({ key: 'value' }); | ||
| assert.deepStrictEqual(result, { ok: true }); | ||
| scope.done(); | ||
| }); | ||
|
|
||
| it('should send an empty body and resolve when called without a payload', async () => { | ||
| const scope = nock('https://hooks.slack.com') | ||
| .post(/triggers/, (body) => { | ||
| assert.deepStrictEqual(body, {}); | ||
| return true; | ||
| }) | ||
| .reply(200, { ok: true }); | ||
| const result = await trigger.send(); | ||
| assert.strictEqual(result.ok, true); | ||
| scope.done(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('on failure', () => { | ||
| it('should reject on an HTTP error status', async () => { | ||
| const statusCode = 500; | ||
| const scope = nock('https://hooks.slack.com') | ||
| .post(/triggers/) | ||
| .reply(statusCode); | ||
| try { | ||
| await trigger.send({ key: 'value' }); | ||
| assert.fail('expected rejection'); | ||
| } catch (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); | ||
| } | ||
| }); | ||
|
|
||
| it('should reject with an HTTPError carrying the response body on a 401', async () => { | ||
| const scope = nock('https://hooks.slack.com') | ||
| .post(/triggers/) | ||
| .reply(401, { ok: false, error: 'invalid_auth' }); | ||
| try { | ||
| await trigger.send({ key: 'value' }); | ||
| assert.fail('expected rejection'); | ||
| } catch (error) { | ||
| const httpError = error as WebhookTriggerHTTPError; | ||
| assert.strictEqual(httpError.code, ErrorCode.HTTPError); | ||
| // biome-ignore lint/suspicious/noExplicitAny: reading the wrapped axios response body | ||
| const response = (httpError.original as any).response; | ||
| assert.strictEqual(response.status, 401); | ||
| assert.deepStrictEqual(response.data, { ok: false, error: 'invalid_auth' }); | ||
| } | ||
| scope.done(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('User-Agent header', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice 💯 should we also add a tests that covers using
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @WilliamBergamin Thanks for calling this out! We're introducing a test in 3bed3dc for this. |
||
| 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(); | ||
| } | ||
| }); | ||
|
|
||
| it('should send app metadata added via addAppMetadata in the User-Agent header', async () => { | ||
| const scope = nock('https://hooks.slack.com', { | ||
| reqheaders: { | ||
| 'User-Agent': (value) => value.includes('my-tool/1.2.3') && /@slack:webhook/.test(value), | ||
| }, | ||
| }) | ||
| .post(/triggers/) | ||
| .reply(200, { ok: true }); | ||
| try { | ||
| addAppMetadata({ name: 'my-tool', version: '1.2.3' }); | ||
| const trigger = new WebhookTrigger(url); | ||
| await trigger.send({ key: 'value' }); | ||
| } finally { | ||
| scope.done(); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import type { Agent } from 'node:http'; | ||
|
|
||
| import axios, { type AxiosInstance } 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Paise for mirroring existing behaviors 💯 |
||
| /** | ||
| * 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) { | ||
| 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 response.data; | ||
| // 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; | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+61
to
+76
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👁️🗨️ note: Our return inlines the response values without more change to mirror implementations of the |
||
|
|
||
| /* | ||
| * Exported types | ||
| */ | ||
|
|
||
| export interface WebhookTriggerDefaultArguments { | ||
| agent?: Agent; | ||
| timeout?: number; | ||
| } | ||
|
|
||
| export interface WebhookTriggerSendArguments { | ||
| [key: string]: string; | ||
| } | ||
|
|
||
| export interface WebhookTriggerResult { | ||
| ok: boolean; | ||
| error?: string; | ||
| } | ||
|
Comment on lines
+91
to
+94
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🪬 note: These values are more strict now too!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks good 💯 If we need to add more fields in the future we can |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,16 +16,19 @@ export enum ErrorCode { | |
| } | ||
|
|
||
| export type IncomingWebhookSendError = IncomingWebhookRequestError | IncomingWebhookHTTPError; | ||
| export type WebhookTriggerSendError = WebhookTriggerRequestError | WebhookTriggerHTTPError; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| export interface IncomingWebhookRequestError extends CodedError { | ||
| code: ErrorCode.RequestError; | ||
| original: Error; | ||
| } | ||
| export type WebhookTriggerRequestError = IncomingWebhookRequestError; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Safe move 🧠 I like it 💯 |
||
|
|
||
| export interface IncomingWebhookHTTPError extends CodedError { | ||
| code: ErrorCode.HTTPError; | ||
| original: Error; | ||
| } | ||
| export type WebhookTriggerHTTPError = IncomingWebhookHTTPError; | ||
|
|
||
| /** | ||
| * Factory for producing a {@link CodedError} from a generic error | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📚 note: I hoped to generalize this package
READMEfor now with added information but think we might revise this ongoing?