Skip to content

Commit 59b8fe9

Browse files
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 <[email protected]> Signed-off-by: Todd Baert <[email protected]> Co-authored-by: Todd Baert <[email protected]>
1 parent 4fe8d87 commit 59b8fe9

8 files changed

+300
-39
lines changed

packages/nest/README.md

+22-4
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ yarn add @openfeature/nestjs-sdk @openfeature/server-sdk @openfeature/core
7272

7373
The following list contains the peer dependencies of `@openfeature/nestjs-sdk` with its expected and compatible versions:
7474

75-
* `@openfeature/server-sdk`: >=1.7.5
76-
* `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
77-
* `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
78-
* `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
75+
- `@openfeature/server-sdk`: >=1.7.5
76+
- `@nestjs/common`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
77+
- `@nestjs/core`: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
78+
- `rxjs`: ^6.0.0 || ^7.0.0 || ^8.0.0
7979

8080
The minimum required version of `@openfeature/server-sdk` currently is `1.7.5`.
8181

@@ -152,6 +152,24 @@ export class OpenFeatureTestService {
152152
}
153153
```
154154

155+
#### Managing Controller or Route Access via Feature Flags
156+
157+
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.
158+
159+
```ts
160+
import { Controller, Get } from '@nestjs/common';
161+
import { RequireFlagsEnabled } from '@openfeature/nestjs-sdk';
162+
163+
@Controller()
164+
export class OpenFeatureController {
165+
@RequireFlagsEnabled({ flags: [{ flagKey: 'testBooleanFlag' }] })
166+
@Get('/welcome')
167+
public async welcome() {
168+
return 'Welcome to this OpenFeature-enabled NestJS app!';
169+
}
170+
}
171+
```
172+
155173
## Module additional information
156174

157175
### Flag evaluation context injection

packages/nest/src/feature.decorator.ts

+3-19
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
import { createParamDecorator, Inject } from '@nestjs/common';
2-
import type {
3-
EvaluationContext,
4-
EvaluationDetails,
5-
FlagValue,
6-
JsonValue} from '@openfeature/server-sdk';
7-
import {
8-
OpenFeature,
9-
Client,
10-
} from '@openfeature/server-sdk';
2+
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonValue } from '@openfeature/server-sdk';
3+
import { Client } from '@openfeature/server-sdk';
114
import { getOpenFeatureClientToken } from './open-feature.module';
125
import type { Observable } from 'rxjs';
136
import { from } from 'rxjs';
7+
import { getClientForEvaluation } from './utils';
148

