Skip to content

Commit 1e148fd

Browse files
committed
feat: nestjs async factory
Signed-off-by: imranismail <hey@imranismail.dev>
1 parent f3f3685 commit 1e148fd

File tree

5 files changed

+171
-49
lines changed

5 files changed

+171
-49
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: 109 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
1+
import type { DynamicModule, Provider as NestProvider } from '@nestjs/common';
2+
import { Module, ExecutionContext, ConfigurableModuleBuilder } from '@nestjs/common';
13
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';
9-
import type {
10-
Client,
114
Hook,
125
Provider,
136
EvaluationContext,
@@ -22,69 +15,121 @@ import { APP_INTERCEPTOR } from '@nestjs/core';
2215
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
2316
import { ShutdownService } from './shutdown.service';
2417

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

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

37-
if (options.hooks) {
38-
OpenFeature.addHooks(...options.hooks);
39-
}
47+
private static buildModule(
48+
baseDynamicModule: DynamicModule,
49+
options: typeof ASYNC_OPTIONS_TYPE | typeof OPTIONS_TYPE,
50+
): DynamicModule {
51+
const { useGlobalInterceptor, domains } = options;
4052

41-
options.handlers?.forEach(([event, handler]) => {
42-
OpenFeature.addHandler(event, handler);
43-
});
53+
const providers: NestProvider[] = [
54+
...(baseDynamicModule.providers || []),
55+
ShutdownService,
56+
// Initialize OpenFeature when options become available,
57+
{
58+
provide: OPEN_FEATURE_INIT_TOKEN,
59+
inject: [MODULE_OPTIONS_TOKEN],
60+
useFactory: async (options: OpenFeatureModuleOptions) => {
61+
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator());
62+
63+
if (options.logger) {
64+
OpenFeature.setLogger(options.logger);
65+
}
66+
67+
if (options.hooks) {
68+
OpenFeature.addHooks(...options.hooks);
69+
}
70+
71+
options.handlers?.forEach(([event, handler]) => {
72+
OpenFeature.addHandler(event, handler);
73+
});
4474

45-
const clientValueProviders: NestFactoryProvider<Client>[] = [
75+
if (options.defaultProvider) {
76+
await OpenFeature.setProviderAndWait(options.defaultProvider);
77+
}
78+
79+
if (options.providers) {
80+
await Promise.all(
81+
Object.entries(options.providers).map(([domain, provider]) =>
82+
OpenFeature.setProviderAndWait(domain, provider),
83+
),
84+
);
85+
}
86+
87+
return options;
88+
},
89+
},
90+
// Default client
4691
{
4792
provide: getOpenFeatureClientToken(),
93+
inject: [OPEN_FEATURE_INIT_TOKEN],
4894
useFactory: () => OpenFeature.getClient(),
4995
},
96+
// Context factory
97+
{
98+
provide: ContextFactoryToken,
99+
inject: [OPEN_FEATURE_INIT_TOKEN],
100+
useFactory: (options: OpenFeatureModuleOptions) => options.contextFactory,
101+
},
50102
];
51103

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

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-
});
108+
providers.push({
109+
provide: token,
110+
useFactory: () => OpenFeature.getClient(domain),
111+
inject: [OPEN_FEATURE_INIT_TOKEN],
63112
});
64-
}
65-
66-
const nestProviders: NestProvider[] = [ShutdownService];
67-
nestProviders.push(...clientValueProviders);
68113

69-
const contextFactoryProvider: ValueProvider = {
70-
provide: ContextFactoryToken,
71-
useValue: options?.contextFactory,
72-
};
73-
nestProviders.push(contextFactoryProvider);
114+
return token;
115+
});
74116

75117
if (useGlobalInterceptor) {
76-
const interceptorProvider: ClassProvider = {
118+
providers.push({
77119
provide: APP_INTERCEPTOR,
78120
useClass: EvaluationContextInterceptor,
79-
};
80-
nestProviders.push(interceptorProvider);
121+
});
81122
}
82123

83124
return {
84-
global: true,
85-
module: OpenFeatureModule,
86-
providers: nestProviders,
87-
exports: [...clientValueProviders, ContextFactoryToken],
125+
...baseDynamicModule,
126+
providers,
127+
exports: [
128+
...(baseDynamicModule.exports || []),
129+
getOpenFeatureClientToken(),
130+
ContextFactoryToken,
131+
...(domainClientTokens || []),
132+
],
88133
};
89134
}
90135
}
@@ -132,6 +177,12 @@ export interface OpenFeatureModuleOptions {
132177
* @see {@link AsyncLocalStorageTransactionContextPropagator}
133178
*/
134179
contextFactory?: ContextFactory;
180+
}
181+
182+
/**
183+
* Extra options available at module definition time
184+
*/
185+
export interface OpenFeatureModuleExtras {
135186
/**
136187
* If set to false, the global {@link EvaluationContextInterceptor} is disabled.
137188
* This means that automatic propagation of the {@link EvaluationContext} created by the {@link this#contextFactory} is not working.
@@ -145,6 +196,16 @@ export interface OpenFeatureModuleOptions {
145196
* @default true
146197
*/
147198
useGlobalInterceptor?: boolean;
199+
/**
200+
* Whether the module should be global.
201+
* @default true
202+
*/
203+
isGlobal?: boolean;
204+
/**
205+
* Domains for which to create domain-scoped OpenFeature clients.
206+
* Each domain will get its own injectable client token via {@link getOpenFeatureClientToken}.
207+
*/
208+
domains?: string[];
148209
}
149210

150211
/**

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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,63 @@ 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+
const mockLogger = {
129+
error: jest.fn(),
130+
warn: jest.fn(),
131+
info: jest.fn(),
132+
debug: jest.fn(),
133+
};
134+
const setLoggerSpy = jest.spyOn(OpenFeature, 'setLogger');
135+
136+
afterEach(() => {
137+
setLoggerSpy.mockClear();
138+
});
139+
140+
afterAll(() => {
141+
setLoggerSpy.mockRestore();
142+
});
143+
144+
it('should set the logger on OpenFeature during module initialization', async () => {
145+
const moduleRef = await Test.createTestingModule({
146+
imports: [OpenFeatureModule.forRoot({ logger: mockLogger })],
147+
}).compile();
148+
149+
expect(setLoggerSpy).toHaveBeenCalledWith(mockLogger);
150+
await moduleRef.close();
151+
});
152+
});
94153
});

0 commit comments

Comments
 (0)