diff --git a/packages/nest/src/open-feature.module.ts b/packages/nest/src/open-feature.module.ts index e20065257..61c63e23f 100644 --- a/packages/nest/src/open-feature.module.ts +++ b/packages/nest/src/open-feature.module.ts @@ -1,13 +1,6 @@ +import type { DynamicModule } from '@nestjs/common'; +import { Module, ConfigurableModuleBuilder } from '@nestjs/common'; import type { - DynamicModule, - FactoryProvider as NestFactoryProvider, - ValueProvider, - ClassProvider, - Provider as NestProvider, -} from '@nestjs/common'; -import { Module, ExecutionContext } from '@nestjs/common'; -import type { - Client, Hook, Provider, EvaluationContext, @@ -22,73 +15,105 @@ import { APP_INTERCEPTOR } from '@nestjs/core'; import { EvaluationContextInterceptor } from './evaluation-context-interceptor'; import { ShutdownService } from './shutdown.service'; +export const OPEN_FEATURE_INIT_TOKEN = Symbol('OPEN_FEATURE_INIT'); + /** - * OpenFeatureModule is a NestJS wrapper for OpenFeature Server-SDK. + * Initialize OpenFeature with the provided options. */ -@Module({}) -export class OpenFeatureModule { - static forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule { - OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator()); - - if (options.logger) { - OpenFeature.setLogger(options.logger); - } - - if (options.hooks) { - OpenFeature.addHooks(...options.hooks); - } - - options.handlers?.forEach(([event, handler]) => { - OpenFeature.addHandler(event, handler); - }); - - const clientValueProviders: NestFactoryProvider[] = [ - { - provide: getOpenFeatureClientToken(), - useFactory: () => OpenFeature.getClient(), - }, - ]; - - if (options?.defaultProvider) { - OpenFeature.setProvider(options.defaultProvider); - } - - if (options?.providers) { - Object.entries(options.providers).forEach(([domain, provider]) => { - OpenFeature.setProvider(domain, provider); - clientValueProviders.push({ - provide: getOpenFeatureClientToken(domain), - useFactory: () => OpenFeature.getClient(domain), - }); - }); - } - - const nestProviders: NestProvider[] = [ShutdownService]; - nestProviders.push(...clientValueProviders); - - const contextFactoryProvider: ValueProvider = { - provide: ContextFactoryToken, - useValue: options?.contextFactory, - }; - nestProviders.push(contextFactoryProvider); - - if (useGlobalInterceptor) { - const interceptorProvider: ClassProvider = { - provide: APP_INTERCEPTOR, - useClass: EvaluationContextInterceptor, - }; - nestProviders.push(interceptorProvider); - } - - return { - global: true, - module: OpenFeatureModule, - providers: nestProviders, - exports: [...clientValueProviders, ContextFactoryToken], - }; +async function initializeOpenFeature(options: OpenFeatureModuleOptions): Promise { + OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator()); + + if (options.logger) { + OpenFeature.setLogger(options.logger); + } + + if (options.hooks) { + OpenFeature.addHooks(...options.hooks); } + + options.handlers?.forEach(([event, handler]) => { + OpenFeature.addHandler(event, handler); + }); + + if (options.defaultProvider) { + await OpenFeature.setProviderAndWait(options.defaultProvider); + } + + if (options.providers) { + await Promise.all( + Object.entries(options.providers).map(([domain, provider]) => OpenFeature.setProviderAndWait(domain, provider)), + ); + } + + return options; } +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = + new ConfigurableModuleBuilder() + .setClassMethodName('forRoot') + .setExtras( + { isGlobal: true, useGlobalInterceptor: true, domains: [] }, + (definition, extras) => { + const moduleProviders: DynamicModule['providers'] = [ + ...(definition.providers || []), + ShutdownService, + { + provide: OPEN_FEATURE_INIT_TOKEN, + inject: [MODULE_OPTIONS_TOKEN], + useFactory: initializeOpenFeature, + }, + // Default client + { + provide: getOpenFeatureClientToken(), + inject: [OPEN_FEATURE_INIT_TOKEN], + useFactory: () => OpenFeature.getClient(), + }, + // Context factory + { + provide: ContextFactoryToken, + inject: [OPEN_FEATURE_INIT_TOKEN], + useFactory: (options: OpenFeatureModuleOptions) => options.contextFactory, + }, + ]; + + const moduleExports: DynamicModule['exports'] = [ + ...(definition.exports || []), + ContextFactoryToken, + getOpenFeatureClientToken(), + ]; + + if (extras.useGlobalInterceptor) { + moduleProviders.push({ + provide: APP_INTERCEPTOR, + useClass: EvaluationContextInterceptor, + }); + } + + for (const domain of extras.domains || []) { + moduleProviders.push({ + provide: getOpenFeatureClientToken(domain), + useFactory: () => OpenFeature.getClient(domain), + inject: [OPEN_FEATURE_INIT_TOKEN], + }); + moduleExports.push(getOpenFeatureClientToken(domain)); + } + + return { + ...definition, + global: extras.isGlobal, + providers: moduleProviders, + exports: moduleExports, + }; + }, + ) + .build(); + +/** + * OpenFeatureModule is a NestJS wrapper for OpenFeature Server-SDK. + */ +@Module({}) +export class OpenFeatureModule extends ConfigurableModuleClass {} + /** * Options for the {@link OpenFeatureModule}. */ @@ -132,6 +157,12 @@ export interface OpenFeatureModuleOptions { * @see {@link AsyncLocalStorageTransactionContextPropagator} */ contextFactory?: ContextFactory; +} + +/** + * Extra options available at module definition time + */ +export interface OpenFeatureModuleExtras { /** * If set to false, the global {@link EvaluationContextInterceptor} is disabled. * This means that automatic propagation of the {@link EvaluationContext} created by the {@link this#contextFactory} is not working. @@ -145,6 +176,16 @@ export interface OpenFeatureModuleOptions { * @default true */ useGlobalInterceptor?: boolean; + /** + * Whether the module should be global. + * @default true + */ + isGlobal?: boolean; + /** + * Domains for which to create domain-scoped OpenFeature clients. + * Each domain will get its own injectable client token via {@link getOpenFeatureClientToken}. + */ + domains?: string[]; } /** diff --git a/packages/nest/test/fixtures.ts b/packages/nest/test/fixtures.ts index 352770c7b..8ef8c4c90 100644 --- a/packages/nest/test/fixtures.ts +++ b/packages/nest/test/fixtures.ts @@ -81,5 +81,6 @@ export const getOpenFeatureDefaultTestModule = () => { contextFactory: exampleContextFactory, defaultProvider, providers, + domains: ['domainScopedClient'], }); }; diff --git a/packages/nest/test/open-feature-sdk.spec.ts b/packages/nest/test/open-feature-sdk.spec.ts index fb6d64f5f..ba14a426a 100644 --- a/packages/nest/test/open-feature-sdk.spec.ts +++ b/packages/nest/test/open-feature-sdk.spec.ts @@ -192,6 +192,7 @@ describe('OpenFeature SDK', () => { defaultProvider, providers, useGlobalInterceptor: false, + domains: ['domainScopedClient'], }), ], providers: [OpenFeatureTestService], diff --git a/packages/nest/test/open-feature.module.spec.ts b/packages/nest/test/open-feature.module.spec.ts index fd165488b..f388ed93f 100644 --- a/packages/nest/test/open-feature.module.spec.ts +++ b/packages/nest/test/open-feature.module.spec.ts @@ -2,7 +2,7 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src'; import type { Client } from '@openfeature/server-sdk'; -import { OpenFeature } from '@openfeature/server-sdk'; +import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk'; import { getOpenFeatureDefaultTestModule } from './fixtures'; describe('OpenFeatureModule', () => { @@ -91,4 +91,63 @@ describe('OpenFeatureModule', () => { await moduleWithoutProvidersRef.close(); }); }); + + describe('forRootAsync', () => { + let moduleRef: TestingModule; + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ + OpenFeatureModule.forRootAsync({ + useFactory: () => ({ + defaultProvider: new InMemoryProvider({ + testAsyncFlag: { + defaultVariant: 'default', + variants: { default: 'async-value' }, + disabled: false, + }, + }), + }), + }), + ], + }).compile(); + }); + + afterAll(async () => { + await moduleRef.close(); + }); + + it('should configure module with async options', async () => { + const client = moduleRef.get(getOpenFeatureClientToken()); + expect(client).toBeDefined(); + expect(await client.getStringValue('testAsyncFlag', '')).toEqual('async-value'); + }); + }); + + describe('logger', () => { + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const setLoggerSpy = jest.spyOn(OpenFeature, 'setLogger'); + + afterEach(() => { + setLoggerSpy.mockClear(); + }); + + afterAll(() => { + setLoggerSpy.mockRestore(); + }); + + it('should set the logger on OpenFeature during module initialization', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [OpenFeatureModule.forRoot({ logger: mockLogger })], + }).compile(); + + expect(setLoggerSpy).toHaveBeenCalledWith(mockLogger); + await moduleRef.close(); + }); + }); });