feat: adds RequireFlagsEnabled decorator (#1159)

## This PR
- Feature: Adds a `RequireFlagsEnabled` decorator to allow a simple,
reusable way to block access to a specific controller or endpoint based
on the value of a list of one, or many, boolean flags

### Notes
- Discussions on the approach & implementation are welcome!

### Follow-up Tasks
- Update OpenFeature NestJS docs to include new `RequireFlagsEnabled`
decorator & usage examples

### How to test
`npx jest --selectProject=nest`

---------

Signed-off-by: Kaushal Kapasi <kaushal.kapasi@taplytics.com>
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
This commit is contained in:
Kaushal Kapasi 2025-04-24 09:32:55 -04:00 committed by GitHub
parent 4fe8d87a2e
commit 59b8fe904f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 300 additions and 39 deletions

View File

@ -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

View File

@ -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.
@ -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.
*

View File

@ -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';

View File

@ -0,0 +1,104 @@
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';
import type { ContextFactory } from './context-factory';
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 {
/**
* 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;
/**
* 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;
}
/**
* 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',
* },
* 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()
* ```
* @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)));
const FlagsEnabledInterceptor = (props: RequireFlagsEnabledProps) => {
class FlagsEnabledInterceptor implements NestInterceptor {
constructor() {}
async intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
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);
if (!endpointAccessible) {
throw props.exception || new NotFoundException(`Cannot ${req.method} ${req.url}`);
}
}
return next.handle();
}
}
return mixin(FlagsEnabledInterceptor);
};

View File

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

View File

@ -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';
@ -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 = {

View File

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

View File

@ -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,40 @@ export class OpenFeatureController {
public async handleDynamicContextInServiceRequest() {
return this.testService.serviceMethodWithDynamicContext('testBooleanFlag');
}
@RequireFlagsEnabled({
flags: [{ flagKey: 'testBooleanFlag' }],
})
@Get('/flags-enabled')
public async handleGuardedBooleanRequest() {
return 'Get Boolean Flag Success!';
}
@RequireFlagsEnabled({
flags: [{ flagKey: 'testBooleanFlag' }],
exception: new ForbiddenException(),
})
@Get('/flags-enabled-custom-exception')
public async handleBooleanRequestWithCustomException() {
return 'Get Boolean Flag Success!';
}
@RequireFlagsEnabled({
flags: [{ flagKey: 'testBooleanFlag2' }],
exception: new ForbiddenException(),
context: {
targetingKey: 'user-id',
},
})
@Get('/flags-enabled-custom-exception-with-context')
public async handleBooleanRequestWithCustomExceptionAndContext() {
return 'Get Boolean Flag Success!';
}
}
@Controller()
@UseInterceptors(EvaluationContextInterceptor)
export class OpenFeatureControllerContextScopedController {
export class OpenFeatureContextScopedController {
constructor(private testService: OpenFeatureTestService) {}
@Get('/controller-context')
@ -101,4 +137,27 @@ export class OpenFeatureControllerContextScopedController {
) {
return feature.pipe(map((details) => this.testService.serviceMethod(details)));
}
@RequireFlagsEnabled({
flags: [{ flagKey: 'testBooleanFlag' }],
domain: 'domainScopedClient',
})
@Get('/controller-context/flags-enabled')
public async handleBooleanRequest() {
return 'Get Boolean Flag Success!';
}
}
@Controller('require-flags-enabled')
@RequireFlagsEnabled({
flags: [{ flagKey: 'testBooleanFlag', defaultValue: false }, { flagKey: 'testBooleanFlag2' }],
exception: new ForbiddenException(),
})
export class OpenFeatureRequireFlagsEnabledController {
constructor() {}
@Get('/')
public async handleGetRequest() {
return 'Hello, world!';
}
}