159
/**
1610
* Options for injecting an OpenFeature client into a constructor.
@@ -56,16 +50,6 @@ interface FeatureProps<T extends FlagValue> {
5650
context?: EvaluationContext;
5751
}
5852

59-
/**
60-
* Returns a domain scoped or the default OpenFeature client with the given context.
61-
* @param {string} domain The domain of the OpenFeature client.
62-
* @param {EvaluationContext} context The evaluation context of the client.
63-
* @returns {Client} The OpenFeature client.
64-
*/
65-
function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
66-
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
67-
}
68-
6953
/**
7054
* Route handler parameter decorator.
7155
*

packages/nest/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export * from './open-feature.module';
22
export * from './feature.decorator';
33
export * from './evaluation-context-interceptor';
44
export * from './context-factory';
5+
export * from './require-flags-enabled.decorator';
56
// re-export the server-sdk so consumers can access that API from the nestjs-sdk
67
export * from '@openfeature/server-sdk';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { CallHandler, ExecutionContext, HttpException, NestInterceptor } from '@nestjs/common';
2+
import { applyDecorators, mixin, NotFoundException, UseInterceptors } from '@nestjs/common';
3+
import { getClientForEvaluation } from './utils';
4+
import type { EvaluationContext } from '@openfeature/server-sdk';
5+
import type { ContextFactory } from './context-factory';
6+
7+
type RequiredFlag = {
8+
flagKey: string;
9+
defaultValue?: boolean;
10+
};
11+
12+
/**
13+
* Options for using one or more Boolean feature flags to control access to a Controller or Route.
14+
*/
15+
interface RequireFlagsEnabledProps {
16+
/**
17+
* The key and default value of the feature flag.
18+
* @see {@link Client#getBooleanValue}
19+
*/
20+
flags: RequiredFlag[];
21+
22+
/**
23+
* The exception to throw if any of the required feature flags are not enabled.
24+
* Defaults to a 404 Not Found exception.
25+
* @see {@link HttpException}
26+
* @default new NotFoundException(`Cannot ${req.method} ${req.url}`)
27+
*/
28+
exception?: HttpException;
29+
30+
/**
31+
* The domain of the OpenFeature client, if a domain scoped client should be used.
32+
* @see {@link OpenFeature#getClient}
33+
*/
34+
domain?: string;
35+
36+
/**
37+
* The {@link EvaluationContext} for evaluating the feature flag.
38+
* @see {@link OpenFeature#setContext}
39+
*/
40+
context?: EvaluationContext;
41+
42+
/**
43+
* A factory function for creating an OpenFeature {@link EvaluationContext} from Nest {@link ExecutionContext}.
44+
* 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}.
45+
* @see {@link ContextFactory}
46+
*/
47+
contextFactory?: ContextFactory;
48+
}
49+
50+
/**
51+
* Controller or Route permissions handler decorator.
52+
*
53+
* Requires that the given feature flags are enabled for the request to be processed, else throws an exception.
54+
*
55+
* For example:
56+
* ```typescript
57+
* @RequireFlagsEnabled({
58+
* flags: [ // Required, an array of Boolean flags to check, with optional default values (defaults to false)
59+
* { flagKey: 'flagName' },
60+
* { flagKey: 'flagName2', defaultValue: true },
61+
* ],
62+
* exception: new ForbiddenException(), // Optional, defaults to a 404 Not Found Exception
63+
* domain: 'my-domain', // Optional, defaults to the default OpenFeature Client
64+
* context: { // Optional, defaults to the global OpenFeature Context
65+
* targetingKey: 'user-id',
66+
* },
67+
* contextFactory: (context: ExecutionContext) => { // Optional, defaults to the global OpenFeature Context. Takes precedence over the context option.
68+
* return {
69+
* targetingKey: context.switchToHttp().getRequest().headers['x-user-id'],
70+
* };
71+
* },
72+
* })
73+
* @Get('/')
74+
* public async handleGetRequest()
75+
* ```
76+
* @param {RequireFlagsEnabledProps} props The options for injecting the feature flag.
77+
* @returns {ClassDecorator & MethodDecorator} The decorator that can be used to require Boolean Feature Flags to be enabled for a controller or a specific route.
78+
*/
79+
export const RequireFlagsEnabled = (props: RequireFlagsEnabledProps): ClassDecorator & MethodDecorator =>
80+
applyDecorators(UseInterceptors(FlagsEnabledInterceptor(props)));
81+
82+
const FlagsEnabledInterceptor = (props: RequireFlagsEnabledProps) => {
83+
class FlagsEnabledInterceptor implements NestInterceptor {
84+
constructor() {}
85+
86+
async intercept(context: ExecutionContext, next: CallHandler) {
87+
const req = context.switchToHttp().getRequest();
88+
const evaluationContext = props.contextFactory ? await props.contextFactory(context) : props.context;
89+
const client = getClientForEvaluation(props.domain, evaluationContext);
90+
91+
for (const flag of props.flags) {
92+
const endpointAccessible = await client.getBooleanValue(flag.flagKey, flag.defaultValue ?? false);
93+
94+
if (!endpointAccessible) {
95+
throw props.exception || new NotFoundException(`Cannot ${req.method} ${req.url}`);
96+
}
97+
}
98+
99+
return next.handle();
100+
}
101+
}
102+
103+
return mixin(FlagsEnabledInterceptor);
104+
};

