Skip to content

Commit 0be5fc6

Browse files
committed
feat: nestjs async factory
1 parent f3f3685 commit 0be5fc6

File tree

5 files changed

+164
-48
lines changed

5 files changed

+164
-48
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/nest/src/open-feature.module.ts

Lines changed: 104 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
1-
import type {
2-
DynamicModule,
3-
FactoryProvider as NestFactoryProvider,
4-
ValueProvider,
5-
ClassProvider,
6-
Provider as NestProvider,
7-
} from '@nestjs/common';
8-
import { Module, ExecutionContext } from '@nestjs/common';
1+
import type { DynamicModule, Provider as NestProvider } from '@nestjs/common';
2+
import { Module, ExecutionContext, ConfigurableModuleBuilder } from '@nestjs/common';
93
import type {
104
Client,
115
Hook,
@@ -22,69 +16,116 @@ import { APP_INTERCEPTOR } from '@nestjs/core';
2216
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
2317
import { ShutdownService } from './shutdown.service';
2418

19+
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } =
20+
new ConfigurableModuleBuilder<OpenFeatureModuleOptions>()
21+
.setClassMethodName('forRoot')
22+
.setExtras<OpenFeatureModuleExtras>(
23+
{ isGlobal: true, useGlobalInterceptor: true, domains: [] },
24+
(definition, extras) => ({
25+
...definition,
26+
global: extras.isGlobal,
27+
}),
28+
)
29+
.build();
30+
2531
/**
2632
* OpenFeatureModule is a NestJS wrapper for OpenFeature Server-SDK.
2733
*/
2834
@Module({})
29-
export class OpenFeatureModule {
30-
static forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule {
31-
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator());
35+
export class OpenFeatureModule extends ConfigurableModuleClass {
36+
static forRoot(options: typeof OPTIONS_TYPE): DynamicModule {
37+
const baseDynamicModule = super.forRoot(options);
38+
return this.buildModule(baseDynamicModule, options);
39+
}
3240

33-
if (options.logger) {
34-
OpenFeature.setLogger(options.logger);
35-
}
41+
static forRootAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
42+
const baseDynamicModule = super.forRootAsync(options);
43+
return this.buildModule(baseDynamicModule, options);
44+
}
3645

37-
if (options.hooks) {
38-
OpenFeature.addHooks(...options.hooks);
39-
}
46+
private static buildModule(baseDynamicModule: DynamicModule, extras: OpenFeatureModuleExtras = {}): DynamicModule {
47+
const { useGlobalInterceptor = true, domains = [] } = extras;
4048

41-
options.handlers?.forEach(([event, handler]) => {
42-
OpenFeature.addHandler(event, handler);
43-
});
49+
const providers: NestProvider[] = [
50+
...(baseDynamicModule.providers || []),
51+
ShutdownService,
52+
// Initialize OpenFeature when options become available,
53+
{
54+
provide: 'OPEN_FEATURE_INIT',
55+
inject: [MODULE_OPTIONS_TOKEN],
56+
useFactory: async (options: OpenFeatureModuleOptions) => {
57+
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator());
4458

45-
const clientValueProviders: NestFactoryProvider<Client>[] = [
59+
if (options.logger) {
60+
OpenFeature.setLogger(options.logger);
61+
}
62+
63+
if (options.hooks) {
64+
OpenFeature.addHooks(...options.hooks);
65+
}
66+
67+
options.handlers?.forEach(([event, handler]) => {
68+
OpenFeature.addHandler(event, handler);
69+
});
70+
71+
if (options.defaultProvider) {
72+
await OpenFeature.setProviderAndWait(options.defaultProvider);
73+
}
74+
75+
if (options.providers) {
76+
await Promise.all(
77+
Object.entries(options.providers).map(([domain, provider]) =>
78+
OpenFeature.setProviderAndWait(domain, provider),
79+
),
80+
);
81+
}
82+
83+
return options;
84+
},
85+
},
86+
// Default client
4687
{
4788
provide: getOpenFeatureClientToken(),
89+
inject: ['OPEN_FEATURE_INIT'],
4890
useFactory: () => OpenFeature.getClient(),
4991
},
92+
// Context factory
93+
{
94+
provide: ContextFactoryToken,
95+
inject: ['OPEN_FEATURE_INIT'],
96+
useFactory: (options: OpenFeatureModuleOptions) => options.contextFactory,
97+
},
5098
];
5199

52-
if (options?.defaultProvider) {
53-
OpenFeature.setProvider(options.defaultProvider);
54-
}
100+
// Domain-scoped clients from extras
101+
const domainClientTokens = domains.map((domain: string) => {
102+
const token = getOpenFeatureClientToken(domain);
55103

56-
if (options?.providers) {
57-
Object.entries(options.providers).forEach(([domain, provider]) => {
58-
OpenFeature.setProvider(domain, provider);
59-
clientValueProviders.push({
60-
provide: getOpenFeatureClientToken(domain),
61-
useFactory: () => OpenFeature.getClient(domain),
62-
});
104+
providers.push({
105+
provide: token,
106+
useFactory: () => OpenFeature.getClient(domain),
107+
inject: ['OPEN_FEATURE_INIT'],
63108
});
64-
}
65109

66-
const nestProviders: NestProvider[] = [ShutdownService];
67-
nestProviders.push(...clientValueProviders);
68-
69-
const contextFactoryProvider: ValueProvider = {
70-
provide: ContextFactoryToken,
71-
useValue: options?.contextFactory,
72-
};
73-
nestProviders.push(contextFactoryProvider);
110+
return token;
111+
});
74112

75113
if (useGlobalInterceptor) {
76-
const interceptorProvider: ClassProvider = {
114+
providers.push({
77115
provide: APP_INTERCEPTOR,
78116
useClass: EvaluationContextInterceptor,
79-
};
80-
nestProviders.push(interceptorProvider);
117+
});
81118
}
82119

83120
return {
84-
global: true,
85-
module: OpenFeatureModule,
86-
providers: nestProviders,
87-
exports: [...clientValueProviders, ContextFactoryToken],
121+
...baseDynamicModule,
122+
providers,
123+
exports: [
124+
...(baseDynamicModule.exports || []),
125+
getOpenFeatureClientToken(),
126+
ContextFactoryToken,
127+
...domainClientTokens,
128+
],
88129
};
89130
}
90131
}
@@ -132,6 +173,12 @@ export interface OpenFeatureModuleOptions {
132173
* @see {@link AsyncLocalStorageTransactionContextPropagator}
133174
*/
134175
contextFactory?: ContextFactory;
176+
}
177+
178+
/**
179+
* Extra options available at module definition time
180+
*/
181+
export interface OpenFeatureModuleExtras {
135182
/**
136183
* If set to false, the global {@link EvaluationContextInterceptor} is disabled.
137184
* This means that automatic propagation of the {@link EvaluationContext} created by the {@link this#contextFactory} is not working.
@@ -145,6 +192,16 @@ export interface OpenFeatureModuleOptions {
145192
* @default true
146193
*/
147194
useGlobalInterceptor?: boolean;
195+
/**
196+
* Whether the module should be global.
197+
* @default true
198+
*/
199+
isGlobal?: boolean;
200+
/**
201+
* Domains for which to create domain-scoped OpenFeature clients.
202+
* Each domain will get its own injectable client token via {@link getOpenFeatureClientToken}.
203+
*/
204+
domains?: string[];
148205
}
149206

150207
/**

packages/nest/test/fixtures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,6 @@ export const getOpenFeatureDefaultTestModule = () => {
8181
contextFactory: exampleContextFactory,
8282
defaultProvider,
8383
providers,
84+
domains: ['domainScopedClient'],
8485
});
8586
};

packages/nest/test/open-feature-sdk.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ describe('OpenFeature SDK', () => {
192192
defaultProvider,
193193
providers,
194194
useGlobalInterceptor: false,
195+
domains: ['domainScopedClient'],
195196
}),
196197
],
197198
providers: [OpenFeatureTestService],

packages/nest/test/open-feature.module.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,61 @@ describe('OpenFeatureModule', () => {
9191
await moduleWithoutProvidersRef.close();
9292
});
9393
});
94+
95+
describe('forRootAsync', () => {
96+
let moduleRef: TestingModule;
97+
98+
beforeAll(async () => {
99+
moduleRef = await Test.createTestingModule({
100+
imports: [
101+
OpenFeatureModule.forRootAsync({
102+
useFactory: () => ({
103+
defaultProvider: new (require('@openfeature/server-sdk').InMemoryProvider)({
104+
testAsyncFlag: {
105+
defaultVariant: 'default',
106+
variants: { default: 'async-value' },
107+
disabled: false,
108+
},
109+
}),
110+
}),
111+
}),
112+
],
113+
}).compile();
114+
});
115+
116+
afterAll(async () => {
117+
await moduleRef.close();
118+
});
119+
120+
it('should configure module with async options', async () => {
121+
const client = moduleRef.get<Client>(getOpenFeatureClientToken());
122+
expect(client).toBeDefined();
123+
expect(await client.getStringValue('testAsyncFlag', '')).toEqual('async-value');
124+
});
125+
});
126+
127+
describe('logger', () => {
128+
let moduleRef: TestingModule;
129+
const mockLogger = {
130+
error: jest.fn(),
131+
warn: jest.fn(),
132+
info: jest.fn(),
133+
debug: jest.fn(),
134+
};
135+
136+
beforeAll(async () => {
137+
moduleRef = await Test.createTestingModule({
138+
imports: [OpenFeatureModule.forRoot({ logger: mockLogger })],
139+
}).compile();
140+
});
141+
142+
afterAll(async () => {
143+
await moduleRef.close();
144+
});
145+
146+
it('should set logger on OpenFeature', () => {
147+
// Logger is set during module initialization
148+
expect(moduleRef).toBeDefined();
149+
});
150+
});
94151
});

0 commit comments

Comments
 (0)