From 291198544b9b560275f73c3c9a360786e1ee2984 Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Fri, 14 Mar 2025 15:45:40 -0400 Subject: [PATCH 1/9] feat: adds `RequireFlagsEnabled` decorator to allow reusable controller & endpoint access based on boolean flag values Signed-off-by: Kaushal Kapasi --- packages/nest/src/index.ts | 1 + .../src/require-flags-enabled.decorator.ts | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 packages/nest/src/require-flags-enabled.decorator.ts diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index 20514df86..7296307e8 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -2,5 +2,6 @@ export * from './open-feature.module'; export * from './feature.decorator'; export * from './evaluation-context-interceptor'; export * from './context-factory'; +export * from './require-flags-enabled.decorator'; // re-export the server-sdk so consumers can access that API from the nestjs-sdk export * from '@openfeature/server-sdk'; diff --git a/packages/nest/src/require-flags-enabled.decorator.ts b/packages/nest/src/require-flags-enabled.decorator.ts new file mode 100644 index 000000000..525cc563f --- /dev/null +++ b/packages/nest/src/require-flags-enabled.decorator.ts @@ -0,0 +1,87 @@ +import { + applyDecorators, + CallHandler, + ExecutionContext, + HttpException, + mixin, + NestInterceptor, + NotFoundException, + UseInterceptors, +} from '@nestjs/common'; +import { OpenFeature } from '@openfeature/server-sdk'; + +/** + * Options for injecting a feature flag into a route handler. + */ +interface RequireFlagsEnabledProps { + /** + * The key of the feature flag. + * @see {@link Client#getBooleanValue} + */ + flagKeys: string[]; + /** + * The exception to throw if any of the required feature flags are not enabled. + * Defaults to a 404 Not Found exception. + * @see {@link HttpException} + */ + exception?: HttpException; + + /** + * The domain of the OpenFeature client, if a domain scoped client should be used. + * @see {@link OpenFeature#getClient} + */ + domain?: string; +} + +/** + * Returns a domain scoped or the default OpenFeature client with the given context. + * @param {string} domain The domain of the OpenFeature client. + * @returns {Client} The OpenFeature client. + */ +function getClientForEvaluation(domain?: string) { + return domain ? OpenFeature.getClient(domain) : OpenFeature.getClient(); +} + +/** + * Controller or Route permissions handler decorator. + * + * Requires that the given feature flags are enabled for the request to be processed, else throws an exception. + * + * For example: + * ```typescript + * @RequireFlagsEnabled({ + * flagKeys: ['flagName', 'flagName2'], // Required, an array of Boolean feature flag keys + * exception: new ForbiddenException(), // Optional, defaults to a 404 Not Found exception + * domain: 'my-domain', // Optional, defaults to the default OpenFeature client + * }) + * @Get('/') + * public async handleGetRequest() + * ``` + * @param {RequireFlagsEnabledProps} options The options for injecting the feature flag. + * @returns {Decorator} + */ +export const RequireFlagsEnabled = (props: RequireFlagsEnabledProps): ClassDecorator & MethodDecorator => + applyDecorators(UseInterceptors(FlagsEnabledInterceptor(props))); + +const FlagsEnabledInterceptor = (props: RequireFlagsEnabledProps) => { + class FlagsEnabledInterceptor implements NestInterceptor { + constructor() {} + + async intercept(context: ExecutionContext, next: CallHandler) { + const req = context.switchToHttp().getRequest(); + const client = getClientForEvaluation(props.domain); + + for (const flagKey of props.flagKeys) { + const endpointAccessible = await client.getBooleanValue(flagKey, false); + + if (!endpointAccessible) { + throw props.exception || new NotFoundException(`Cannot ${req.method} ${req.url}`); + } + } + + return next.handle(); + } + } + + return mixin(FlagsEnabledInterceptor); +}; From 457e2726fd6b0e1a1052ae0f1dfc598210da87f8 Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Tue, 25 Mar 2025 12:04:53 -0400 Subject: [PATCH 2/9] fix: update imports for types and cleanup js docs on the require flags enabled decorator Signed-off-by: Kaushal Kapasi --- .../nest/src/require-flags-enabled.decorator.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/nest/src/require-flags-enabled.decorator.ts b/packages/nest/src/require-flags-enabled.decorator.ts index 525cc563f..9080d5d0d 100644 --- a/packages/nest/src/require-flags-enabled.decorator.ts +++ b/packages/nest/src/require-flags-enabled.decorator.ts @@ -1,13 +1,6 @@ -import { - applyDecorators, - CallHandler, - ExecutionContext, - HttpException, - mixin, - NestInterceptor, - NotFoundException, - UseInterceptors, -} from '@nestjs/common'; +import type { CallHandler, ExecutionContext, HttpException, NestInterceptor } from '@nestjs/common'; +import { applyDecorators, mixin, NotFoundException, UseInterceptors } from '@nestjs/common'; +import type { Client } from '@openfeature/server-sdk'; import { OpenFeature } from '@openfeature/server-sdk'; /** @@ -57,8 +50,8 @@ function getClientForEvaluation(domain?: string) { * @Get('/') * public async handleGetRequest() * ``` - * @param {RequireFlagsEnabledProps} options The options for injecting the feature flag. - * @returns {Decorator} + * @param {RequireFlagsEnabledProps} props The options for injecting the feature flag. + * @returns {ClassDecorator & MethodDecorator} The decorator that can be used to require Boolean Feature Flags to be enabled for a controller or a specific route. */ export const RequireFlagsEnabled = (props: RequireFlagsEnabledProps): ClassDecorator & MethodDecorator => applyDecorators(UseInterceptors(FlagsEnabledInterceptor(props))); From 60b1fc52606fbf414f282012fe8343d645c93660 Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Tue, 25 Mar 2025 13:59:24 -0400 Subject: [PATCH 3/9] feat: add tests for RequireFlagsEnabled decorator Signed-off-by: Kaushal Kapasi --- packages/nest/test/open-feature-sdk.spec.ts | 80 +++++++++++++++++---- packages/nest/test/test-app.ts | 55 ++++++++++++-- test-harness | 1 - 3 files changed, 119 insertions(+), 17 deletions(-) delete mode 160000 test-harness diff --git a/packages/nest/test/open-feature-sdk.spec.ts b/packages/nest/test/open-feature-sdk.spec.ts index 901f810fd..8d96e9702 100644 --- a/packages/nest/test/open-feature-sdk.spec.ts +++ b/packages/nest/test/open-feature-sdk.spec.ts @@ -2,7 +2,12 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import type { INestApplication } from '@nestjs/common'; import supertest from 'supertest'; -import { OpenFeatureController, OpenFeatureControllerContextScopedController, OpenFeatureTestService } from './test-app'; +import { + OpenFeatureController, + OpenFeatureContextScopedController, + OpenFeatureRequireFlagsEnabledController, + OpenFeatureTestService, +} from './test-app'; import { exampleContextFactory, getOpenFeatureDefaultTestModule } from './fixtures'; import { OpenFeatureModule } from '../src'; import { defaultProvider, providers } from './fixtures'; @@ -14,11 +19,9 @@ describe('OpenFeature SDK', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ - getOpenFeatureDefaultTestModule() - ], + imports: [getOpenFeatureDefaultTestModule()], providers: [OpenFeatureTestService], - controllers: [OpenFeatureController], + controllers: [OpenFeatureController, OpenFeatureRequireFlagsEnabledController], }).compile(); app = moduleRef.createNestApplication(); app = await app.init(); @@ -112,7 +115,7 @@ describe('OpenFeature SDK', () => { }); describe('evaluation context service should', () => { - it('inject the evaluation context from contex factory', async function() { + it('inject the evaluation context from contex factory', async function () { const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation'); await supertest(app.getHttpServer()) .get('/dynamic-context-in-service') @@ -122,26 +125,62 @@ describe('OpenFeature SDK', () => { expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: 'dynamic-user' }, {}); }); }); + + describe('require flags enabled decorator', () => { + describe('OpenFeatureController', () => { + it('should sucessfully return the response if the flag is enabled', async () => { + await supertest(app.getHttpServer()).get('/flags-enabled').expect(200).expect('Get Boolean Flag Success!'); + }); + + it('should throw an exception if the flag is disabled', async () => { + jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({ + value: false, + reason: 'DISABLED', + }); + await supertest(app.getHttpServer()).get('/flags-enabled').expect(404); + }); + + it('should throw a custom exception if the flag is disabled', async () => { + jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({ + value: false, + reason: 'DISABLED', + }); + await supertest(app.getHttpServer()).get('/flags-enabled-custom-exception').expect(403); + }); + }); + + describe('OpenFeatureControllerRequireFlagsEnabled', () => { + it('should allow access to the RequireFlagsEnabled controller', async () => { + await supertest(app.getHttpServer()).get('/require-flags-enabled').expect(200).expect('Hello, world!'); + }); + + it('should throw a 403 - Forbidden exception if the flag is disabled', async () => { + jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({ + value: false, + reason: 'DISABLED', + }); + await supertest(app.getHttpServer()).get('/require-flags-enabled').expect(403); + }); + }); + }); }); describe('Without global context interceptor', () => { - let moduleRef: TestingModule; let app: INestApplication; beforeAll(async () => { - moduleRef = await Test.createTestingModule({ imports: [ OpenFeatureModule.forRoot({ contextFactory: exampleContextFactory, defaultProvider, providers, - useGlobalInterceptor: false + useGlobalInterceptor: false, }), ], providers: [OpenFeatureTestService], - controllers: [OpenFeatureController, OpenFeatureControllerContextScopedController], + controllers: [OpenFeatureController, OpenFeatureContextScopedController], }).compile(); app = moduleRef.createNestApplication(); app = await app.init(); @@ -158,7 +197,7 @@ describe('OpenFeature SDK', () => { }); describe('evaluation context service should', () => { - it('inject empty context if no context interceptor is configured', async function() { + it('inject empty context if no context interceptor is configured', async function () { const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation'); await supertest(app.getHttpServer()) .get('/dynamic-context-in-service') @@ -172,9 +211,26 @@ describe('OpenFeature SDK', () => { describe('With Controller bound Context interceptor', () => { it('should not use context if global context interceptor is not configured', async () => { const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation'); - await supertest(app.getHttpServer()).get('/controller-context').set('x-user-id', '123').expect(200).expect('true'); + await supertest(app.getHttpServer()) + .get('/controller-context') + .set('x-user-id', '123') + .expect(200) + .expect('true'); expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: '123' }, {}); }); }); + + describe('require flags enabled decorator', () => { + it('should return a 404 - Not Found exception if the flag is disabled', async () => { + jest.spyOn(providers.domainScopedClient, 'resolveBooleanEvaluation').mockResolvedValueOnce({ + value: false, + reason: 'DISABLED', + }); + await supertest(app.getHttpServer()) + .get('/controller-context/flags-enabled') + .set('x-user-id', '123') + .expect(404); + }); + }); }); }); diff --git a/packages/nest/test/test-app.ts b/packages/nest/test/test-app.ts index c717b1a1f..e9b91ef69 100644 --- a/packages/nest/test/test-app.ts +++ b/packages/nest/test/test-app.ts @@ -1,7 +1,14 @@ -import { Controller, Get, Injectable, UseInterceptors } from '@nestjs/common'; -import type { Observable} from 'rxjs'; +import { Controller, ForbiddenException, Get, Injectable, UseInterceptors } from '@nestjs/common'; +import type { Observable } from 'rxjs'; import { map } from 'rxjs'; -import { BooleanFeatureFlag, ObjectFeatureFlag, NumberFeatureFlag, OpenFeatureClient, StringFeatureFlag } from '../src'; +import { + BooleanFeatureFlag, + ObjectFeatureFlag, + NumberFeatureFlag, + OpenFeatureClient, + StringFeatureFlag, + RequireFlagsEnabled, +} from '../src'; import type { Client, EvaluationDetails, FlagValue } from '@openfeature/server-sdk'; import { EvaluationContextInterceptor } from '../src'; @@ -84,11 +91,28 @@ export class OpenFeatureController { public async handleDynamicContextInServiceRequest() { return this.testService.serviceMethodWithDynamicContext('testBooleanFlag'); } + + @RequireFlagsEnabled({ + flagKeys: ['testBooleanFlag'], + }) + @Get('/flags-enabled') + public async handleGuardedBooleanRequest() { + return 'Get Boolean Flag Success!'; + } + + @RequireFlagsEnabled({ + flagKeys: ['testBooleanFlag'], + exception: new ForbiddenException(), + }) + @Get('/flags-enabled-custom-exception') + public async handleBooleanRequestWithCustomException() { + return 'Get Boolean Flag Success!'; + } } @Controller() @UseInterceptors(EvaluationContextInterceptor) -export class OpenFeatureControllerContextScopedController { +export class OpenFeatureContextScopedController { constructor(private testService: OpenFeatureTestService) {} @Get('/controller-context') @@ -101,4 +125,27 @@ export class OpenFeatureControllerContextScopedController { ) { return feature.pipe(map((details) => this.testService.serviceMethod(details))); } + + @RequireFlagsEnabled({ + flagKeys: ['testBooleanFlag'], + domain: 'domainScopedClient', + }) + @Get('/controller-context/flags-enabled') + public async handleBooleanRequest() { + return 'Get Boolean Flag Success!'; + } +} + +@Controller('require-flags-enabled') +@RequireFlagsEnabled({ + flagKeys: ['testBooleanFlag'], + exception: new ForbiddenException(), +}) +export class OpenFeatureRequireFlagsEnabledController { + constructor() {} + + @Get('/') + public async handleGetRequest() { + return 'Hello, world!'; + } } diff --git a/test-harness b/test-harness deleted file mode 160000 index 48c56d131..000000000 --- a/test-harness +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 48c56d1314ba89f6d1fdc2c17a8e4ac42e6b1981 From 32a3e7f9a433e814ea8fad9f06640b1badf8fa08 Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Wed, 26 Mar 2025 11:53:13 -0400 Subject: [PATCH 4/9] feat: update options for RequireFlagsEnabled decorator to include context and allow for flags to change the default value for flag evaluation. move getClientForEvaluation method to utils file Signed-off-by: Kaushal Kapasi --- packages/nest/src/feature.decorator.ts | 22 ++------- .../src/require-flags-enabled.decorator.ts | 48 +++++++++++-------- packages/nest/src/utils.ts | 12 +++++ packages/nest/test/fixtures.ts | 5 ++ packages/nest/test/open-feature-sdk.spec.ts | 16 +++++++ packages/nest/test/test-app.ts | 20 ++++++-- 6 files changed, 81 insertions(+), 42 deletions(-) create mode 100644 packages/nest/src/utils.ts diff --git a/packages/nest/src/feature.decorator.ts b/packages/nest/src/feature.decorator.ts index c6dba0623..4df439046 100644 --- a/packages/nest/src/feature.decorator.ts +++ b/packages/nest/src/feature.decorator.ts @@ -1,16 +1,10 @@ import { createParamDecorator, Inject } from '@nestjs/common'; -import type { - EvaluationContext, - EvaluationDetails, - FlagValue, - JsonValue} from '@openfeature/server-sdk'; -import { - OpenFeature, - Client, -} from '@openfeature/server-sdk'; +import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk'; +import { OpenFeature, Client } from '@openfeature/server-sdk'; import { getOpenFeatureClientToken } from './open-feature.module'; import type { Observable } from 'rxjs'; import { from } from 'rxjs'; +import { getClientForEvaluation } from './utils'; /** * Options for injecting an OpenFeature client into a constructor. @@ -56,16 +50,6 @@ interface FeatureProps { context?: EvaluationContext; } -/** - * Returns a domain scoped or the default OpenFeature client with the given context. - * @param {string} domain The domain of the OpenFeature client. - * @param {EvaluationContext} context The evaluation context of the client. - * @returns {Client} The OpenFeature client. - */ -function getClientForEvaluation(domain?: string, context?: EvaluationContext) { - return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context); -} - /** * Route handler parameter decorator. * diff --git a/packages/nest/src/require-flags-enabled.decorator.ts b/packages/nest/src/require-flags-enabled.decorator.ts index 9080d5d0d..73af2ea59 100644 --- a/packages/nest/src/require-flags-enabled.decorator.ts +++ b/packages/nest/src/require-flags-enabled.decorator.ts @@ -1,21 +1,28 @@ import type { CallHandler, ExecutionContext, HttpException, NestInterceptor } from '@nestjs/common'; import { applyDecorators, mixin, NotFoundException, UseInterceptors } from '@nestjs/common'; -import type { Client } from '@openfeature/server-sdk'; -import { OpenFeature } from '@openfeature/server-sdk'; +import { getClientForEvaluation } from './utils'; +import type { EvaluationContext } from '@openfeature/server-sdk'; + +type RequiredFlag = { + flagKey: string; + defaultValue?: boolean; +}; /** - * Options for injecting a feature flag into a route handler. + * Options for using one or more Boolean feature flags to control access to a Controller or Route. */ interface RequireFlagsEnabledProps { /** - * The key of the feature flag. + * The key and default value of the feature flag. * @see {@link Client#getBooleanValue} */ - flagKeys: string[]; + flags: RequiredFlag[]; + /** * The exception to throw if any of the required feature flags are not enabled. * Defaults to a 404 Not Found exception. * @see {@link HttpException} + * @default new NotFoundException(`Cannot ${req.method} ${req.url}`) */ exception?: HttpException; @@ -24,15 +31,12 @@ interface RequireFlagsEnabledProps { * @see {@link OpenFeature#getClient} */ domain?: string; -} -/** - * Returns a domain scoped or the default OpenFeature client with the given context. - * @param {string} domain The domain of the OpenFeature client. - * @returns {Client} The OpenFeature client. - */ -function getClientForEvaluation(domain?: string) { - return domain ? OpenFeature.getClient(domain) : OpenFeature.getClient(); + /** + * Global {@link EvaluationContext} for OpenFeature. + * @see {@link OpenFeature#setContext} + */ + context?: EvaluationContext; } /** @@ -43,9 +47,15 @@ function getClientForEvaluation(domain?: string) { * For example: * ```typescript * @RequireFlagsEnabled({ - * flagKeys: ['flagName', 'flagName2'], // Required, an array of Boolean feature flag keys - * exception: new ForbiddenException(), // Optional, defaults to a 404 Not Found exception - * domain: 'my-domain', // Optional, defaults to the default OpenFeature client + * flags: [ // Required, an array of Boolean flags to check, with optional default values (defaults to false) + * { flagKey: 'flagName' }, + * { flagKey: 'flagName2', defaultValue: true }, + * ], + * exception: new ForbiddenException(), // Optional, defaults to a 404 Not Found Exception + * domain: 'my-domain', // Optional, defaults to the default OpenFeature Client + * context: { // Optional, defaults to the global OpenFeature Context + * targetingKey: 'user-id', + * }, * }) * @Get('/') * public async handleGetRequest() @@ -62,10 +72,10 @@ const FlagsEnabledInterceptor = (props: RequireFlagsEnabledProps) => { async intercept(context: ExecutionContext, next: CallHandler) { const req = context.switchToHttp().getRequest(); - const client = getClientForEvaluation(props.domain); + const client = getClientForEvaluation(props.domain, props.context); - for (const flagKey of props.flagKeys) { - const endpointAccessible = await client.getBooleanValue(flagKey, false); + for (const flag of props.flags) { + const endpointAccessible = await client.getBooleanValue(flag.flagKey, flag.defaultValue ?? false); if (!endpointAccessible) { throw props.exception || new NotFoundException(`Cannot ${req.method} ${req.url}`); diff --git a/packages/nest/src/utils.ts b/packages/nest/src/utils.ts new file mode 100644 index 000000000..aec145b61 --- /dev/null +++ b/packages/nest/src/utils.ts @@ -0,0 +1,12 @@ +import type { Client, EvaluationContext } from '@openfeature/server-sdk'; +import { OpenFeature } from '@openfeature/server-sdk'; + +/** + * Returns a domain scoped or the default OpenFeature client with the given context. + * @param {string} domain The domain of the OpenFeature client. + * @param {EvaluationContext} context The evaluation context of the client. + * @returns {Client} The OpenFeature client. + */ +export function getClientForEvaluation(domain?: string, context?: EvaluationContext) { + return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context); +} diff --git a/packages/nest/test/fixtures.ts b/packages/nest/test/fixtures.ts index b773682aa..08fb35d92 100644 --- a/packages/nest/test/fixtures.ts +++ b/packages/nest/test/fixtures.ts @@ -23,6 +23,11 @@ export const defaultProvider = new InMemoryProvider({ variants: { default: { client: 'default' } }, disabled: false, }, + testBooleanFlag2: { + defaultVariant: 'default', + variants: { default: false, enabled: true }, + disabled: false, + }, }); export const providers = { diff --git a/packages/nest/test/open-feature-sdk.spec.ts b/packages/nest/test/open-feature-sdk.spec.ts index 8d96e9702..a692842c7 100644 --- a/packages/nest/test/open-feature-sdk.spec.ts +++ b/packages/nest/test/open-feature-sdk.spec.ts @@ -147,10 +147,26 @@ describe('OpenFeature SDK', () => { }); await supertest(app.getHttpServer()).get('/flags-enabled-custom-exception').expect(403); }); + + it('should throw a custom exception if the flag is disabled with context', async () => { + jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({ + value: false, + reason: 'DISABLED', + }); + await supertest(app.getHttpServer()) + .get('/flags-enabled-custom-exception-with-context') + .set('x-user-id', '123') + .expect(403); + }); }); describe('OpenFeatureControllerRequireFlagsEnabled', () => { it('should allow access to the RequireFlagsEnabled controller', async () => { + // Only mock the first flag evaluation for Flag with key `testBooleanFlag2`, the second flag evaluation will use the default variation for flag with key `testBooleanFlag` + jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({ + value: true, + reason: 'TARGETING_MATCH', + }); await supertest(app.getHttpServer()).get('/require-flags-enabled').expect(200).expect('Hello, world!'); }); diff --git a/packages/nest/test/test-app.ts b/packages/nest/test/test-app.ts index e9b91ef69..2db8d05a1 100644 --- a/packages/nest/test/test-app.ts +++ b/packages/nest/test/test-app.ts @@ -93,7 +93,7 @@ export class OpenFeatureController { } @RequireFlagsEnabled({ - flagKeys: ['testBooleanFlag'], + flags: [{ flagKey: 'testBooleanFlag' }], }) @Get('/flags-enabled') public async handleGuardedBooleanRequest() { @@ -101,13 +101,25 @@ export class OpenFeatureController { } @RequireFlagsEnabled({ - flagKeys: ['testBooleanFlag'], + flags: [{ flagKey: 'testBooleanFlag' }], exception: new ForbiddenException(), }) @Get('/flags-enabled-custom-exception') public async handleBooleanRequestWithCustomException() { return 'Get Boolean Flag Success!'; } + + @RequireFlagsEnabled({ + flags: [{ flagKey: 'testBooleanFlag' }], + exception: new ForbiddenException(), + context: { + targetingKey: 'user-id', + }, + }) + @Get('/flags-enabled-custom-exception-with-context') + public async handleBooleanRequestWithCustomExceptionAndContext() { + return 'Get Boolean Flag Success!'; + } } @Controller() @@ -127,7 +139,7 @@ export class OpenFeatureContextScopedController { } @RequireFlagsEnabled({ - flagKeys: ['testBooleanFlag'], + flags: [{ flagKey: 'testBooleanFlag' }], domain: 'domainScopedClient', }) @Get('/controller-context/flags-enabled') @@ -138,7 +150,7 @@ export class OpenFeatureContextScopedController { @Controller('require-flags-enabled') @RequireFlagsEnabled({ - flagKeys: ['testBooleanFlag'], + flags: [{ flagKey: 'testBooleanFlag2', defaultValue: true }, { flagKey: 'testBooleanFlag' }], exception: new ForbiddenException(), }) export class OpenFeatureRequireFlagsEnabledController { From c99945d593ab213d682095d13e3799e65664d1c6 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 27 Mar 2025 14:53:23 -0400 Subject: [PATCH 5/9] fixup: restore sm Signed-off-by: Todd Baert --- test-harness | 1 + 1 file changed, 1 insertion(+) create mode 160000 test-harness diff --git a/test-harness b/test-harness new file mode 160000 index 000000000..48c56d131 --- /dev/null +++ b/test-harness @@ -0,0 +1 @@ +Subproject commit 48c56d1314ba89f6d1fdc2c17a8e4ac42e6b1981 From 0118d8798c920feb2a53e5285792c1f21d3da83f Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Fri, 28 Mar 2025 15:14:53 -0400 Subject: [PATCH 6/9] chore: remove unused import to fix lint errors Signed-off-by: Kaushal Kapasi --- packages/nest/src/feature.decorator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nest/src/feature.decorator.ts b/packages/nest/src/feature.decorator.ts index 4df439046..aa728eb5f 100644 --- a/packages/nest/src/feature.decorator.ts +++ b/packages/nest/src/feature.decorator.ts @@ -1,6 +1,6 @@ import { createParamDecorator, Inject } from '@nestjs/common'; import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk'; -import { OpenFeature, Client } from '@openfeature/server-sdk'; +import { Client } from '@openfeature/server-sdk'; import { getOpenFeatureClientToken } from './open-feature.module'; import type { Observable } from 'rxjs'; import { from } from 'rxjs'; From a68c228b5bed2aebe457ed6aa59d742aad3b5e93 Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Mon, 31 Mar 2025 11:51:31 -0400 Subject: [PATCH 7/9] fix: update docs for context definition on RequireFlagsEnabledProps. add tests with targeting rules defined for the InMemoryProvider Signed-off-by: Kaushal Kapasi --- .../src/require-flags-enabled.decorator.ts | 2 +- packages/nest/test/fixtures.ts | 7 ++++++ packages/nest/test/open-feature-sdk.spec.ts | 25 +++++++++---------- packages/nest/test/test-app.ts | 4 +-- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/nest/src/require-flags-enabled.decorator.ts b/packages/nest/src/require-flags-enabled.decorator.ts index 73af2ea59..cdf1ddbc2 100644 --- a/packages/nest/src/require-flags-enabled.decorator.ts +++ b/packages/nest/src/require-flags-enabled.decorator.ts @@ -33,7 +33,7 @@ interface RequireFlagsEnabledProps { domain?: string; /** - * Global {@link EvaluationContext} for OpenFeature. + * The {@link EvaluationContext} for evaluating the feature flag. * @see {@link OpenFeature#setContext} */ context?: EvaluationContext; diff --git a/packages/nest/test/fixtures.ts b/packages/nest/test/fixtures.ts index 08fb35d92..352770c7b 100644 --- a/packages/nest/test/fixtures.ts +++ b/packages/nest/test/fixtures.ts @@ -1,4 +1,5 @@ import { InMemoryProvider } from '@openfeature/server-sdk'; +import type { EvaluationContext } from '@openfeature/server-sdk'; import type { ExecutionContext } from '@nestjs/common'; import { OpenFeatureModule } from '../src'; @@ -27,6 +28,12 @@ export const defaultProvider = new InMemoryProvider({ defaultVariant: 'default', variants: { default: false, enabled: true }, disabled: false, + contextEvaluator: (ctx: EvaluationContext) => { + if (ctx.targetingKey === '123') { + return 'enabled'; + } + return 'default'; + }, }, }); diff --git a/packages/nest/test/open-feature-sdk.spec.ts b/packages/nest/test/open-feature-sdk.spec.ts index a692842c7..fb6d64f5f 100644 --- a/packages/nest/test/open-feature-sdk.spec.ts +++ b/packages/nest/test/open-feature-sdk.spec.ts @@ -149,10 +149,6 @@ describe('OpenFeature SDK', () => { }); it('should throw a custom exception if the flag is disabled with context', async () => { - jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({ - value: false, - reason: 'DISABLED', - }); await supertest(app.getHttpServer()) .get('/flags-enabled-custom-exception-with-context') .set('x-user-id', '123') @@ -161,21 +157,24 @@ describe('OpenFeature SDK', () => { }); describe('OpenFeatureControllerRequireFlagsEnabled', () => { - it('should allow access to the RequireFlagsEnabled controller', async () => { - // Only mock the first flag evaluation for Flag with key `testBooleanFlag2`, the second flag evaluation will use the default variation for flag with key `testBooleanFlag` - jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({ - value: true, - reason: 'TARGETING_MATCH', - }); - await supertest(app.getHttpServer()).get('/require-flags-enabled').expect(200).expect('Hello, world!'); + it('should allow access to the RequireFlagsEnabled controller with global context interceptor', async () => { + await supertest(app.getHttpServer()) + .get('/require-flags-enabled') + .set('x-user-id', '123') + .expect(200) + .expect('Hello, world!'); + }); + + it('should throw a 403 - Forbidden exception if user does not match targeting requirements', async () => { + await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', 'not-123').expect(403); }); - it('should throw a 403 - Forbidden exception if the flag is disabled', async () => { + it('should throw a 403 - Forbidden exception if one of the flags is disabled', async () => { jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({ value: false, reason: 'DISABLED', }); - await supertest(app.getHttpServer()).get('/require-flags-enabled').expect(403); + await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', '123').expect(403); }); }); }); diff --git a/packages/nest/test/test-app.ts b/packages/nest/test/test-app.ts index 2db8d05a1..aa5358cd6 100644 --- a/packages/nest/test/test-app.ts +++ b/packages/nest/test/test-app.ts @@ -110,7 +110,7 @@ export class OpenFeatureController { } @RequireFlagsEnabled({ - flags: [{ flagKey: 'testBooleanFlag' }], + flags: [{ flagKey: 'testBooleanFlag2' }], exception: new ForbiddenException(), context: { targetingKey: 'user-id', @@ -150,7 +150,7 @@ export class OpenFeatureContextScopedController { @Controller('require-flags-enabled') @RequireFlagsEnabled({ - flags: [{ flagKey: 'testBooleanFlag2', defaultValue: true }, { flagKey: 'testBooleanFlag' }], + flags: [{ flagKey: 'testBooleanFlag', defaultValue: false }, { flagKey: 'testBooleanFlag2' }], exception: new ForbiddenException(), }) export class OpenFeatureRequireFlagsEnabledController { From bffdb8a1f3f31e14c9d68c7d0675785f13e10055 Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Mon, 21 Apr 2025 21:46:13 -0400 Subject: [PATCH 8/9] feat: add contextFactory param to RequireFlagsEnabled decorator Signed-off-by: Kaushal Kapasi --- .../nest/src/require-flags-enabled.decorator.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/nest/src/require-flags-enabled.decorator.ts b/packages/nest/src/require-flags-enabled.decorator.ts index cdf1ddbc2..1888f9974 100644 --- a/packages/nest/src/require-flags-enabled.decorator.ts +++ b/packages/nest/src/require-flags-enabled.decorator.ts @@ -2,6 +2,7 @@ import type { CallHandler, ExecutionContext, HttpException, NestInterceptor } fr import { applyDecorators, mixin, NotFoundException, UseInterceptors } from '@nestjs/common'; import { getClientForEvaluation } from './utils'; import type { EvaluationContext } from '@openfeature/server-sdk'; +import type { ContextFactory } from './context-factory'; type RequiredFlag = { flagKey: string; @@ -37,6 +38,13 @@ interface RequireFlagsEnabledProps { * @see {@link OpenFeature#setContext} */ context?: EvaluationContext; + + /** + * A factory function for creating an OpenFeature {@link EvaluationContext} from Nest {@link ExecutionContext}. + * For example, this can be used to get header info from an HTTP request or information from a gRPC call to be used in the {@link EvaluationContext}. + * @see {@link ContextFactory} + */ + contextFactory?: ContextFactory; } /** @@ -56,6 +64,11 @@ interface RequireFlagsEnabledProps { * context: { // Optional, defaults to the global OpenFeature Context * targetingKey: 'user-id', * }, + * contextFactory: (context: ExecutionContext) => { // Optional, defaults to the global OpenFeature Context. Takes precedence over the context option. + * return { + * targetingKey: context.switchToHttp().getRequest().headers['x-user-id'], + * }; + * }, * }) * @Get('/') * public async handleGetRequest() @@ -72,7 +85,8 @@ const FlagsEnabledInterceptor = (props: RequireFlagsEnabledProps) => { async intercept(context: ExecutionContext, next: CallHandler) { const req = context.switchToHttp().getRequest(); - const client = getClientForEvaluation(props.domain, props.context); + const evaluationContext = props.contextFactory ? await props.contextFactory(context) : props.context; + const client = getClientForEvaluation(props.domain, evaluationContext); for (const flag of props.flags) { const endpointAccessible = await client.getBooleanValue(flag.flagKey, flag.defaultValue ?? false); From 29578324e403f7070220bcd8f39462a9e808bb32 Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Mon, 21 Apr 2025 22:29:12 -0400 Subject: [PATCH 9/9] chore: update NestJS readme with a simple example of how to implement the RequiredFlagsController Signed-off-by: Kaushal Kapasi --- packages/nest/README.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/nest/README.md b/packages/nest/README.md index 931d30230..dfcd0093c 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -72,10 +72,10 @@ yarn add @openfeature/nestjs-sdk @openfeature/server-sdk @openfeature/core The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions: -* `@openfeature/server-sdk`: >=1.7.5 -* `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 -* `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 -* `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0 +- `@openfeature/server-sdk`: >=1.7.5 +- `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 +- `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 +- `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0 The minimum required version of `@openfeature/server-sdk` currently is `1.7.5`. @@ -152,6 +152,24 @@ export class OpenFeatureTestService { } ``` +#### Managing Controller or Route Access via Feature Flags + +The `RequireFlagsEnabled` decorator can be used to manage access to a controller or route based on the enabled state of a feature flag. The decorator will throw an exception if the required feature flag(s) are not enabled. + +```ts +import { Controller, Get } from '@nestjs/common'; +import { RequireFlagsEnabled } from '@openfeature/nestjs-sdk'; + +@Controller() +export class OpenFeatureController { + @RequireFlagsEnabled({ flags: [{ flagKey: 'testBooleanFlag' }] }) + @Get('/welcome') + public async welcome() { + return 'Welcome to this OpenFeature-enabled NestJS app!'; + } +} +``` + ## Module additional information ### Flag evaluation context injection