diff --git a/integration/module-utils/src/integration.module-definition.ts b/integration/module-utils/src/integration.module-definition.ts new file mode 100644 index 00000000000..89d7a984241 --- /dev/null +++ b/integration/module-utils/src/integration.module-definition.ts @@ -0,0 +1,17 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; +import { IntegrationModuleOptions } from './interfaces/integration-module-options.interface'; + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = + new ConfigurableModuleBuilder() + .setClassMethodName('forRoot') + .setFactoryMethodName('construct') + .setExtras( + { + isGlobal: true, + }, + (definition, extras) => ({ + ...definition, + global: extras.isGlobal, + }), + ) + .build(); diff --git a/integration/module-utils/src/integration.module.ts b/integration/module-utils/src/integration.module.ts new file mode 100644 index 00000000000..3b994f4bd4f --- /dev/null +++ b/integration/module-utils/src/integration.module.ts @@ -0,0 +1,16 @@ +import { Inject, Module } from '@nestjs/common'; +import { + ConfigurableModuleClass, + MODULE_OPTIONS_TOKEN, +} from './integration.module-definition'; +import { IntegrationModuleOptions } from './interfaces/integration-module-options.interface'; + +@Module({}) +export class IntegrationModule extends ConfigurableModuleClass { + constructor( + @Inject(MODULE_OPTIONS_TOKEN) + public readonly options: IntegrationModuleOptions, + ) { + super(); + } +} diff --git a/integration/module-utils/src/interfaces/integration-module-options.interface.ts b/integration/module-utils/src/interfaces/integration-module-options.interface.ts new file mode 100644 index 00000000000..802e02076fa --- /dev/null +++ b/integration/module-utils/src/interfaces/integration-module-options.interface.ts @@ -0,0 +1,4 @@ +export interface IntegrationModuleOptions { + url: string; + secure?: boolean; +} diff --git a/integration/module-utils/test/integration-module.spec.ts b/integration/module-utils/test/integration-module.spec.ts new file mode 100644 index 00000000000..ea22a71c571 --- /dev/null +++ b/integration/module-utils/test/integration-module.spec.ts @@ -0,0 +1,47 @@ +import { Test } from '@nestjs/testing'; +import { expect } from 'chai'; +import { IntegrationModule } from '../src/integration.module'; + +describe('Module utils (ConfigurableModuleBuilder)', () => { + it('should auto-generate "forRoot" method', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + IntegrationModule.forRoot({ + isGlobal: true, + url: 'test_url', + secure: false, + }), + ], + }).compile(); + + const integrationModule = moduleRef.get(IntegrationModule); + + expect(integrationModule.options).to.deep.equal({ + url: 'test_url', + secure: false, + }); + }); + + it('should auto-generate "forRootAsync" method', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + IntegrationModule.forRootAsync({ + isGlobal: true, + useFactory: () => { + return { + url: 'test_url', + secure: false, + }; + }, + }), + ], + }).compile(); + + const integrationModule = moduleRef.get(IntegrationModule); + + expect(integrationModule.options).to.deep.equal({ + url: 'test_url', + secure: false, + }); + }); +}); diff --git a/integration/module-utils/tsconfig.json b/integration/module-utils/tsconfig.json new file mode 100644 index 00000000000..c6354c56487 --- /dev/null +++ b/integration/module-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "noImplicitAny": false, + "removeComments": true, + "noLib": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es6", + "sourceMap": true, + "allowJs": true, + "outDir": "./dist" + }, + "include": [ + "src/**/*", + "e2e/**/*" + ], + "exclude": [ + "node_modules", + ] +} \ No newline at end of file diff --git a/packages/common/cache/cache.constants.ts b/packages/common/cache/cache.constants.ts index 0ecf5c94e9f..799547a7b27 100644 --- a/packages/common/cache/cache.constants.ts +++ b/packages/common/cache/cache.constants.ts @@ -1,4 +1,3 @@ export const CACHE_MANAGER = 'CACHE_MANAGER'; -export const CACHE_MODULE_OPTIONS = 'CACHE_MODULE_OPTIONS'; export const CACHE_KEY_METADATA = 'cache_module:cache_key'; export const CACHE_TTL_METADATA = 'cache_module:cache_ttl'; diff --git a/packages/common/cache/cache.module-definition.ts b/packages/common/cache/cache.module-definition.ts new file mode 100644 index 00000000000..25d74e98638 --- /dev/null +++ b/packages/common/cache/cache.module-definition.ts @@ -0,0 +1,12 @@ +import { ConfigurableModuleBuilder } from '../module-utils'; +import { + CacheModuleOptions, + CacheOptionsFactory, +} from './interfaces/cache-module.interface'; + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = + new ConfigurableModuleBuilder({ + moduleName: 'Cache', + }) + .setFactoryMethodName('createCacheOptions' as keyof CacheOptionsFactory) + .build(); diff --git a/packages/common/cache/cache.module.ts b/packages/common/cache/cache.module.ts index 4e2c63d48eb..992f98f0379 100644 --- a/packages/common/cache/cache.module.ts +++ b/packages/common/cache/cache.module.ts @@ -1,11 +1,11 @@ import { Module } from '../decorators'; -import { DynamicModule, Provider } from '../interfaces'; -import { CACHE_MANAGER, CACHE_MODULE_OPTIONS } from './cache.constants'; +import { DynamicModule } from '../interfaces'; +import { CACHE_MANAGER } from './cache.constants'; +import { ConfigurableModuleClass } from './cache.module-definition'; import { createCacheManager } from './cache.providers'; import { CacheModuleAsyncOptions, CacheModuleOptions, - CacheOptionsFactory, } from './interfaces/cache-module.interface'; /** @@ -19,7 +19,7 @@ import { providers: [createCacheManager()], exports: [CACHE_MANAGER], }) -export class CacheModule { +export class CacheModule extends ConfigurableModuleClass { /** * Configure the cache manager statically. * @@ -31,9 +31,8 @@ export class CacheModule { options: CacheModuleOptions = {} as any, ): DynamicModule { return { - module: CacheModule, global: options.isGlobal, - providers: [{ provide: CACHE_MODULE_OPTIONS, useValue: options }], + ...super.register(options), }; } @@ -48,47 +47,11 @@ export class CacheModule { static registerAsync< StoreConfig extends Record = Record, >(options: CacheModuleAsyncOptions): DynamicModule { + const moduleDefinition = super.registerAsync(options); return { - module: CacheModule, global: options.isGlobal, - imports: options.imports, - providers: [ - ...this.createAsyncProviders(options), - ...(options.extraProviders || []), - ], - }; - } - - private static createAsyncProviders>( - options: CacheModuleAsyncOptions, - ): Provider[] { - if (options.useExisting || options.useFactory) { - return [this.createAsyncOptionsProvider(options)]; - } - return [ - this.createAsyncOptionsProvider(options), - { - provide: options.useClass, - useClass: options.useClass, - }, - ]; - } - - private static createAsyncOptionsProvider< - StoreConfig extends Record, - >(options: CacheModuleAsyncOptions): Provider { - if (options.useFactory) { - return { - provide: CACHE_MODULE_OPTIONS, - useFactory: options.useFactory, - inject: options.inject || [], - }; - } - return { - provide: CACHE_MODULE_OPTIONS, - useFactory: async (optionsFactory: CacheOptionsFactory) => - optionsFactory.createCacheOptions(), - inject: [options.useExisting || options.useClass], + ...moduleDefinition, + providers: moduleDefinition.providers.concat(options.extraProviders), }; } } diff --git a/packages/common/cache/cache.providers.ts b/packages/common/cache/cache.providers.ts index 2e6df398d2c..4aaacf0f541 100644 --- a/packages/common/cache/cache.providers.ts +++ b/packages/common/cache/cache.providers.ts @@ -1,6 +1,7 @@ import { Provider } from '../interfaces'; import { loadPackage } from '../utils/load-package.util'; -import { CACHE_MANAGER, CACHE_MODULE_OPTIONS } from './cache.constants'; +import { CACHE_MANAGER } from './cache.constants'; +import { MODULE_OPTIONS_TOKEN } from './cache.module-definition'; import { defaultCacheOptions } from './default-options'; import { CacheManagerOptions } from './interfaces/cache-manager.interface'; @@ -31,6 +32,6 @@ export function createCacheManager(): Provider { ...(options || {}), }); }, - inject: [CACHE_MODULE_OPTIONS], + inject: [MODULE_OPTIONS_TOKEN], }; } diff --git a/packages/common/cache/interfaces/cache-module.interface.ts b/packages/common/cache/interfaces/cache-module.interface.ts index 5beb769c892..a5fd220cc37 100644 --- a/packages/common/cache/interfaces/cache-module.interface.ts +++ b/packages/common/cache/interfaces/cache-module.interface.ts @@ -1,4 +1,5 @@ -import { ModuleMetadata, Provider, Type } from '../../interfaces'; +import { Provider, Type } from '../../interfaces'; +import { ConfigurableModuleAsyncOptions } from '../../module-utils'; import { CacheManagerOptions } from './cache-manager.interface'; export type CacheModuleOptions< @@ -39,7 +40,10 @@ export interface CacheOptionsFactory< */ export interface CacheModuleAsyncOptions< StoreConfig extends Record = Record, -> extends Pick { +> extends ConfigurableModuleAsyncOptions< + CacheModuleOptions, + keyof CacheOptionsFactory + > { /** * Injection token resolving to an existing provider. The provider must implement * the `CacheOptionsFactory` interface. @@ -63,6 +67,9 @@ export interface CacheModuleAsyncOptions< * Dependencies that a Factory may inject. */ inject?: any[]; + /** + * Extra providers to be registered within a scope of this module. + */ extraProviders?: Provider[]; /** * If "true', register `CacheModule` as a global module. diff --git a/packages/common/http/http.constants.ts b/packages/common/http/http.constants.ts index 21f6d849933..d69880b1887 100644 --- a/packages/common/http/http.constants.ts +++ b/packages/common/http/http.constants.ts @@ -1,3 +1 @@ export const AXIOS_INSTANCE_TOKEN = 'AXIOS_INSTANCE_TOKEN'; -export const HTTP_MODULE_ID = 'HTTP_MODULE_ID'; -export const HTTP_MODULE_OPTIONS = 'HTTP_MODULE_OPTIONS'; diff --git a/packages/common/http/http.module-definition.ts b/packages/common/http/http.module-definition.ts new file mode 100644 index 00000000000..708d8f701db --- /dev/null +++ b/packages/common/http/http.module-definition.ts @@ -0,0 +1,23 @@ +import { Provider } from '../interfaces'; +import { ConfigurableModuleBuilder } from '../module-utils'; +import { HttpModuleOptions } from './interfaces'; + +export const { + ConfigurableModuleClass, + MODULE_OPTIONS_TOKEN, + ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'Http', + alwaysTransient: true, +}) + .setFactoryMethodName('createHttpOptions') + .setExtras<{ extraProviders?: Provider[] }>( + { + extraProviders: [], + }, + (definition, extras) => ({ + ...definition, + providers: definition.providers.concat(extras?.extraProviders), + }), + ) + .build(); diff --git a/packages/common/http/http.module.ts b/packages/common/http/http.module.ts index cc036767384..a2e46b46475 100644 --- a/packages/common/http/http.module.ts +++ b/packages/common/http/http.module.ts @@ -1,18 +1,14 @@ import Axios from 'axios'; import { Module } from '../decorators/modules/module.decorator'; -import { DynamicModule, Provider } from '../interfaces'; -import { randomStringGenerator } from '../utils/random-string-generator.util'; +import { DynamicModule } from '../interfaces'; +import { AXIOS_INSTANCE_TOKEN } from './http.constants'; import { - AXIOS_INSTANCE_TOKEN, - HTTP_MODULE_ID, - HTTP_MODULE_OPTIONS, -} from './http.constants'; + ASYNC_OPTIONS_TYPE, + ConfigurableModuleClass, + MODULE_OPTIONS_TOKEN, +} from './http.module-definition'; import { HttpService } from './http.service'; -import { - HttpModuleAsyncOptions, - HttpModuleOptions, - HttpModuleOptionsFactory, -} from './interfaces'; +import { HttpModuleOptions } from './interfaces'; /** * @deprecated "HttpModule" (from the "@nestjs/common" package) is deprecated and will be removed in the next major release. Please, use the "@nestjs/axios" package instead. @@ -27,73 +23,17 @@ import { ], exports: [HttpService], }) -export class HttpModule { - static register(config: HttpModuleOptions): DynamicModule { - return { - module: HttpModule, - providers: [ - { - provide: AXIOS_INSTANCE_TOKEN, - useValue: Axios.create(config), - }, - { - provide: HTTP_MODULE_ID, - useValue: randomStringGenerator(), - }, - ], - }; - } - - static registerAsync(options: HttpModuleAsyncOptions): DynamicModule { +export class HttpModule extends ConfigurableModuleClass { + static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule { return { - module: HttpModule, - imports: options.imports, + ...super.registerAsync(options), providers: [ - ...this.createAsyncProviders(options), { provide: AXIOS_INSTANCE_TOKEN, useFactory: (config: HttpModuleOptions) => Axios.create(config), - inject: [HTTP_MODULE_OPTIONS], + inject: [MODULE_OPTIONS_TOKEN], }, - { - provide: HTTP_MODULE_ID, - useValue: randomStringGenerator(), - }, - ...(options.extraProviders || []), ], }; } - - private static createAsyncProviders( - options: HttpModuleAsyncOptions, - ): Provider[] { - if (options.useExisting || options.useFactory) { - return [this.createAsyncOptionsProvider(options)]; - } - return [ - this.createAsyncOptionsProvider(options), - { - provide: options.useClass, - useClass: options.useClass, - }, - ]; - } - - private static createAsyncOptionsProvider( - options: HttpModuleAsyncOptions, - ): Provider { - if (options.useFactory) { - return { - provide: HTTP_MODULE_OPTIONS, - useFactory: options.useFactory, - inject: options.inject || [], - }; - } - return { - provide: HTTP_MODULE_OPTIONS, - useFactory: async (optionsFactory: HttpModuleOptionsFactory) => - optionsFactory.createHttpOptions(), - inject: [options.useExisting || options.useClass], - }; - } } diff --git a/packages/common/http/interfaces/http-module.interface.ts b/packages/common/http/interfaces/http-module.interface.ts index 794f9385ca4..02b147d867d 100644 --- a/packages/common/http/interfaces/http-module.interface.ts +++ b/packages/common/http/interfaces/http-module.interface.ts @@ -1,19 +1,3 @@ import { AxiosRequestConfig } from 'axios'; -import { ModuleMetadata, Provider, Type } from '../../interfaces'; export type HttpModuleOptions = AxiosRequestConfig; - -export interface HttpModuleOptionsFactory { - createHttpOptions(): Promise | HttpModuleOptions; -} - -export interface HttpModuleAsyncOptions - extends Pick { - useExisting?: Type; - useClass?: Type; - useFactory?: ( - ...args: any[] - ) => Promise | HttpModuleOptions; - inject?: any[]; - extraProviders?: Provider[]; -} diff --git a/packages/common/index.ts b/packages/common/index.ts index 0fe073784c1..350464899c6 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -61,6 +61,7 @@ export { WsExceptionFilter, WsMessageHandler, } from './interfaces'; +export * from './module-utils'; export * from './pipes'; export * from './serializer'; export * from './services'; diff --git a/packages/common/module-utils/configurable-module.builder.ts b/packages/common/module-utils/configurable-module.builder.ts new file mode 100644 index 00000000000..0e3f1318f20 --- /dev/null +++ b/packages/common/module-utils/configurable-module.builder.ts @@ -0,0 +1,330 @@ +import { DynamicModule, Provider } from '../interfaces'; +import { Logger } from '../services/logger.service'; +import { randomStringGenerator } from '../utils/random-string-generator.util'; +import { + ASYNC_METHOD_SUFFIX, + CONFIGURABLE_MODULE_ID, + DEFAULT_FACTORY_CLASS_METHOD_KEY, + DEFAULT_METHOD_KEY, +} from './constants'; +import { + ConfigurableModuleAsyncOptions, + ConfigurableModuleCls, + ConfigurableModuleOptionsFactory, +} from './interfaces'; +import { ConfigurableModuleHost } from './interfaces/configurable-module-host.interface'; +import { generateOptionsInjectionToken } from './utils/generate-options-injection-token.util'; + +/** + * @publicApi + */ +export interface ConfigurableModuleBuilderOptions { + /** + * Specified what injection token should be used for the module options provider. + * By default, an auto-generated UUID will be used. + */ + optionsInjectionToken?: string | symbol; + /** + * By default, an UUID will be used as a module options provider token. + * Explicitly specifying the "moduleName" will instruct the "ConfigurableModuleBuilder" + * to use a more descriptive provider token. + * + * For example, if `moduleName: "Cache"` then auto-generated provider token will be "CACHE_MODULE_OPTIONS". + */ + moduleName?: string; + /** + * Indicates whether module should always be "transient", meaning, + * every time you call the static method to construct a dynamic module, + * regardless of what arguments you pass in, a new "unique" module will be created. + * + * @default false + */ + alwaysTransient?: boolean; +} + +/** + * Factory that lets you create configurable modules and + * provides a way to reduce the majority of dynamic module boilerplate. + * + * @publicApi + */ +export class ConfigurableModuleBuilder< + ModuleOptions, + StaticMethodKey extends string = typeof DEFAULT_METHOD_KEY, + FactoryClassMethodKey extends string = typeof DEFAULT_FACTORY_CLASS_METHOD_KEY, + ExtraModuleDefinitionOptions = {}, +> { + protected staticMethodKey: StaticMethodKey; + protected factoryClassMethodKey: FactoryClassMethodKey; + protected extras: ExtraModuleDefinitionOptions; + protected transformModuleDefinition: ( + definition: DynamicModule, + extraOptions: ExtraModuleDefinitionOptions, + ) => DynamicModule; + + protected readonly logger = new Logger(ConfigurableModuleBuilder.name); + + constructor( + protected readonly options: ConfigurableModuleBuilderOptions = {}, + parentBuilder?: ConfigurableModuleBuilder, + ) { + if (parentBuilder) { + this.staticMethodKey = parentBuilder.staticMethodKey as StaticMethodKey; + this.factoryClassMethodKey = + parentBuilder.factoryClassMethodKey as FactoryClassMethodKey; + this.transformModuleDefinition = parentBuilder.transformModuleDefinition; + this.extras = parentBuilder.extras as ExtraModuleDefinitionOptions; + } + } + + /** + * Registers the "extras" object (a set of extra options that can be used to modify the dynamic module definition). + * Values you specify within the "extras" object will be used as default values (that can be overriden by module consumers). + * + * This method also applies the so-called "module definition transform function" that takes the auto-generated + * dynamic module object ("DynamicModule") and the actual consumer "extras" object as input parameters. + * The "extras" object consists of values explicitly specified by module consumers and default values. + * + * @example + * ```typescript + * .setExtras<{ isGlobal?: boolean }>({ isGlobal: false }, (definition, extras) => + * ({ ...definition, global: extras.isGlobal }) + * ) + * ``` + */ + setExtras( + extras: ExtraModuleDefinitionOptions, + transformDefinition: ( + definition: DynamicModule, + extras: ExtraModuleDefinitionOptions, + ) => DynamicModule, + ) { + const builder = new ConfigurableModuleBuilder< + ModuleOptions, + StaticMethodKey, + FactoryClassMethodKey, + ExtraModuleDefinitionOptions + >(this.options, this as any); + builder.extras = extras; + builder.transformModuleDefinition = transformDefinition; + return builder; + } + + /** + * Dynamic modules must expose public static methods that let you pass in + * configuration parameters (control the module's behavior from the outside). + * Some frequently used names that you may have seen in other modules are: + * "forRoot", "forFeature", "register", "configure". + * + * This method "setClassMethodName" lets you specify the name of the + * method that will be auto-generated. + * + * @param key name of the method + */ + setClassMethodName(key: StaticMethodKey) { + const builder = new ConfigurableModuleBuilder< + ModuleOptions, + StaticMethodKey, + FactoryClassMethodKey, + ExtraModuleDefinitionOptions + >(this.options, this as any); + builder.staticMethodKey = key; + return builder; + } + + /** + * Asynchronously configured modules (that rely on other modules, i.e. "ConfigModule") + * let you pass the configuration factory class that will be registered and instantiated as a provider. + * This provider then will be used to retrieve the module's configuration. To provide the configuration, + * the corresponding factory method must be implemented. + * + * This method ("setFactoryMethodName") lets you control what method name will have to be + * implemented by the config factory (default is "create"). + * + * @param key name of the method + */ + setFactoryMethodName( + key: FactoryClassMethodKey, + ) { + const builder = new ConfigurableModuleBuilder< + ModuleOptions, + StaticMethodKey, + FactoryClassMethodKey, + ExtraModuleDefinitionOptions + >(this.options, this as any); + builder.factoryClassMethodKey = key; + return builder; + } + + /** + * Returns an object consisting of multiple properties that lets you + * easily construct dynamic configurable modules. See "ConfigurableModuleHost" interface for more details. + */ + build(): ConfigurableModuleHost< + ModuleOptions, + StaticMethodKey, + FactoryClassMethodKey, + ExtraModuleDefinitionOptions + > { + this.staticMethodKey ??= DEFAULT_METHOD_KEY as StaticMethodKey; + this.factoryClassMethodKey ??= + DEFAULT_FACTORY_CLASS_METHOD_KEY as FactoryClassMethodKey; + this.options.optionsInjectionToken ??= this.options.moduleName + ? this.constructInjectionTokenString() + : generateOptionsInjectionToken(); + this.transformModuleDefinition ??= definition => definition; + + return { + ConfigurableModuleClass: + this.createConfigurableModuleCls(), + MODULE_OPTIONS_TOKEN: this.options.optionsInjectionToken, + ASYNC_OPTIONS_TYPE: this.createTypeProxy('ASYNC_OPTIONS_TYPE'), + OPTIONS_TYPE: this.createTypeProxy('OPTIONS_TYPE'), + }; + } + + private constructInjectionTokenString(): string { + const moduleNameInSnakeCase = this.options.moduleName + .trim() + .split(/(?=[A-Z])/) + .join('_') + .toUpperCase(); + return `${moduleNameInSnakeCase}_MODULE_OPTIONS`; + } + + private createConfigurableModuleCls(): ConfigurableModuleCls< + ModuleOptions, + StaticMethodKey, + FactoryClassMethodKey + > { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const asyncMethodKey = this.staticMethodKey + ASYNC_METHOD_SUFFIX; + + class InternalModuleClass { + static [self.staticMethodKey]( + options: ModuleOptions & ExtraModuleDefinitionOptions, + ): DynamicModule { + const providers = [ + { + provide: self.options.optionsInjectionToken, + useValue: this.omitExtras(options, self.extras), + }, + ]; + if (self.options.alwaysTransient) { + providers.push({ + provide: CONFIGURABLE_MODULE_ID, + useValue: randomStringGenerator(), + }); + } + return self.transformModuleDefinition( + { + module: this, + providers, + }, + options, + ); + } + + static [asyncMethodKey]( + options: ConfigurableModuleAsyncOptions & + ExtraModuleDefinitionOptions, + ): DynamicModule { + const providers = this.createAsyncProviders(options); + if (self.options.alwaysTransient) { + providers.push({ + provide: CONFIGURABLE_MODULE_ID, + useValue: randomStringGenerator(), + }); + } + return self.transformModuleDefinition( + { + module: this, + imports: options.imports || [], + providers, + }, + options, + ); + } + + private static omitExtras( + input: ModuleOptions & ExtraModuleDefinitionOptions, + extras: ExtraModuleDefinitionOptions | undefined, + ): ModuleOptions { + if (!extras) { + return input; + } + const moduleOptions = {}; + const extrasKeys = Object.keys(extras); + + Object.keys(input) + .filter(key => !extrasKeys.includes(key)) + .forEach(key => { + moduleOptions[key] = input[key]; + }); + return moduleOptions as ModuleOptions; + } + + private static createAsyncProviders( + options: ConfigurableModuleAsyncOptions, + ): Provider[] { + if (options.useExisting || options.useFactory) { + return [this.createAsyncOptionsProvider(options)]; + } + return [ + this.createAsyncOptionsProvider(options), + { + provide: options.useClass, + useClass: options.useClass, + }, + ]; + } + + private static createAsyncOptionsProvider( + options: ConfigurableModuleAsyncOptions, + ): Provider { + if (options.useFactory) { + return { + provide: self.options.optionsInjectionToken, + useFactory: options.useFactory, + inject: options.inject || [], + }; + } + return { + provide: self.options.optionsInjectionToken, + useFactory: async ( + optionsFactory: ConfigurableModuleOptionsFactory< + ModuleOptions, + FactoryClassMethodKey + >, + ) => + await optionsFactory[ + self.factoryClassMethodKey as keyof typeof optionsFactory + ](), + inject: [options.useExisting || options.useClass], + }; + } + } + return InternalModuleClass as unknown as ConfigurableModuleCls< + ModuleOptions, + StaticMethodKey, + FactoryClassMethodKey + >; + } + + private createTypeProxy( + typeName: 'OPTIONS_TYPE' | 'ASYNC_OPTIONS_TYPE' | 'OptionsFactoryInterface', + ) { + const proxy = new Proxy( + {}, + { + get: () => { + throw new Error( + `"${typeName}" is not supposed to be used as a value.`, + ); + }, + }, + ); + return proxy as any; + } +} diff --git a/packages/common/module-utils/constants.ts b/packages/common/module-utils/constants.ts new file mode 100644 index 00000000000..2d97c0c8cf7 --- /dev/null +++ b/packages/common/module-utils/constants.ts @@ -0,0 +1,5 @@ +export const DEFAULT_METHOD_KEY = 'register'; +export const DEFAULT_FACTORY_CLASS_METHOD_KEY = 'create'; + +export const ASYNC_METHOD_SUFFIX = 'Async'; +export const CONFIGURABLE_MODULE_ID = 'CONFIGURABLE_MODULE_ID'; diff --git a/packages/common/module-utils/index.ts b/packages/common/module-utils/index.ts new file mode 100644 index 00000000000..4393992bd15 --- /dev/null +++ b/packages/common/module-utils/index.ts @@ -0,0 +1,2 @@ +export * from './configurable-module.builder'; +export * from './interfaces'; diff --git a/packages/common/module-utils/interfaces/configurable-module-async-options.interface.ts b/packages/common/module-utils/interfaces/configurable-module-async-options.interface.ts new file mode 100644 index 00000000000..56f7040e45c --- /dev/null +++ b/packages/common/module-utils/interfaces/configurable-module-async-options.interface.ts @@ -0,0 +1,51 @@ +import { FactoryProvider, ModuleMetadata, Type } from '../../interfaces'; +import { DEFAULT_FACTORY_CLASS_METHOD_KEY } from '../constants'; + +/** + * Interface that must be implemented by the module options factory class. + * Method key varies depending on the "FactoryClassMethodKey" type argument. + * + * @publicApi + */ +export type ConfigurableModuleOptionsFactory< + ModuleOptions, + FactoryClassMethodKey extends string, +> = Record< + `${FactoryClassMethodKey}`, + () => Promise | ModuleOptions +>; + +/** + * Interface that represents the module async options object + * Factory method name varies depending on the "FactoryClassMethodKey" type argument. + * + * @publicApi + */ +export interface ConfigurableModuleAsyncOptions< + ModuleOptions, + FactoryClassMethodKey extends string = typeof DEFAULT_FACTORY_CLASS_METHOD_KEY, +> extends Pick { + /** + * Injection token resolving to an existing provider. The provider must implement + * the corresponding interface. + */ + useExisting?: Type< + ConfigurableModuleOptionsFactory + >; + /** + * Injection token resolving to a class that will be instantiated as a provider. + * The class must implement the corresponding interface. + */ + useClass?: Type< + ConfigurableModuleOptionsFactory + >; + /** + * Function returning options (or a Promise resolving to options) to configure the + * cache module. + */ + useFactory?: (...args: unknown[]) => Promise | ModuleOptions; + /** + * Dependencies that a Factory may inject. + */ + inject?: FactoryProvider['inject']; +} diff --git a/packages/common/module-utils/interfaces/configurable-module-cls.interface.ts b/packages/common/module-utils/interfaces/configurable-module-cls.interface.ts new file mode 100644 index 00000000000..e01c706bef9 --- /dev/null +++ b/packages/common/module-utils/interfaces/configurable-module-cls.interface.ts @@ -0,0 +1,35 @@ +import { DynamicModule } from '../../interfaces'; +import { + DEFAULT_FACTORY_CLASS_METHOD_KEY, + DEFAULT_METHOD_KEY, +} from '../constants'; +import { ConfigurableModuleAsyncOptions } from './configurable-module-async-options.interface'; + +/** + * Class that represents a blueprint/prototype for a configurable Nest module. + * This class provides static methods for constructing dynamic modules. Their names + * can be controlled through the "MethodKey" type argument. + * + * @publicApi + */ +export type ConfigurableModuleCls< + ModuleOptions, + MethodKey extends string = typeof DEFAULT_METHOD_KEY, + FactoryClassMethodKey extends string = typeof DEFAULT_FACTORY_CLASS_METHOD_KEY, + ExtraModuleDefinitionOptions = {}, +> = { + new (): any; +} & Record< + `${MethodKey}`, + (options: ModuleOptions & ExtraModuleDefinitionOptions) => DynamicModule +> & + Record< + `${MethodKey}Async`, + ( + options: ConfigurableModuleAsyncOptions< + ModuleOptions, + FactoryClassMethodKey + > & + ExtraModuleDefinitionOptions, + ) => DynamicModule + >; diff --git a/packages/common/module-utils/interfaces/configurable-module-host.interface.ts b/packages/common/module-utils/interfaces/configurable-module-host.interface.ts new file mode 100644 index 00000000000..6a652ecce2f --- /dev/null +++ b/packages/common/module-utils/interfaces/configurable-module-host.interface.ts @@ -0,0 +1,77 @@ +import { ConfigurableModuleAsyncOptions } from './configurable-module-async-options.interface'; +import { ConfigurableModuleCls } from './configurable-module-cls.interface'; + +/** + * Configurable module host. See properties for more details + * + * @publicApi + */ +export interface ConfigurableModuleHost< + ModuleOptions = Record, + MethodKey extends string = string, + FactoryClassMethodKey extends string = string, + ExtraModuleDefinitionOptions = {}, +> { + /** + * Class that represents a blueprint/prototype for a configurable Nest module. + * This class provides static methods for constructing dynamic modules. Their names + * can be controlled through the "MethodKey" type argument. + * + * Your module class should inherit from this class to make the static methods available. + * + * @example + * ```typescript + * @Module({}) + * class IntegrationModule extends ConfigurableModuleCls { + * // ... + * } + * ``` + */ + ConfigurableModuleClass: ConfigurableModuleCls< + ModuleOptions, + MethodKey, + FactoryClassMethodKey, + ExtraModuleDefinitionOptions + >; + /** + * Module options provider token. Can be used to inject the "options object" to + * providers registered within the host module. + */ + MODULE_OPTIONS_TOKEN: string | symbol; + /** + * Can be used to auto-infer the compound "async module options" type. + * Note: this property is not supposed to be used as a value. + * + * @example + * ```typescript + * @Module({}) + * class IntegrationModule extends ConfigurableModuleCls { + * static module = initializer(IntegrationModule); + * + * static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule { + * return super.registerAsync(options); + * } + * ``` + */ + ASYNC_OPTIONS_TYPE: ConfigurableModuleAsyncOptions< + ModuleOptions, + FactoryClassMethodKey + > & + ExtraModuleDefinitionOptions; + /** + * Can be used to auto-infer the compound "module options" type (options interface + extra module definition options). + * Note: this property is not supposed to be used as a value. + * + * @example + * ```typescript + * @Module({}) + * class IntegrationModule extends ConfigurableModuleCls { + * static module = initializer(IntegrationModule); + * + * static register(options: typeof OPTIONS_TYPE): DynamicModule { + * return super.register(options); + * } + * ``` + */ + OPTIONS_TYPE: ModuleOptions & ExtraModuleDefinitionOptions; +} diff --git a/packages/common/module-utils/interfaces/index.ts b/packages/common/module-utils/interfaces/index.ts new file mode 100644 index 00000000000..35c52307638 --- /dev/null +++ b/packages/common/module-utils/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './configurable-module-async-options.interface'; +export * from './configurable-module-cls.interface'; +export * from './configurable-module-host.interface'; diff --git a/packages/common/module-utils/utils/generate-options-injection-token.util.ts b/packages/common/module-utils/utils/generate-options-injection-token.util.ts new file mode 100644 index 00000000000..259a6afa33e --- /dev/null +++ b/packages/common/module-utils/utils/generate-options-injection-token.util.ts @@ -0,0 +1,6 @@ +import { randomStringGenerator } from '../../utils/random-string-generator.util'; + +export function generateOptionsInjectionToken() { + const hash = randomStringGenerator(); + return `CONFIGURABLE_MODULE_OPTIONS[${hash}]`; +} diff --git a/packages/common/test/module-utils/configurable-module.builder.spec.ts b/packages/common/test/module-utils/configurable-module.builder.spec.ts new file mode 100644 index 00000000000..3d36232718a --- /dev/null +++ b/packages/common/test/module-utils/configurable-module.builder.spec.ts @@ -0,0 +1,112 @@ +import { expect } from 'chai'; +import { Provider } from '../../interfaces'; +import { ConfigurableModuleBuilder } from '../../module-utils'; + +describe('ConfigurableModuleBuilder', () => { + describe('setExtras', () => { + it('should apply module definition transformer function and return typed builder', () => { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder() + .setExtras( + { isGlobal: false }, + (definition, extras: { isGlobal: boolean }) => ({ + ...definition, + global: extras.isGlobal, + }), + ) + .build(); + + expect( + ConfigurableModuleClass.register({ + // No type error + isGlobal: true, + }), + ).to.deep.include({ + global: true, + }); + }); + }); + describe('setClassMethodName', () => { + it('should set static class method name and return typed builder', () => { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder() + .setClassMethodName('forRoot') + .build(); + + expect(ConfigurableModuleClass.forRoot).to.not.be.undefined; + expect(ConfigurableModuleClass.forRootAsync).to.not.be.undefined; + expect((ConfigurableModuleClass as any).register).to.be.undefined; + }); + }); + describe('setFactoryMethodName', () => { + it('should set configuration factory class method name and return typed builder', () => { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder() + .setFactoryMethodName('createOptions') + .build(); + + expect( + ConfigurableModuleClass.registerAsync({ + useClass: class { + // No type error + createOptions() {} + }, + }), + ).to.not.be.undefined; + }); + }); + describe('build', () => { + it('should return a fully typed "ConfigurableModuleClass"', () => { + type ExtraConfig = { isGlobal?: boolean; extraProviders: Provider[] }; + + const { + ConfigurableModuleClass, + OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE, + MODULE_OPTIONS_TOKEN, + } = new ConfigurableModuleBuilder({ + moduleName: 'RandomTest', + alwaysTransient: true, + }) + .setFactoryMethodName('createOptions') + .setClassMethodName('forFeature') + .setExtras( + { isGlobal: false, extraProviders: [] }, + (definition, extras) => ({ + ...definition, + global: extras.isGlobal, + providers: definition.providers?.concat(extras.extraProviders), + }), + ) + .build(); + + const definition = ConfigurableModuleClass.forFeatureAsync({ + useFactory: () => {}, + isGlobal: true, + extraProviders: ['test' as any], + }); + + expect(definition.global).to.equal(true); + expect(definition.providers).to.have.length(3); + expect(definition.providers).to.deep.contain('test'); + expect(MODULE_OPTIONS_TOKEN).to.equal('RANDOM_TEST_MODULE_OPTIONS'); + expect((definition.providers[0] as any).provide).to.equal( + 'RANDOM_TEST_MODULE_OPTIONS', + ); + + try { + expect(ASYNC_OPTIONS_TYPE.imports).to.equal(undefined); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(err.message).to.equal( + '"ASYNC_OPTIONS_TYPE" is not supposed to be used as a value.', + ); + } + try { + expect(OPTIONS_TYPE.isGlobal).to.equal(undefined); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(err.message).to.equal( + '"OPTIONS_TYPE" is not supposed to be used as a value.', + ); + } + }); + }); +});