Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 112 additions & 71 deletions packages/nest/src/open-feature.module.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,73 +15,105 @@
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
import { ShutdownService } from './shutdown.service';

export const OPEN_FEATURE_INIT_TOKEN = Symbol('OPEN_FEATURE_INIT');

/**

Check warning on line 20 in packages/nest/src/open-feature.module.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @returns declaration

Check warning on line 20 in packages/nest/src/open-feature.module.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @param "options" declaration
* 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<Client>[] = [
{
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<OpenFeatureModuleOptions> {
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<OpenFeatureModuleOptions>()
.setClassMethodName('forRoot')
.setExtras<OpenFeatureModuleExtras>(
{ 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}.
*/
Expand Down Expand Up @@ -126,12 +151,18 @@
*/
handlers?: [ServerProviderEvents, EventHandler][];
/**
* The {@link ContextFactory} for creating an {@link EvaluationContext} from Nest {@link ExecutionContext} information.

Check warning on line 154 in packages/nest/src/open-feature.module.ts

View workflow job for this annotation

GitHub Actions / format-lint

The type 'ExecutionContext' is undefined
* This could be header values of a request or something similar.
* The context is automatically used for all feature flag evaluations during this request.
* @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.
Expand All @@ -145,12 +176,22 @@
* @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[];
}

/**
* Returns an injection token for a (domain scoped) OpenFeature client.
* @param {string} domain The domain of the OpenFeature client.
* @returns {Client} The injection token.

Check warning on line 194 in packages/nest/src/open-feature.module.ts

View workflow job for this annotation

GitHub Actions / format-lint

The type 'Client' is undefined
*/
export function getOpenFeatureClientToken(domain?: string): string {
return domain ? `OpenFeatureClient_${domain}` : 'OpenFeatureClient_default';
Expand Down
1 change: 1 addition & 0 deletions packages/nest/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,6 @@ export const getOpenFeatureDefaultTestModule = () => {
contextFactory: exampleContextFactory,
defaultProvider,
providers,
domains: ['domainScopedClient'],
});
};
1 change: 1 addition & 0 deletions packages/nest/test/open-feature-sdk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ describe('OpenFeature SDK', () => {
defaultProvider,
providers,
useGlobalInterceptor: false,
domains: ['domainScopedClient'],
}),
],
providers: [OpenFeatureTestService],
Expand Down
61 changes: 60 additions & 1 deletion packages/nest/test/open-feature.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<Client>(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();
});
});
Comment thread
imranismail marked this conversation as resolved.
});