packages/nest/src/utils.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Client, EvaluationContext } from '@openfeature/server-sdk';
2+
import { OpenFeature } from '@openfeature/server-sdk';
3+
4+
/**
5+
* Returns a domain scoped or the default OpenFeature client with the given context.
6+
* @param {string} domain The domain of the OpenFeature client.
7+
* @param {EvaluationContext} context The evaluation context of the client.
8+
* @returns {Client} The OpenFeature client.
9+
*/
10+
export function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
11+
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
12+
}

packages/nest/test/fixtures.ts

+12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { InMemoryProvider } from '@openfeature/server-sdk';
2+
import type { EvaluationContext } from '@openfeature/server-sdk';
23
import type { ExecutionContext } from '@nestjs/common';
34
import { OpenFeatureModule } from '../src';
45

@@ -23,6 +24,17 @@ export const defaultProvider = new InMemoryProvider({
2324
variants: { default: { client: 'default' } },
2425
disabled: false,
2526
},
27+
testBooleanFlag2: {
28+
defaultVariant: 'default',
29+
variants: { default: false, enabled: true },
30+
disabled: false,
31+
contextEvaluator: (ctx: EvaluationContext) => {
32+
if (ctx.targetingKey === '123') {
33+
return 'enabled';
34+
}
35+
return 'default';
36+
},
37+
},
2638
});
2739

2840
export const providers = {

packages/nest/test/open-feature-sdk.spec.ts

+83-12
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import type { TestingModule } from '@nestjs/testing';
22
import { Test } from '@nestjs/testing';
33
import type { INestApplication } from '@nestjs/common';
44
import supertest from 'supertest';
5-
import { OpenFeatureController, OpenFeatureControllerContextScopedController, OpenFeatureTestService } from './test-app';
5+
import {
6+
OpenFeatureController,
7+
OpenFeatureContextScopedController,
8+
OpenFeatureRequireFlagsEnabledController,
9+
OpenFeatureTestService,
10+
} from './test-app';
611
import { exampleContextFactory, getOpenFeatureDefaultTestModule } from './fixtures';
712
import { OpenFeatureModule } from '../src';
813
import { defaultProvider, providers } from './fixtures';
@@ -14,11 +19,9 @@ describe('OpenFeature SDK', () => {
1419

1520
beforeAll(async () => {
1621
moduleRef = await Test.createTestingModule({
17-
imports: [
18-
getOpenFeatureDefaultTestModule()
19-
],
22+
imports: [getOpenFeatureDefaultTestModule()],
2023
providers: [OpenFeatureTestService],
21-
controllers: [OpenFeatureController],
24+
controllers: [OpenFeatureController, OpenFeatureRequireFlagsEnabledController],
2225
}).compile();
2326
app = moduleRef.createNestApplication();
2427
app = await app.init();
@@ -112,7 +115,7 @@ describe('OpenFeature SDK', () => {
112115
});
113116

114117
describe('evaluation context service should', () => {
115-
it('inject the evaluation context from contex factory', async function() {
118+
it('inject the evaluation context from contex factory', async function () {
116119
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
117120
await supertest(app.getHttpServer())
118121
.get('/dynamic-context-in-service')
@@ -122,26 +125,77 @@ describe('OpenFeature SDK', () => {
122125
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: 'dynamic-user' }, {});
123126
});
124127
});
128+
129+
describe('require flags enabled decorator', () => {
130+
describe('OpenFeatureController', () => {
131+
it('should sucessfully return the response if the flag is enabled', async () => {
132+
await supertest(app.getHttpServer()).get('/flags-enabled').expect(200).expect('Get Boolean Flag Success!');
133+
});
134+
135+
it('should throw an exception if the flag is disabled', async () => {
136+
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
137+
value: false,
138+
reason: 'DISABLED',
139+
});
140+
await supertest(app.getHttpServer()).get('/flags-enabled').expect(404);
141+
});
142+
143+
it('should throw a custom exception if the flag is disabled', async () => {
144+
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
145+
value: false,
146+
reason: 'DISABLED',
147+
});
148+
await supertest(app.getHttpServer()).get('/flags-enabled-custom-exception').expect(403);
149+
});
150+
151+
it('should throw a custom exception if the flag is disabled with context', async () => {
152+
await supertest(app.getHttpServer())
153+
.get('/flags-enabled-custom-exception-with-context')
154+
.set('x-user-id', '123')
155+
.expect(403);
156+
});
157+
});
158+
159+
describe('OpenFeatureControllerRequireFlagsEnabled', () => {
160+
it('should allow access to the RequireFlagsEnabled controller with global context interceptor', async () => {
161+
await supertest(app.getHttpServer())
162+
.get('/require-flags-enabled')
163+
.set('x-user-id', '123')
164+
.expect(200)
165+
.expect('Hello, world!');
166+
});
167+
168+
it('should throw a 403 - Forbidden exception if user does not match targeting requirements', async () => {
169+
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', 'not-123').expect(403);
170+
});
171+
172+
it('should throw a 403 - Forbidden exception if one of the flags is disabled', async () => {
173+
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
174+
value: false,
175+
reason: 'DISABLED',
176+
});
177+
await supertest(app.getHttpServer()).get('/require-flags-enabled').set('x-user-id', '123').expect(403);
178+
});
179+
});
180+
});
125181
});
126182

127183
describe('Without global context interceptor', () => {
128-
129184
let moduleRef: TestingModule;
130185
let app: INestApplication;
131186

132187
beforeAll(async () => {
133-
134188
moduleRef = await Test.createTestingModule({
135189
imports: [
136190
OpenFeatureModule.forRoot({
137191
contextFactory: exampleContextFactory,
138192
defaultProvider,
139193
providers,
140-
useGlobalInterceptor: false
194+
useGlobalInterceptor: false,
141195
}),
142196
],
143197
providers: [OpenFeatureTestService],
144-
controllers: [OpenFeatureController, OpenFeatureControllerContextScopedController],
198+
controllers: [OpenFeatureController, OpenFeatureContextScopedController],
145199
}).compile();
146200
app = moduleRef.createNestApplication();
147201
app = await app.init();
@@ -158,7 +212,7 @@ describe('OpenFeature SDK', () => {
158212
});
159213

160214
describe('evaluation context service should', () => {
161-
it('inject empty context if no context interceptor is configured', async function() {
215+
it('inject empty context if no context interceptor is configured', async function () {
162216
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
163217
await supertest(app.getHttpServer())
164218
.get('/dynamic-context-in-service')
@@ -172,9 +226,26 @@ describe('OpenFeature SDK', () => {
172226
describe('With Controller bound Context interceptor', () => {
173227
it('should not use context if global context interceptor is not configured', async () => {
174228
const evaluationSpy = jest.spyOn(defaultProvider, 'resolveBooleanEvaluation');
175-
await supertest(app.getHttpServer()).get('/controller-context').set('x-user-id', '123').expect(200).expect('true');
229+
await supertest(app.getHttpServer())
230+
.get('/controller-context')
231+
.set('x-user-id', '123')
232+
.expect(200)
233+
.expect('true');
176234
expect(evaluationSpy).toHaveBeenCalledWith('testBooleanFlag', false, { targetingKey: '123' }, {});
177235
});
178236
});
237+
238+
describe('require flags enabled decorator', () => {
239+
it('should return a 404 - Not Found exception if the flag is disabled', async () => {
240+
jest.spyOn(providers.domainScopedClient, 'resolveBooleanEvaluation').mockResolvedValueOnce({
241+
value: false,
242+
reason: 'DISABLED',
243+
});
244+
await supertest(app.getHttpServer())
245+
.get('/controller-context/flags-enabled')
246+
.set('x-user-id', '123')
247+
.expect(404);
248+
});
249+
});
179250
});
180251
});

0 commit comments

Comments
 (0)