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:
parent
4fe8d87a2e
commit
59b8fe904f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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!';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue