Skip to content

Commit 6ec4788

Browse files
committed
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 <[email protected]>
1 parent a67cc35 commit 6ec4788

File tree

6 files changed

+81
-42
lines changed

6 files changed

+81
-42
lines changed

packages/nest/src/feature.decorator.ts

Lines changed: 3 additions & 19 deletions
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 { OpenFeature, 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/require-flags-enabled.decorator.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
import type { CallHandler, ExecutionContext, HttpException, NestInterceptor } from '@nestjs/common';
22
import { applyDecorators, mixin, NotFoundException, UseInterceptors } from '@nestjs/common';
3-
import type { Client } from '@openfeature/server-sdk';
4-
import { OpenFeature } from '@openfeature/server-sdk';
3+
import { getClientForEvaluation } from './utils';
4+
import type { EvaluationContext } from '@openfeature/server-sdk';
5+
6+
type RequiredFlag = {
7+
flagKey: string;
8+
defaultValue?: boolean;
9+
};
510

611
/**
7-
* Options for injecting a feature flag into a route handler.
12+
* Options for using one or more Boolean feature flags to control access to a Controller or Route.
813
*/
914
interface RequireFlagsEnabledProps {
1015
/**
11-
* The key of the feature flag.
16+
* The key and default value of the feature flag.
1217
* @see {@link Client#getBooleanValue}
1318
*/
14-
flagKeys: string[];
19+
flags: RequiredFlag[];
20+
1521
/**
1622
* The exception to throw if any of the required feature flags are not enabled.
1723
* Defaults to a 404 Not Found exception.
1824
* @see {@link HttpException}
25+
* @default new NotFoundException(`Cannot ${req.method} ${req.url}`)
1926
*/
2027
exception?: HttpException;
2128

@@ -24,15 +31,12 @@ interface RequireFlagsEnabledProps {
2431
* @see {@link OpenFeature#getClient}
2532
*/
2633
domain?: string;
27-
}
2834

29-
/**
30-
* Returns a domain scoped or the default OpenFeature client with the given context.
31-
* @param {string} domain The domain of the OpenFeature client.
32-
* @returns {Client} The OpenFeature client.
33-
*/
34-
function getClientForEvaluation(domain?: string) {
35-
return domain ? OpenFeature.getClient(domain) : OpenFeature.getClient();
35+
/**
36+
* Global {@link EvaluationContext} for OpenFeature.
37+
* @see {@link OpenFeature#setContext}
38+
*/
39+
context?: EvaluationContext;
3640
}
3741

3842
/**
@@ -43,9 +47,15 @@ function getClientForEvaluation(domain?: string) {
4347
* For example:
4448
* ```typescript
4549
* @RequireFlagsEnabled({
46-
* flagKeys: ['flagName', 'flagName2'], // Required, an array of Boolean feature flag keys
47-
* exception: new ForbiddenException(), // Optional, defaults to a 404 Not Found exception
48-
* domain: 'my-domain', // Optional, defaults to the default OpenFeature client
50+
* flags: [ // Required, an array of Boolean flags to check, with optional default values (defaults to false)
51+
* { flagKey: 'flagName' },
52+
* { flagKey: 'flagName2', defaultValue: true },
53+
* ],
54+
* exception: new ForbiddenException(), // Optional, defaults to a 404 Not Found Exception
55+
* domain: 'my-domain', // Optional, defaults to the default OpenFeature Client
56+
* context: { // Optional, defaults to the global OpenFeature Context
57+
* targetingKey: 'user-id',
58+
* },
4959
* })
5060
* @Get('/')
5161
* public async handleGetRequest()
@@ -62,10 +72,10 @@ const FlagsEnabledInterceptor = (props: RequireFlagsEnabledProps) => {
6272

6373
async intercept(context: ExecutionContext, next: CallHandler) {
6474
const req = context.switchToHttp().getRequest();
65-
const client = getClientForEvaluation(props.domain);
75+
const client = getClientForEvaluation(props.domain, props.context);
6676

67-
for (const flagKey of props.flagKeys) {
68-
const endpointAccessible = await client.getBooleanValue(flagKey, false);
77+
for (const flag of props.flags) {
78+
const endpointAccessible = await client.getBooleanValue(flag.flagKey, flag.defaultValue ?? false);
6979

7080
if (!endpointAccessible) {
7181
throw props.exception || new NotFoundException(`Cannot ${req.method} ${req.url}`);

packages/nest/src/utils.ts

Lines changed: 12 additions & 0 deletions
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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ export const defaultProvider = new InMemoryProvider({
2323
variants: { default: { client: 'default' } },
2424
disabled: false,
2525
},
26+
testBooleanFlag2: {
27+
defaultVariant: 'default',
28+
variants: { default: false, enabled: true },
29+
disabled: false,
30+
},
2631
});
2732

2833
export const providers = {

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,26 @@ describe('OpenFeature SDK', () => {
147147
});
148148
await supertest(app.getHttpServer()).get('/flags-enabled-custom-exception').expect(403);
149149
});
150+
151+
it('should throw a custom exception if the flag is disabled with context', async () => {
152+
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
153+
value: false,
154+
reason: 'DISABLED',
155+
});
156+
await supertest(app.getHttpServer())
157+
.get('/flags-enabled-custom-exception-with-context')
158+
.set('x-user-id', '123')
159+
.expect(403);
160+
});
150161
});
151162

152163
describe('OpenFeatureControllerRequireFlagsEnabled', () => {
153164
it('should allow access to the RequireFlagsEnabled controller', async () => {
165+
// 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`
166+
jest.spyOn(defaultProvider, 'resolveBooleanEvaluation').mockResolvedValueOnce({
167+
value: true,
168+
reason: 'TARGETING_MATCH',
169+
});
154170
await supertest(app.getHttpServer()).get('/require-flags-enabled').expect(200).expect('Hello, world!');
155171
});
156172

packages/nest/test/test-app.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,21 +93,33 @@ export class OpenFeatureController {
9393
}
9494

9595
@RequireFlagsEnabled({
96-
flagKeys: ['testBooleanFlag'],
96+
flags: [{ flagKey: 'testBooleanFlag' }],
9797
})
9898
@Get('/flags-enabled')
9999
public async handleGuardedBooleanRequest() {
100100
return 'Get Boolean Flag Success!';
101101
}
102102

103103
@RequireFlagsEnabled({
104-
flagKeys: ['testBooleanFlag'],
104+
flags: [{ flagKey: 'testBooleanFlag' }],
105105
exception: new ForbiddenException(),
106106
})
107107
@Get('/flags-enabled-custom-exception')
108108
public async handleBooleanRequestWithCustomException() {
109109
return 'Get Boolean Flag Success!';
110110
}
111+
112+
@RequireFlagsEnabled({
113+
flags: [{ flagKey: 'testBooleanFlag' }],
114+
exception: new ForbiddenException(),
115+
context: {
116+
targetingKey: 'user-id',
117+
},
118+
})
119+
@Get('/flags-enabled-custom-exception-with-context')
120+
public async handleBooleanRequestWithCustomExceptionAndContext() {
121+
return 'Get Boolean Flag Success!';
122+
}
111123
}
112124

113125
@Controller()
@@ -127,7 +139,7 @@ export class OpenFeatureContextScopedController {
127139
}
128140

129141
@RequireFlagsEnabled({
130-
flagKeys: ['testBooleanFlag'],
142+
flags: [{ flagKey: 'testBooleanFlag' }],
131143
domain: 'domainScopedClient',
132144
})
133145
@Get('/controller-context/flags-enabled')
@@ -138,7 +150,7 @@ export class OpenFeatureContextScopedController {
138150

139151
@Controller('require-flags-enabled')
140152
@RequireFlagsEnabled({
141-
flagKeys: ['testBooleanFlag'],
153+
flags: [{ flagKey: 'testBooleanFlag2', defaultValue: true }, { flagKey: 'testBooleanFlag' }],
142154
exception: new ForbiddenException(),
143155
})
144156
export class OpenFeatureRequireFlagsEnabledController {

0 commit comments

Comments
 (0)