diff --git a/src/__tests__/fixtures.ts b/src/__tests__/fixtures.ts index 968129a..05035da 100644 --- a/src/__tests__/fixtures.ts +++ b/src/__tests__/fixtures.ts @@ -1,5 +1,6 @@ import { Test } from '@nestjs/testing'; -import { SlackConfig, SlackModule } from '../slack.module'; +import { SlackModule } from '../slack.module'; +import { SlackConfig } from '../types'; export const createApp = (options?: Partial) => { return Test.createTestingModule({ diff --git a/src/__tests__/module.test.ts b/src/__tests__/module.test.ts new file mode 100644 index 0000000..895ebf4 --- /dev/null +++ b/src/__tests__/module.test.ts @@ -0,0 +1,78 @@ +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 { SlackConfig } from '../types'; + +interface Config { + slackWebhookUrl: string; +} + +describe('slack.module', () => { + const baseUrl = 'http://example.com'; + + it('should construct with useFactory', async () => { + const app = await Test.createTestingModule({ + imports: [ + SlackModule.forRootAsync({ + useFactory: () => { + return { + type: 'webhook', + webhookOptions: { url: `${baseUrl}/webhook` }, + }; + }, + }), + ], + }).compile(); + const service = app.get(SlackService); + + const scope = nock(baseUrl, { encodedQueryParams: true }) + .post('/webhook', { + text: 'hello-world', + }) + .reply(200, 'ok'); + + await service.postMessage({ text: 'hello-world' }); + + scope.done(); + }); + + it('should construct with useClass', async () => { + @Injectable() + class ConfigClass { + slackConfigModuleOptions(): SlackConfig { + return { + type: 'webhook', + webhookOptions: { url: `${baseUrl}/webhook` }, + }; + } + } + + @Module({ + exports: [ConfigClass], + providers: [ConfigClass], + }) + class TestModule {} + + const app = await Test.createTestingModule({ + imports: [ + SlackModule.forRootAsync({ + imports: [TestModule], + useClass: ConfigClass, + }), + ], + }).compile(); + const service = app.get(SlackService); + + const scope = nock(baseUrl, { encodedQueryParams: true }) + .post('/webhook', { + text: 'hello-world', + }) + .reply(200, 'ok'); + + await service.postMessage({ text: 'hello-world' }); + + scope.done(); + }); +}); diff --git a/src/constants.ts b/src/constants.ts index 8408497..e4ea53a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,5 @@ 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'; diff --git a/src/slack.module.ts b/src/slack.module.ts index a8ad1d0..f5580db 100644 --- a/src/slack.module.ts +++ b/src/slack.module.ts @@ -1,126 +1,106 @@ import { DynamicModule, Module, Provider } from '@nestjs/common'; -import type { WebClientOptions } from '@slack/web-api'; import { GOOGLE_LOGGING, SLACK_MODULE_OPTIONS, + SLACK_MODULE_USER_OPTIONS, SLACK_WEBHOOK_URL, SLACK_WEB_CLIENT, } from './constants'; import { SlackService } from './slack.service'; +import { + SlackAsyncConfig, + SlackConfig, + SlackConfigFactory, + SlackSyncConfig, +} from './types'; import { invariant } from './utils'; -export interface SlackApiOptions { - /** - * You'll need a token to authenticate with Slack Web API - * Read more: https://api.slack.com/tutorials/tracks/getting-a-token - */ - token: string; - - clientOptions?: WebClientOptions; -} - -export interface SlackWebhookOptions { - /** - * Incoming Webhooks are a simple way to post messages from apps into Slack. - * Creating an Incoming Webhook gives you a unique URL to which you send a - * JSON payload with the message text and some options. - * - * Read more: https://api.slack.com/messaging/webhooks - */ - url: string; -} - -export type SlackRequestType = 'api' | 'webhook' | 'stdout' | 'google'; - -export interface SlackConfig { - /** - * This argument refers to how you want to send requests - * to Slack. - * - * `api` is the default option, it utilizes `@slack/web-api`, which also - * requires setting `apiOptions`. Setting `stdout` and `google` makes - * this module send requests directly to stdout as a JSON-string. This is - * useful where you're consuming logs and want to forward them to Slack. - * `google` provides a JSON structure as Google Logging wants. - * - * **Note**: We suggest using a distributed model where logs are consumed - * when logging to Slack in production; it's easier to dump something to - * to a logger than calling the Slack Web API. - * - * @default stdout - */ - type: SlackRequestType; - - /** - * This option is used when channel isn't defined - * when sending a request. - */ - defaultChannel?: string; - - /** - * These configuration options are only required when type is set to - * `api`. - */ - apiOptions?: SlackApiOptions; - - /** - * These configuration options are only required when type is set to - * `api`. - */ - webhookOptions?: SlackWebhookOptions; - - /** - * Setting this changes which function is used to stdout. - * - * Only used for types `stdout` - */ - output?: (out: unknown) => void; - - // If true, registers `SlackModule` as a global module. - isGlobal?: boolean; -} - @Module({ providers: [SlackService], exports: [SlackService], }) export class SlackModule { - static forRoot(opts?: Partial): DynamicModule { - const options: SlackConfig = { - type: 'stdout', - output: /* istanbul ignore next */ (out: unknown) => - process.stdout.write(`${JSON.stringify(out)}\n`), - ...opts, - }; - + static forRoot(opts: Partial = {}): DynamicModule { const providers = [ { - provide: SLACK_MODULE_OPTIONS, - useValue: options, + provide: SLACK_MODULE_USER_OPTIONS, + useValue: opts, }, - this.createAsyncGoogleLogger(options), - this.createAsyncWebClient(options), - this.createAsyncWebhook(options), + this.createAsyncConfig(), + this.createAsyncGoogleLogger(), + this.createAsyncWebClient(), + this.createAsyncWebhook(), ]; return { - global: options.isGlobal, + global: opts.isGlobal, module: SlackModule, providers, exports: providers, }; } - private static createAsyncGoogleLogger({ type }: SlackConfig): Provider { - if (type !== 'google') { + static forRootAsync(opts: SlackAsyncConfig): DynamicModule { + const providers = [ + this.createAsyncOptionsProvider(opts), + this.createAsyncConfig(), + this.createAsyncGoogleLogger(), + this.createAsyncWebClient(), + this.createAsyncWebhook(), + ]; + return { + global: opts.isGlobal, + module: SlackModule, + imports: opts.imports, + providers, + exports: providers, + }; + } + + private static createAsyncOptionsProvider(opts: SlackAsyncConfig): Provider { + if (opts.useFactory) { return { - provide: GOOGLE_LOGGING, - useValue: null, + provide: SLACK_MODULE_USER_OPTIONS, + useFactory: opts.useFactory, + inject: opts.inject || [], }; } + invariant(opts.useClass); + return { + provide: SLACK_MODULE_USER_OPTIONS, + useFactory: async ( + optionsFactory: SlackConfigFactory, + ): Promise => optionsFactory.slackConfigModuleOptions(), + inject: [opts.useClass], + }; + } + + private static createAsyncConfig(): Provider { + return { + provide: SLACK_MODULE_OPTIONS, + inject: [SLACK_MODULE_USER_OPTIONS], + useFactory: async (opts: SlackConfig) => { + return { + type: 'stdout', + output: /* istanbul ignore next */ (out: unknown) => + process.stdout.write(`${JSON.stringify(out)}\n`), + ...opts, + }; + }, + }; + } + private static createAsyncGoogleLogger(): Provider { return { provide: GOOGLE_LOGGING, - useFactory: async () => { + inject: [SLACK_MODULE_OPTIONS], + useFactory: async (opts: SlackConfig) => { + if (opts.type !== 'google') { + return { + provide: GOOGLE_LOGGING, + useValue: null, + }; + } + const { Logging } = await import('@google-cloud/logging'); const logging = new Logging(); return logging.logSync('slack'); @@ -128,18 +108,18 @@ export class SlackModule { }; } - private static createAsyncWebClient({ type }: SlackConfig): Provider { - if (type !== 'api') { - return { - provide: SLACK_WEB_CLIENT, - useValue: null, - }; - } - + private static createAsyncWebClient(): Provider { return { provide: SLACK_WEB_CLIENT, inject: [SLACK_MODULE_OPTIONS], useFactory: async (opts: SlackConfig) => { + if (opts.type !== 'api') { + return { + provide: SLACK_WEB_CLIENT, + useValue: null, + }; + } + invariant( opts.apiOptions, 'You must provide `apiOptions` when using the api type.', @@ -154,18 +134,18 @@ export class SlackModule { }; } - private static createAsyncWebhook({ type }: SlackConfig): Provider { - if (type !== 'webhook') { - return { - provide: SLACK_WEBHOOK_URL, - useValue: null, - }; - } - + 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, + }; + } + invariant( opts.webhookOptions, 'You must provide `webhookOptions` when using the webhook type.', diff --git a/src/slack.service.ts b/src/slack.service.ts index 8dc5671..6cd776c 100644 --- a/src/slack.service.ts +++ b/src/slack.service.ts @@ -9,7 +9,7 @@ import { SLACK_WEBHOOK_URL, SLACK_WEB_CLIENT, } from './constants'; -import type { SlackConfig, SlackRequestType } from './slack.module'; +import type { SlackConfig, SlackRequestType } from './types'; import { invariant } from './utils'; export type SlackMessageOptions = Partial; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..90c477f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,89 @@ +import { ModuleMetadata, Type } from '@nestjs/common'; +import { WebClientOptions } from '@slack/web-api'; + +export interface SlackApiOptions { + /** + * You'll need a token to authenticate with Slack Web API + * Read more: https://api.slack.com/tutorials/tracks/getting-a-token + */ + token: string; + + clientOptions?: WebClientOptions; +} + +export interface SlackWebhookOptions { + /** + * Incoming Webhooks are a simple way to post messages from apps into Slack. + * Creating an Incoming Webhook gives you a unique URL to which you send a + * JSON payload with the message text and some options. + * + * Read more: https://api.slack.com/messaging/webhooks + */ + url: string; +} + +export type SlackRequestType = 'api' | 'webhook' | 'stdout' | 'google'; + +export interface SlackConfig { + /** + * This argument refers to how you want to send requests + * to Slack. + * + * `api` is the default option, it utilizes `@slack/web-api`, which also + * requires setting `apiOptions`. Setting `stdout` and `google` makes + * this module send requests directly to stdout as a JSON-string. This is + * useful where you're consuming logs and want to forward them to Slack. + * `google` provides a JSON structure as Google Logging wants. + * + * **Note**: We suggest using a distributed model where logs are consumed + * when logging to Slack in production; it's easier to dump something to + * to a logger than calling the Slack Web API. + * + * @default stdout + */ + type: SlackRequestType; + + /** + * This option is used when channel isn't defined + * when sending a request. + */ + defaultChannel?: string; + + /** + * These configuration options are only required when type is set to + * `api`. + */ + apiOptions?: SlackApiOptions; + + /** + * These configuration options are only required when type is set to + * `api`. + */ + webhookOptions?: SlackWebhookOptions; + + /** + * Setting this changes which function is used to stdout. + * + * Only used for types `stdout` + */ + output?: (out: unknown) => void; +} + +export interface SlackSyncConfig extends SlackConfig { + // If true, registers `SlackModule` as a global module. + isGlobal?: boolean; +} + +export interface SlackAsyncConfig extends Pick { + useClass?: Type; + useFactory?: (...args: any[]) => Promise | SlackConfig; + inject?: any[]; + useExisting?: Type; + + // If true, registers `SlackModule` as a global module. + isGlobal?: boolean; +} + +export interface SlackConfigFactory { + slackConfigModuleOptions(): Promise | SlackConfig; +}