Skip to content

Commit 95524f4

Browse files
authored
feat: add evaluation context management to the web SDK (#704)
## This PR - adds the ability to manage named context in the web SDK ### Related Issues Fixes #685 ### Notes This change requires additions to the spec before it can be merged. I'll mark this PR as a draft until the spec has been updated. I also noticed that we currently use the term "named clients" to describe the scoped providers. I believe this terminology is confusing and made writing the JSDocs difficult because the client name shouldn't be important when setting context. I think we should consider using the term "Provider Namespace". In my opinion, this more accurately describes the behavior and could be introduced in a non-breaking way. --------- Signed-off-by: Michael Beemer <[email protected]>
1 parent 0e1ff8b commit 95524f4

File tree

2 files changed

+239
-28
lines changed

2 files changed

+239
-28
lines changed

packages/client/src/open-feature.ts

Lines changed: 132 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { EvaluationContext, ManageContext, OpenFeatureCommonAPI } from '@openfeature/core';
1+
import {
2+
EvaluationContext,
3+
ManageContext,
4+
OpenFeatureCommonAPI,
5+
objectOrUndefined,
6+
stringOrUndefined,
7+
} from '@openfeature/core';
28
import { Client, OpenFeatureClient } from './client';
39
import { NOOP_PROVIDER, Provider } from './provider';
410
import { OpenFeatureEventEmitter } from './events';
@@ -16,8 +22,8 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
1622
protected _events = new OpenFeatureEventEmitter();
1723
protected _defaultProvider: Provider = NOOP_PROVIDER;
1824
protected _createEventEmitter = () => new OpenFeatureEventEmitter();
25+
protected _namedProviderContext: Map<string, EvaluationContext> = new Map();
1926

20-
// eslint-disable-next-line @typescript-eslint/no-empty-function
2127
private constructor() {
2228
super('client');
2329
}
@@ -38,26 +44,120 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
3844
return instance;
3945
}
4046

41-
async setContext(context: EvaluationContext): Promise<void> {
42-
const oldContext = this._context;
43-
this._context = context;
44-
45-
const allProviders = [this._defaultProvider, ...this._clientProviders.values()];
46-
await Promise.all(
47-
allProviders.map(async (provider) => {
48-
try {
49-
return await provider.onContextChange?.(oldContext, context);
50-
} catch (err) {
51-
this._logger?.error(`Error running context change handler of provider ${provider.metadata.name}:`, err);
52-
}
53-
}),
54-
);
47+
/**
48+
* Sets the evaluation context globally.
49+
* This will be used by all providers that have not been overridden with a named client.
50+
* @param {EvaluationContext} context Evaluation context
51+
* @example
52+
* await OpenFeature.setContext({ region: "us" });
53+
*/
54+
async setContext(context: EvaluationContext): Promise<void>;
55+
/**
56+
* Sets the evaluation context for a specific provider.
57+
* This will only affect providers with a matching client name.
58+
* @param {string} clientName The name to identify the client
59+
* @param {EvaluationContext} context Evaluation context
60+
* @example
61+
* await OpenFeature.setContext("test", { scope: "provider" });
62+
* OpenFeature.setProvider(new MyProvider()) // Uses the default context
63+
* OpenFeature.setProvider("test", new MyProvider()) // Uses context: { scope: "provider" }
64+
*/
65+
async setContext(clientName: string, context: EvaluationContext): Promise<void>;
66+
async setContext<T extends EvaluationContext>(nameOrContext: T | string, contextOrUndefined?: T): Promise<void> {
67+
const clientName = stringOrUndefined(nameOrContext);
68+
const context = objectOrUndefined<T>(nameOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {};
69+
70+
if (clientName) {
71+
const provider = this._clientProviders.get(clientName);
72+
if (provider) {
73+
const oldContext = this.getContext(clientName);
74+
this._namedProviderContext.set(clientName, context);
75+
await this.runProviderContextChangeHandler(provider, oldContext, context);
76+
} else {
77+
this._namedProviderContext.set(clientName, context);
78+
}
79+
} else {
80+
const oldContext = this._context;
81+
this._context = context;
82+
83+
const providersWithoutContextOverride = Array.from(this._clientProviders.entries())
84+
.filter(([name]) => !this._namedProviderContext.has(name))
85+
.reduce<Provider[]>((acc, [, provider]) => {
86+
acc.push(provider);
87+
return acc;
88+
}, []);
89+
90+
const allProviders = [this._defaultProvider, ...providersWithoutContextOverride];
91+
await Promise.all(
92+
allProviders.map((provider) => this.runProviderContextChangeHandler(provider, oldContext, context)),
93+
);
94+
}
5595
}
5696

57-
getContext(): EvaluationContext {
97+
/**
98+
* Access the global evaluation context.
99+
* @returns {EvaluationContext} Evaluation context
100+
*/
101+
getContext(): EvaluationContext;
102+
/**
103+
* Access the evaluation context for a specific named client.
104+
* The global evaluation context is returned if a matching named client is not found.
105+
* @param {string} clientName The name to identify the client
106+
* @returns {EvaluationContext} Evaluation context
107+
*/
108+
getContext(clientName: string): EvaluationContext;
109+
getContext(nameOrUndefined?: string): EvaluationContext {
110+
const clientName = stringOrUndefined(nameOrUndefined);
111+
if (clientName) {
112+
const context = this._namedProviderContext.get(clientName);
113+
if (context) {
114+
return context;
115+
} else {
116+
this._logger.debug(`Unable to find context for '${clientName}'.`);
117+
}
118+
}
58119
return this._context;
59120
}
60121

122+
/**
123+
* Resets the global evaluation context to an empty object.
124+
*/
125+
clearContext(): Promise<void>;
126+
/**
127+
* Removes the evaluation context for a specific named client.
128+
* @param {string} clientName The name to identify the client
129+
*/
130+
clearContext(clientName: string): Promise<void>;
131+
async clearContext(nameOrUndefined?: string): Promise<void> {
132+
const clientName = stringOrUndefined(nameOrUndefined);
133+
if (clientName) {
134+
const provider = this._clientProviders.get(clientName);
135+
if (provider) {
136+
const oldContext = this.getContext(clientName);
137+
this._namedProviderContext.delete(clientName);
138+
const newContext = this.getContext();
139+
await this.runProviderContextChangeHandler(provider, oldContext, newContext);
140+
} else {
141+
this._namedProviderContext.delete(clientName);
142+
}
143+
} else {
144+
return this.setContext({});
145+
}
146+
}
147+
148+
/**
149+
* Resets the global evaluation context and removes the evaluation context for
150+
* all named clients.
151+
*/
152+
async clearContexts(): Promise<void> {
153+
// Default context must be cleared first to avoid calling the onContextChange
154+
// handler multiple times for named clients.
155+
await this.clearContext();
156+
157+
// Use allSettled so a promise rejection doesn't affect others
158+
await Promise.allSettled(Array.from(this._clientProviders.keys()).map((name) => this.clearContext(name)));
159+
}
160+
61161
/**
62162
* A factory function for creating new named OpenFeature clients. Clients can contain
63163
* their own state (e.g. logger, hook, context). Multiple clients can be used
@@ -84,8 +184,21 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
84184
* Clears all registered providers and resets the default provider.
85185
* @returns {Promise<void>}
86186
*/
87-
clearProviders(): Promise<void> {
88-
return super.clearProvidersAndSetDefault(NOOP_PROVIDER);
187+
async clearProviders(): Promise<void> {
188+
await super.clearProvidersAndSetDefault(NOOP_PROVIDER);
189+
this._namedProviderContext.clear();
190+
}
191+
192+
private async runProviderContextChangeHandler(
193+
provider: Provider,
194+
oldContext: EvaluationContext,
195+
newContext: EvaluationContext,
196+
): Promise<void> {
197+
try {
198+
return await provider.onContextChange?.(oldContext, newContext);
199+
} catch (err) {
200+
this._logger?.error(`Error running ${provider.metadata.name}'s context change handler:`, err);
201+
}
89202
}
90203
}
91204

packages/client/test/evaluation-context.spec.ts

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,75 @@ class MockProvider implements Provider {
3030
}
3131

3232
describe('Evaluation Context', () => {
33+
afterEach(async () => {
34+
await OpenFeature.clearContexts();
35+
});
36+
3337
describe('Requirement 3.2.2', () => {
34-
it('the API MUST have a method for setting the global evaluation context', () => {
38+
it('the API MUST have a method for setting the global evaluation context', async () => {
3539
const context: EvaluationContext = { property1: false };
36-
OpenFeature.setContext(context);
40+
await OpenFeature.setContext(context);
3741
expect(OpenFeature.getContext()).toEqual(context);
3842
});
43+
44+
it('the API MUST have a method for setting evaluation context for a named client', async () => {
45+
const context: EvaluationContext = { property1: false };
46+
const clientName = 'valid';
47+
await OpenFeature.setContext(clientName, context);
48+
expect(OpenFeature.getContext(clientName)).toEqual(context);
49+
});
50+
51+
it('the API MUST return the default context if not match is found', async () => {
52+
const defaultContext: EvaluationContext = { name: 'test' };
53+
const nameContext: EvaluationContext = { property1: false };
54+
await OpenFeature.setContext(defaultContext);
55+
await OpenFeature.setContext('test', nameContext);
56+
expect(OpenFeature.getContext('invalid')).toEqual(defaultContext);
57+
});
58+
59+
describe('Context Management', () => {
60+
it('should reset global context', async () => {
61+
const globalContext: EvaluationContext = { scope: 'global' };
62+
await OpenFeature.setContext(globalContext);
63+
expect(OpenFeature.getContext()).toEqual(globalContext);
64+
await OpenFeature.clearContext();
65+
expect(OpenFeature.getContext()).toEqual({});
66+
});
67+
68+
it('should remove context from a name provider', async () => {
69+
const globalContext: EvaluationContext = { scope: 'global' };
70+
const testContext: EvaluationContext = { scope: 'test' };
71+
const clientName = 'test';
72+
await OpenFeature.setContext(globalContext);
73+
await OpenFeature.setContext(clientName, testContext);
74+
expect(OpenFeature.getContext(clientName)).toEqual(testContext);
75+
await OpenFeature.clearContext(clientName);
76+
expect(OpenFeature.getContext(clientName)).toEqual(globalContext);
77+
});
78+
79+
it('should only call a providers onContextChange once when clearing context', async () => {
80+
const globalContext: EvaluationContext = { scope: 'global' };
81+
const testContext: EvaluationContext = { scope: 'test' };
82+
const clientName = 'test';
83+
await OpenFeature.setContext(globalContext);
84+
await OpenFeature.setContext(clientName, testContext);
85+
86+
const defaultProvider = new MockProvider();
87+
const provider1 = new MockProvider();
88+
89+
OpenFeature.setProvider(defaultProvider);
90+
OpenFeature.setProvider(clientName, provider1);
91+
92+
// Spy on context changed handlers of all providers
93+
const contextChangedSpies = [defaultProvider, provider1].map((provider) =>
94+
jest.spyOn(provider, 'onContextChange'),
95+
);
96+
97+
await OpenFeature.clearContexts();
98+
99+
contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledTimes(1));
100+
});
101+
});
39102
});
40103

41104
describe('Requirement 3.2.4', () => {
@@ -55,15 +118,50 @@ describe('Evaluation Context', () => {
55118
OpenFeature.setProvider('client2', provider2);
56119

57120
// Spy on context changed handlers of all providers
58-
const contextChangedSpys = [defaultProvider, provider1, provider2].map((provider) =>
59-
jest.spyOn(provider, 'onContextChange')
121+
const contextChangedSpies = [defaultProvider, provider1, provider2].map((provider) =>
122+
jest.spyOn(provider, 'onContextChange'),
60123
);
61124

62125
// Change context
63126
const newContext: EvaluationContext = { property1: true, property2: 'prop2' };
64127
await OpenFeature.setContext(newContext);
65128

66-
contextChangedSpys.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
129+
contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
130+
});
131+
132+
it('on only the providers using the default context', async () => {
133+
// Set initial context
134+
const context: EvaluationContext = { property1: false };
135+
await OpenFeature.setContext(context);
136+
137+
// Set some providers
138+
const defaultProvider = new MockProvider();
139+
const provider1 = new MockProvider();
140+
const provider2 = new MockProvider();
141+
142+
const client1 = 'client1';
143+
const client2 = 'client2';
144+
145+
OpenFeature.setProvider(defaultProvider);
146+
OpenFeature.setProvider(client1, provider1);
147+
OpenFeature.setProvider(client2, provider2);
148+
149+
// Set context for client1
150+
await OpenFeature.setContext(client1, { property1: 'test' });
151+
152+
// Spy on context changed handlers of all providers
153+
const contextShouldChangeSpies = [defaultProvider, provider2].map((provider) =>
154+
jest.spyOn(provider, 'onContextChange'),
155+
);
156+
157+
const contextShouldntChangeSpies = jest.spyOn(provider1, 'onContextChange');
158+
159+
// Change context
160+
const newContext: EvaluationContext = { property1: true, property2: 'prop2' };
161+
await OpenFeature.setContext(newContext);
162+
163+
contextShouldChangeSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
164+
expect(contextShouldntChangeSpies).not.toHaveBeenCalled();
67165
});
68166

69167
it('on all registered providers even if one fails', async () => {
@@ -81,18 +179,18 @@ describe('Evaluation Context', () => {
81179
OpenFeature.setProvider('client2', provider2);
82180

83181
// Spy on context changed handlers of all providers
84-
const contextChangedSpys = [defaultProvider, provider1, provider2].map((provider) =>
85-
jest.spyOn(provider, 'onContextChange')
182+
const contextChangedSpies = [defaultProvider, provider1, provider2].map((provider) =>
183+
jest.spyOn(provider, 'onContextChange'),
86184
);
87185

88186
// Let first handler fail
89-
contextChangedSpys[0].mockImplementation(() => Promise.reject(new Error('Error')));
187+
contextChangedSpies[0].mockImplementation(() => Promise.reject(new Error('Error')));
90188

91189
// Change context
92190
const newContext: EvaluationContext = { property1: true, property2: 'prop2' };
93191
await OpenFeature.setContext(newContext);
94192

95-
contextChangedSpys.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
193+
contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
96194
});
97195
});
98196
});

0 commit comments

Comments
 (0)