Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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();
});
});
});