Skip to content
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

feat: Add multiple webhooks feature #217

Merged
merged 2 commits into from
Jul 13, 2022
Merged
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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ and reusable Slack code declaratively and ready for production.

[nestjs]: https://github.com/nestjs/nest

This documentation is for v2 of this library. If you are looking for v1 documentation,
please check the [v1] branch.
This documentation is for v2 of this library. If you are looking for v1
documentation, please check the [v1] branch.

[v1]: https://github.com/bjerkio/nestjs-slack/tree/v1

Expand Down Expand Up @@ -79,6 +79,32 @@ SlackModule.forRoot({
}),
```

You can also add multiple webhooks, like this:

```typescript
SlackModule.forRoot({
type: 'webhook',
channels: [
{
name: 'dev',
url: '<a webhook url>',
},
{
name: 'customers',
url: '<a webhook url>',
},
],
}),
```

You can also get type assertions if you add a Typescript definition like this:

```typescript
declare module 'nestjs-slack' {
type Channels = 'dev' | 'customers';
}
```

### Example

You can easily inject `SlackService` to be used in your services, controllers,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@slack/web-api": "^6",
"axios": "^0.27.2",
"slack-block-builder": "^2",
"node-fetch": "^2.6.7",
"ts-invariant": "^0.10.3"
},
"peerDependencies": {
Expand Down
5 changes: 0 additions & 5 deletions src/__tests__/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Test } from '@nestjs/testing';
import {
GOOGLE_LOGGING,
SLACK_MODULE_OPTIONS,
SLACK_WEBHOOK_URL,
SLACK_WEB_CLIENT,
} from '../constants';
import { SlackService } from '../slack.service';
Expand All @@ -25,10 +24,6 @@ describe('google logging', () => {
provide: SLACK_WEB_CLIENT,
useValue: null,
},
{
provide: SLACK_WEBHOOK_URL,
useValue: null,
},
{
provide: GOOGLE_LOGGING,
useValue: {
Expand Down
6 changes: 1 addition & 5 deletions src/__tests__/module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@ import { Test } from '@nestjs/testing';
import { SlackModule } from '../slack.module';
import { SlackService } from '../slack.service';
import * as nock from 'nock';
import { Inject, Injectable, Module } from '@nestjs/common';
import { Injectable, Module } from '@nestjs/common';
import { SlackConfig } from '../types';

interface Config {
slackWebhookUrl: string;
}

describe('slack.module', () => {
const baseUrl = 'http://example.com';

Expand Down
106 changes: 76 additions & 30 deletions src/__tests__/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,91 @@ import * as nock from 'nock';

describe('webhook', () => {
let service: SlackService;
let output: any;

const baseUrl = 'https://example.com';

beforeEach(async () => {
output = jest.fn();
const app = await createApp({
type: 'webhook',
url: `${baseUrl}/webhook`,
describe('simple webhook', () => {
it('must send requests to API', async () => {
const app = await createApp({
type: 'webhook',
url: `${baseUrl}/simple-webhook`,
});
service = app.get<SlackService>(SlackService);

const scope = nock(baseUrl, { encodedQueryParams: true })
.post('/simple-webhook', {
text: 'hello-world',
})
.reply(200, 'ok');

await service.postMessage({ text: 'hello-world' });

scope.done();
});
service = app.get<SlackService>(SlackService);
});

it('must send requests to API', async () => {
// nock.recorder.rec();
const scope = nock(baseUrl, { encodedQueryParams: true })
.post('/webhook', {
text: 'hello-world',
})
.reply(200, 'ok');
it.skip('Should throw when request fails', async () => {
expect.assertions(1);
const scope = nock(baseUrl, { encodedQueryParams: true })
.post('/failing-simple-webhook', {
text: 'hello-world',
})
.reply(500, 'fail');
const app = await createApp({
type: 'webhook',
url: `${baseUrl}/failing-simple-webhook`,
});
service = app.get<SlackService>(SlackService);

await service.postMessage({ text: 'hello-world' });
await service
.postMessage({ text: 'hello-world' })
.catch(error => expect(error).toMatchInlineSnapshot());

scope.done();
scope.done();
});
});

it('Should throw when request fails', () => {
nock(baseUrl, { encodedQueryParams: true })
.post('/webhook', {
describe('multiple webhooks', () => {
beforeEach(async () => {
const app = await createApp({
type: 'webhook',
channels: [
{
name: 'test-channel',
url: `${baseUrl}/test-webhook`,
},
{
name: 'failing-test-channel',
url: `${baseUrl}/failing-webhook`,
},
],
});
service = app.get<SlackService>(SlackService);
});

it('must send requests to API', async () => {
const scope = nock(baseUrl, { encodedQueryParams: true })
.post('/test-webhook', {
text: 'hello-world',
})
.reply(200, 'ok');

await service.postMessage({
text: 'hello-world',
})
.reply(500, 'fail');

return service
.postMessage({ text: 'hello-world' })
.catch(error =>
expect(error).toMatchInlineSnapshot(
`[Error: Could not send request to Slack Webhook: fail]`,
),
);
channel: 'test-channel',
});

scope.done();
});

it('should throw when channel does not exist', () => {
expect.assertions(1);
service
.postMessage({ text: 'hello-world', channel: 'not-a-channel' })
.catch(e =>
expect(e).toMatchInlineSnapshot(
`[Error: The channel not-a-channel does not exist. You must add this in the channels option.]`,
),
);
});
});
});
1 change: 0 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export const SLACK_MODULE_OPTIONS = 'SlackModuleOptions';
export const SLACK_MODULE_USER_OPTIONS = 'SlackModuleUserOptions';
export const SLACK_WEB_CLIENT = 'SlackWebClient';
export const SLACK_WEBHOOK_URL = 'SlackWebhookUrl';
export const GOOGLE_LOGGING = 'SlackGoogleLogging';
20 changes: 0 additions & 20 deletions src/slack.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
GOOGLE_LOGGING,
SLACK_MODULE_OPTIONS,
SLACK_MODULE_USER_OPTIONS,
SLACK_WEBHOOK_URL,
SLACK_WEB_CLIENT,
} from './constants';
import { SlackService } from './slack.service';
Expand All @@ -29,7 +28,6 @@ export class SlackModule {
this.createAsyncConfig(),
this.createAsyncGoogleLogger(),
this.createAsyncWebClient(),
this.createAsyncWebhook(),
];
return {
global: opts.isGlobal,
Expand All @@ -45,7 +43,6 @@ export class SlackModule {
this.createAsyncConfig(),
this.createAsyncGoogleLogger(),
this.createAsyncWebClient(),
this.createAsyncWebhook(),
];
return {
global: opts.isGlobal,
Expand Down Expand Up @@ -125,21 +122,4 @@ export class SlackModule {
},
};
}

private static createAsyncWebhook(): Provider {
return {
provide: SLACK_WEBHOOK_URL,
inject: [SLACK_MODULE_OPTIONS],
useFactory: async (opts: SlackConfig) => {
if (opts.type !== 'webhook') {
return {
provide: SLACK_WEBHOOK_URL,
useValue: null,
};
}

return opts.url;
},
};
}
}
52 changes: 39 additions & 13 deletions src/slack.service.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
import type { LogSync } from '@google-cloud/logging';
import { Inject, Injectable } from '@nestjs/common';
import type { ChatPostMessageArguments, WebClient } from '@slack/web-api';
import axios, { AxiosError } from 'axios';
import fetch from 'node-fetch';
import type { SlackBlockDto } from 'slack-block-builder';
import invariant from 'ts-invariant';
import {
GOOGLE_LOGGING,
SLACK_MODULE_OPTIONS,
SLACK_WEBHOOK_URL,
SLACK_WEB_CLIENT,
} from './constants';
import { Channels } from './plugin';
import type { SlackConfig } from './types';

export type SlackMessageOptions = Partial<ChatPostMessageArguments>;
export type SlackMessageOptions<C = Channels> = Partial<
ChatPostMessageArguments & { channel: C }
>;

@Injectable()
export class SlackService {
export class SlackService<C = Channels> {
constructor(
@Inject(SLACK_MODULE_OPTIONS) private readonly options: SlackConfig,
@Inject(SLACK_WEB_CLIENT) private readonly client: WebClient | null,
@Inject(GOOGLE_LOGGING) private readonly log: LogSync | null,
@Inject(SLACK_WEBHOOK_URL) private readonly webhookUrl: string | null,
) {}

/**
Expand All @@ -42,7 +43,7 @@ export class SlackService {
*/
sendText(
text: string,
opts?: Omit<SlackMessageOptions, 'text' | 'blocks'>,
opts?: Omit<SlackMessageOptions<C>, 'text' | 'blocks'>,
): Promise<void> {
return this.postMessage({ text, ...opts });
}
Expand Down Expand Up @@ -81,7 +82,7 @@ export class SlackService {
*/
sendBlocks(
blocks: Readonly<SlackBlockDto>[],
opts?: Omit<SlackMessageOptions, 'blocks'>,
opts?: Omit<SlackMessageOptions<C>, 'blocks'>,
): Promise<void> {
return this.postMessage({
blocks,
Expand All @@ -105,7 +106,7 @@ export class SlackService {
* @param blocks
* @param opts
*/
async postMessage(req: SlackMessageOptions): Promise<void> {
async postMessage(req: SlackMessageOptions<C>): Promise<void> {
const requestTypes = {
api: async () => this.runApiRequest(req),
webhook: async () => this.runWebhookRequest(req),
Expand Down Expand Up @@ -133,14 +134,39 @@ export class SlackService {

private async runWebhookRequest(req: SlackMessageOptions) {
invariant(this.options.type === 'webhook');
invariant(this.webhookUrl, 'expected webhook url to exist');

await axios.post(this.webhookUrl, req).catch((error: AxiosError) => {
invariant(error.response);
if ('channels' in this.options) {
const {
channel: userDefinedChannel = this.options.defaultChannel,
...slackRequest
} = req;

throw new Error(
`Could not send request to Slack Webhook: ${error.response.data}`,
invariant(
userDefinedChannel,
'neither channel nor defaultChannel was applied',
);

const channel = this.options.channels.find(
c => c.name === userDefinedChannel,
);

if (!channel) {
throw new Error(
`The channel ${userDefinedChannel} does not exist. You must add this in the channels option.`,
);
}

return fetch(channel.url, {
method: 'POST',
body: JSON.stringify(slackRequest),
});
}

invariant('url' in this.options);

return fetch(this.options.url, {
method: 'POST',
body: JSON.stringify(req),
});
}

Expand Down
Loading