Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds RequireFlagsEnabled decorator #1159

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
22 changes: 3 additions & 19 deletions packages/nest/src/feature.decorator.ts
Original file line number Diff line number Diff line change
@@ -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 { 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.
Expand Down Expand Up @@ -56,16 +50,6 @@ interface FeatureProps<T extends FlagValue> {
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.
*
Expand Down
1 change: 1 addition & 0 deletions packages/nest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
90 changes: 90 additions & 0 deletions packages/nest/src/require-flags-enabled.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { CallHandler, ExecutionContext, HttpException, NestInterceptor } from '@nestjs/common';
import { applyDecorators, mixin, NotFoundException, UseInterceptors } from '@nestjs/common';
import { getClientForEvaluation } from './utils';
import type { EvaluationContext } from '@openfeature/server-sdk';

type RequiredFlag = {
flagKey: string;
defaultValue?: boolean;
};

/**
* Options for using one or more Boolean feature flags to control access to a Controller or Route.
*/
interface RequireFlagsEnabledProps {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we could have a context factory here too. Basically what we globally have for the OpenFeatureModule:

contextFactory?: ContextFactory;

But this would be optional to me. We do not have it for the other decorators too:

interface FeatureProps<T extends FlagValue> {

So to me this is optional for this PR but it would be a great addition.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, will do.

Copy link
Member

@lukas-reining lukas-reining Apr 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, are you still planning to do it @kaushalkapasi? I can not see it yet, but you re-requested a review.
We can definitely leave it out for now if you want.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukas-reining I decided not to add this in for now.

Would you be able to elaborate how this would be used and how it would work differently compared to the ability to define the context (as on line 39)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries!
The difference is, that the factory could use the ExecutionContext to get request information like headers or IP address and transform that to evaluation context.
This can currently only be done in the transaction context, having it here would allow e.g. to use a specific http header as context value for the specified flags only.

/**
* The key and default value of the feature flag.
* @see {@link Client#getBooleanValue}
*/
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;

/**
* The domain of the OpenFeature client, if a domain scoped client should be used.
* @see {@link OpenFeature#getClient}
*/
domain?: string;

/**
* The {@link EvaluationContext} for evaluating the feature flag.
* @see {@link OpenFeature#setContext}
*/
context?: EvaluationContext;
}

/**
* 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({
* 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()
* ```
* @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)));
Comment on lines +66 to +67
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there could be value in similar function like: RequireFlagEquals.
But this could also be added in another PR.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah! I opted to implement this first as the "easiest" option, a follow up would be to take a flag key and expected value combination to support String, Number and Object type flags.

Copy link
Member

@toddbaert toddbaert Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep that makes sense to me. I think most people would use this current decorator, which could basically just be a shorthand for a RequireFlagEquals implementation. I think instead of a key/value combination though, a lambda predicate might be easier (a lambda taking the EvalutionDetails which will run to decide to run the request or not). For RequireFlagsEnabled, the lambda would just be evalutationDetails.value === true

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kaushalkapasi do you want to do this in this PR?
Because you requested reviews again but I could not find it yet.
We can also leave it out of here, but it might be really good to have here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't added the lambda function in this PR. My only concern is that it would complicate the usage of this Boolean only provider.

I would definitely add it when we build a decorator for other flag types (String, Number and Object).

Let me know if you think it should be implemented here and I'd be happy to do so.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do it like @toddbaert describes, RequireFlagsEnabled would simply be an alias for RequireFlagEquals with evalutationDetails.value === true.
It would not make the RequireFlagsEnabled more complicated in this case.

I would prefer to have it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I'll update this implementation soon and tag you for a re-review when ready!


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, props.context);

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

return next.handle();
}
}

return mixin(FlagsEnabledInterceptor);
};
12 changes: 12 additions & 0 deletions packages/nest/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
12 changes: 12 additions & 0 deletions packages/nest/test/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -23,6 +24,17 @@ export const defaultProvider = new InMemoryProvider({
variants: { default: { client: 'default' } },
disabled: false,
},
testBooleanFlag2: {
defaultVariant: 'default',
variants: { default: false, enabled: true },
disabled: false,
contextEvaluator: (ctx: EvaluationContext) => {
if (ctx.targetingKey === '123') {
return 'enabled';
}
return 'default';
},
},
});

export const providers = {
Expand Down
95 changes: 83 additions & 12 deletions packages/nest/test/open-feature-sdk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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')
Expand All @@ -122,26 +125,77 @@ 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);
});

it('should throw a custom exception if the flag is disabled with context', async () => {
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 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 one of the flags is disabled', async () => {
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
value: false,
reason: 'DISABLED',
});
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', '123').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();
Expand All @@ -158,7 +212,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')
Expand All @@ -172,9 +226,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);
});
});
});
});
Loading