diff --git a/README.md b/README.md index 0eafc1e2..47a0ed23 100644 --- a/README.md +++ b/README.md @@ -240,18 +240,20 @@ The injectable `ClsService` provides the following API to manipulate the cls con The `ClsModule.register()` method takes the following `ClsModuleOptions`: -- **_`namespaceName`_: `string`** - The name of the cls namespace. This is the namespace that will be used by the ClsService and ClsMiddleware (most of the time you will not need to touch this setting) -- **_`global:`_ `boolean`** (default _`false`_) - Whether to make the module global, so you do not have to import `ClsModule.forFeature()` in other modules. - **_`middleware:`_ `ClsMiddlewareOptions`** - An object with additional options for the ClsMiddleware, see below + An object with additional options for the `ClsMiddleware`, see below - **_`guard:`_ `ClsGuardOptions`** - An object with additional options for the ClsGuard, see below + An object with additional options for the `ClsGuard`, see below - **_`interceptor:`_ `ClsInterceptorOptions`** - An object with additional options for the ClsInterceptor, see below + An object with additional options for the `ClsInterceptor`, see below +- **_`global:`_ `boolean`** (default _`false`_) + Whether to make the module global, so you do not have to import `ClsModule.forFeature()` in other modules. +- **_`namespaceName`_: `string`** (default _unset_) + The namespace that will be set up. When used, `ClsService` must be injected using the `@InjectCls('name')` decorator. (most of the time you will not need to touch this setting) -> Important: the `middleware`, `guard` and `interceptor` options are _mutually exclusive_ - do not use more than one of them, otherwise the context will get overridden with the one that runs after. +> **Please note**: the `middleware`, `guard` and `interceptor` options are _mutually exclusive_ - do not use more than one of them, otherwise the context will get overridden with the one that runs after. + +`ClsModule.registerAsync()` is also available. You can supply the usual `imports`, `inject` and `useFactory` parameters. All of the `Cls{Middleware,Guard,Interceptor}Options` take the following parameters (either in `ClsModuleOptions` or directly when instantiating them manually): @@ -408,9 +410,7 @@ Use the `ClsGuard` or `ClsInterceptor` to set up context with any other platform > Warning: Namespace support is currently experimental and has no tests. While the API is mostly stable now, it can still change any time. -The default CLS namespace that the `ClsService` provides should be enough for most application, but should you need it, this package provides a way to use multiple CLS namespaces in order to be fully compatible with `cls-hooked`. - -> **Note**: Since cls-hooked was ditched in version 1.2, it is no longer necessary to strive for compatibility with it. Still, the namespace support was there and there's no reason to remove it. +The default CLS namespace that the `ClsService` provides should be enough for most application, but should you need it, this package provides a way to use multiple CLS namespaces simultaneously. To use custom namespace provider, use `ClsModule.forFeature('my-namespace')`. @@ -426,6 +426,8 @@ export class HelloModule {} This creates a namespaces `ClsService` provider that you can inject using `@InjectCls` ```ts +// hello.service.ts + @Injectable() class HelloService { constructor( @@ -434,14 +436,11 @@ class HelloService { ) {} sayHello() { - return this.myCls.get('hi'); + return this.myCls.run('hi'); } } -``` - -> **Note**: `@InjectCls('x')` is equivalent to `@Inject(getNamespaceToken('x'))`. If you don't pass an argument to `@InjectCls()`, the default ClsService will be injected and is equivalent to omitting the decorator altogether. -```ts +// hello.controller.ts @Injectable() export class HelloController { constructor( @@ -452,7 +451,7 @@ export class HelloController { @Get('/hello') hello2() { - // seting up cls context manually + // setting up cls context manually return this.myCls.run(() => { this.myCls.set('hi', 'Hello'); return this.helloService.sayHello(); @@ -460,3 +459,5 @@ export class HelloController { } } ``` + +> **Note**: `@InjectCls('x')` is equivalent to `@Inject(getNamespaceToken('x'))`. If you don't pass an argument to `@InjectCls()`, the default ClsService will be injected and is equivalent to omitting the decorator altogether. diff --git a/src/lib/cls-service-manager.ts b/src/lib/cls-service-manager.ts index 879d9c5b..47fc9d55 100644 --- a/src/lib/cls-service-manager.ts +++ b/src/lib/cls-service-manager.ts @@ -7,7 +7,10 @@ export const getClsServiceToken = (namespace: string) => `ClsService-${namespace}`; export class ClsServiceManager { - private static namespaces: Record> = {}; + private static namespaces: Record< + string, + AsyncLocalStorage & { name?: string } + > = {}; private static clsServices: Map = new Map([ @@ -20,11 +23,12 @@ export class ClsServiceManager { private static resolveNamespace(name: string) { if (!this.namespaces[name]) { this.namespaces[name] = new AsyncLocalStorage(); + this.namespaces[name].name = name; } return this.namespaces[name]; } - static addClsService(name: string) { + static addClsService(name: string = CLS_DEFAULT_NAMESPACE) { const service = new ClsService(this.resolveNamespace(name)); this.clsServices.set( getClsServiceToken(name), diff --git a/src/lib/cls.constants.ts b/src/lib/cls.constants.ts index 19f66f05..114a2352 100644 --- a/src/lib/cls.constants.ts +++ b/src/lib/cls.constants.ts @@ -2,6 +2,7 @@ export const CLS_REQ = 'CLS_REQUEST'; export const CLS_RES = 'CLS_RESPONSE'; export const CLS_ID = 'CLS_ID'; export const CLS_DEFAULT_NAMESPACE = 'CLS_DEFAULT_NAMESPACE'; +export const CLS_MODULE_OPTIONS = 'ClsModuleOptions'; export const CLS_MIDDLEWARE_OPTIONS = 'ClsMiddlewareOptions'; export const CLS_GUARD_OPTIONS = 'ClsGuardOptions'; export const CLS_INTERCEPTOR_OPTIONS = 'ClsInterceptorOptions'; diff --git a/src/lib/cls.interfaces.ts b/src/lib/cls.interfaces.ts index c78a4294..645ac949 100644 --- a/src/lib/cls.interfaces.ts +++ b/src/lib/cls.interfaces.ts @@ -1,35 +1,57 @@ -import { ExecutionContext } from '@nestjs/common'; +import { ExecutionContext, ModuleMetadata } from '@nestjs/common'; import { CLS_DEFAULT_NAMESPACE } from './cls.constants'; import { ClsService } from './cls.service'; export class ClsModuleOptions { - /** - * The name of the cls namespace. This is the namespace - * that will be used by the ClsService and ClsMiddleware/Interc - * (most of the time you will not need to touch this setting) - */ - namespaceName? = CLS_DEFAULT_NAMESPACE; - /** * whether to make the module global, so you don't need - * to import `ClsModule` in other modules + * to import ClsModule.forFeature()` in other modules */ global? = false; /** - * Cls middleware options + * An object with additional options for the `ClsMiddleware` */ middleware?: ClsMiddlewareOptions = null; /** - * Cls guard options + * An object with additional options for the `ClsGuard` */ guard?: ClsGuardOptions = null; /** - * Cls interceptor options + * An object with additional options for the `ClsInterceptor` */ interceptor?: ClsInterceptorOptions = null; + + /** + * The namespace that will be set up. When used, `ClsService` + * must be injected using the `@InjectCls('name')` decorator. + * (most of the time you will not need to touch this setting) + */ + namespaceName? = CLS_DEFAULT_NAMESPACE; +} + +export type ClsModuleFactoryOptions = Omit< + ClsModuleOptions, + 'global' | 'namespaceName' +>; +export interface ClsModuleAsyncOptions extends Pick { + inject?: any[]; + useFactory?: ( + ...args: any[] + ) => Promise | ClsModuleFactoryOptions; + /** + * whether to make the module global, so you don't need + * to import `ClsModule.forFeature()` in other modules + */ + global?: boolean; + /** + * The namespace that will be set up. When used, `ClsService` + * must be injected using the `@InjectCls('name')` decorator. + * (most of the time you will not need to touch this setting) + */ + namespaceName?: string; } export class ClsMiddlewareOptions { diff --git a/src/lib/cls.module.ts b/src/lib/cls.module.ts index 43b022c7..b044b915 100644 --- a/src/lib/cls.module.ts +++ b/src/lib/cls.module.ts @@ -1,8 +1,10 @@ import { + CanActivate, DynamicModule, Logger, MiddlewareConsumer, Module, + NestInterceptor, NestModule, Provider, } from '@nestjs/common'; @@ -12,6 +14,7 @@ import { HttpAdapterHost, ModuleRef, } from '@nestjs/core'; +import { CLS_MODULE_OPTIONS } from '..'; import { ClsServiceManager, getClsServiceToken } from './cls-service-manager'; import { CLS_GUARD_OPTIONS, @@ -24,6 +27,7 @@ import { ClsGuardOptions, ClsInterceptorOptions, ClsMiddlewareOptions, + ClsModuleAsyncOptions, ClsModuleOptions, } from './cls.interfaces'; @@ -49,7 +53,7 @@ export class ClsModule implements NestModule { // we are running configure, so we mount the middleware options = this.moduleRef.get(CLS_MIDDLEWARE_OPTIONS); } catch (e) { - // we are running static import, so do not mount it + // we are running forFeature import, so do not mount it return; } @@ -78,58 +82,136 @@ export class ClsModule implements NestModule { }; } - static register(options?: ClsModuleOptions): DynamicModule { - options = { ...new ClsModuleOptions(), ...options }; - ClsServiceManager.addClsService(options.namespaceName); + private static clsMiddlewareOptionsFactory( + options: ClsModuleOptions, + ): ClsMiddlewareOptions { const clsMiddlewareOptions = { ...new ClsMiddlewareOptions(), ...options.middleware, namespaceName: options.namespaceName, }; + return clsMiddlewareOptions; + } + + private static clsGuardOptionsFactory( + options: ClsModuleOptions, + ): ClsGuardOptions { const clsGuardOptions = { ...new ClsGuardOptions(), ...options.guard, namespaceName: options.namespaceName, }; + return clsGuardOptions; + } + + private static clsInterceptorOptionsFactory( + options: ClsModuleOptions, + ): ClsInterceptorOptions { const clsInterceptorOptions = { ...new ClsInterceptorOptions(), ...options.interceptor, namespaceName: options.namespaceName, }; + return clsInterceptorOptions; + } + + private static clsGuardFactory(options: ClsGuardOptions): CanActivate { + if (options.mount) { + ClsModule.logger.debug('ClsGuard will be automatically mounted'); + return new ClsGuard(options); + } + return { + canActivate: () => true, + }; + } + + private static clsInterceptorFactory( + options: ClsInterceptorOptions, + ): NestInterceptor { + if (options.mount) { + ClsModule.logger.debug( + 'ClsInterceptor will be automatically mounted', + ); + return new ClsInterceptor(options); + } + return { + intercept: (_, next) => next.handle(), + }; + } + + private static getProviders(options: { namespaceName?: string }) { + ClsServiceManager.addClsService(options.namespaceName); const providers: Provider[] = [ ...ClsServiceManager.getClsServicesAsProviders(), { provide: CLS_MIDDLEWARE_OPTIONS, - useValue: clsMiddlewareOptions, + inject: [CLS_MODULE_OPTIONS], + useFactory: this.clsMiddlewareOptionsFactory, }, { provide: CLS_GUARD_OPTIONS, - useValue: clsGuardOptions, + inject: [CLS_MODULE_OPTIONS], + useFactory: this.clsGuardOptionsFactory, }, { provide: CLS_INTERCEPTOR_OPTIONS, - useValue: clsInterceptorOptions, + inject: [CLS_MODULE_OPTIONS], + useFactory: this.clsInterceptorOptionsFactory, }, ]; - const enhancerArr = []; - if (clsGuardOptions.mount) { - enhancerArr.push({ + const enhancerArr: Provider[] = [ + { provide: APP_GUARD, - useClass: ClsGuard, - }); - } - if (clsInterceptorOptions.mount) { - enhancerArr.push({ + inject: [CLS_GUARD_OPTIONS], + useFactory: this.clsGuardFactory, + }, + { provide: APP_INTERCEPTOR, - useClass: ClsInterceptor, - }); - } + inject: [CLS_INTERCEPTOR_OPTIONS], + useFactory: this.clsInterceptorFactory, + }, + ]; return { - module: ClsModule, providers: providers.concat(...enhancerArr), exports: providers, + }; + } + + static register(options?: ClsModuleOptions): DynamicModule { + options = { ...new ClsModuleOptions(), ...options }; + const { providers, exports } = this.getProviders(options); + + return { + module: ClsModule, + providers: [ + { + provide: CLS_MODULE_OPTIONS, + useValue: options, + }, + ...providers, + ], + exports, global: options.global, }; } + + static registerAsync(asyncOptions: ClsModuleAsyncOptions): DynamicModule { + const { providers, exports } = this.getProviders(asyncOptions); + + return { + module: ClsModule, + imports: asyncOptions.imports, + providers: [ + { + provide: CLS_MODULE_OPTIONS, + inject: asyncOptions.inject, + useFactory: asyncOptions.useFactory, + }, + ...providers, + ], + exports, + global: asyncOptions.global, + }; + } } diff --git a/test/rest/main-express.ts b/test/rest/main-express.ts index 9e4128d1..5b17887a 100644 --- a/test/rest/main-express.ts +++ b/test/rest/main-express.ts @@ -5,6 +5,11 @@ import { TestHttpController, TestHttpService } from './http.app'; @Module({ imports: [ + // ClsModule.registerAsync({ + // useFactory: () => ({ + // middleware: { mount: true, generateId: true }, + // }), + // }), ClsModule.register({ middleware: { mount: true, generateId: true }, }), diff --git a/test/rest/register-async.spec.ts b/test/rest/register-async.spec.ts new file mode 100644 index 00000000..1a3b9b64 --- /dev/null +++ b/test/rest/register-async.spec.ts @@ -0,0 +1,53 @@ +import { INestApplication, Module } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ClsModule } from '../../src'; +import { expectOkIdsRest } from './expect-ids-rest'; +import { TestHttpController, TestHttpService } from './http.app'; + +let app: INestApplication; + +const optionsProvider = { + provide: 'OPTIONS', + useValue: { + mount: true, + generateId: true, + idGenerator: async () => Math.random(), + }, +}; + +@Module({ + providers: [optionsProvider], + exports: [optionsProvider], +}) +class OptionsModule {} +describe('Http Express App - Async Configuration', () => { + it.each(['middleware', 'guard', 'interceptor'])( + 'works with for %s', + async (what) => { + @Module({ + imports: [ + ClsModule.registerAsync({ + imports: [OptionsModule], + inject: ['OPTIONS'], + useFactory: (opts) => ({ + [what]: opts, + }), + }), + ], + providers: [TestHttpService], + controllers: [TestHttpController], + }) + class TestAppWithAutoBoundMiddleware {} + + const moduleFixture: TestingModule = await Test.createTestingModule( + { + imports: [TestAppWithAutoBoundMiddleware], + }, + ).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + + return expectOkIdsRest(app); + }, + ); +});