Skip to content

Commit

Permalink
feat: add evaluation context management to the web SDK (#704)
Browse files Browse the repository at this point in the history
## 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]>
  • Loading branch information
beeme1mr authored Dec 8, 2023
1 parent 0e1ff8b commit 95524f4
Show file tree
Hide file tree
Showing 2 changed files with 239 additions and 28 deletions.
151 changes: 132 additions & 19 deletions packages/client/src/open-feature.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { EvaluationContext, ManageContext, OpenFeatureCommonAPI } from '@openfeature/core';
import {
EvaluationContext,
ManageContext,
OpenFeatureCommonAPI,
objectOrUndefined,
stringOrUndefined,
} from '@openfeature/core';
import { Client, OpenFeatureClient } from './client';
import { NOOP_PROVIDER, Provider } from './provider';
import { OpenFeatureEventEmitter } from './events';
Expand All @@ -16,8 +22,8 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
protected _events = new OpenFeatureEventEmitter();
protected _defaultProvider: Provider = NOOP_PROVIDER;
protected _createEventEmitter = () => new OpenFeatureEventEmitter();
protected _namedProviderContext: Map<string, EvaluationContext> = new Map();

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

async setContext(context: EvaluationContext): Promise<void> {
const oldContext = this._context;
this._context = context;

const allProviders = [this._defaultProvider, ...this._clientProviders.values()];
await Promise.all(
allProviders.map(async (provider) => {
try {
return await provider.onContextChange?.(oldContext, context);
} catch (err) {
this._logger?.error(`Error running context change handler of provider ${provider.metadata.name}:`, err);
}
}),
);
/**
* Sets the evaluation context globally.
* This will be used by all providers that have not been overridden with a named client.
* @param {EvaluationContext} context Evaluation context
* @example
* await OpenFeature.setContext({ region: "us" });
*/
async setContext(context: EvaluationContext): Promise<void>;
/**
* Sets the evaluation context for a specific provider.
* This will only affect providers with a matching client name.
* @param {string} clientName The name to identify the client
* @param {EvaluationContext} context Evaluation context
* @example
* await OpenFeature.setContext("test", { scope: "provider" });
* OpenFeature.setProvider(new MyProvider()) // Uses the default context
* OpenFeature.setProvider("test", new MyProvider()) // Uses context: { scope: "provider" }
*/
async setContext(clientName: string, context: EvaluationContext): Promise<void>;
async setContext<T extends EvaluationContext>(nameOrContext: T | string, contextOrUndefined?: T): Promise<void> {
const clientName = stringOrUndefined(nameOrContext);
const context = objectOrUndefined<T>(nameOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {};

if (clientName) {
const provider = this._clientProviders.get(clientName);
if (provider) {
const oldContext = this.getContext(clientName);
this._namedProviderContext.set(clientName, context);
await this.runProviderContextChangeHandler(provider, oldContext, context);
} else {
this._namedProviderContext.set(clientName, context);
}
} else {
const oldContext = this._context;
this._context = context;

const providersWithoutContextOverride = Array.from(this._clientProviders.entries())
.filter(([name]) => !this._namedProviderContext.has(name))
.reduce<Provider[]>((acc, [, provider]) => {
acc.push(provider);
return acc;
}, []);

const allProviders = [this._defaultProvider, ...providersWithoutContextOverride];
await Promise.all(
allProviders.map((provider) => this.runProviderContextChangeHandler(provider, oldContext, context)),
);
}
}

getContext(): EvaluationContext {
/**
* Access the global evaluation context.
* @returns {EvaluationContext} Evaluation context
*/
getContext(): EvaluationContext;
/**
* Access the evaluation context for a specific named client.
* The global evaluation context is returned if a matching named client is not found.
* @param {string} clientName The name to identify the client
* @returns {EvaluationContext} Evaluation context
*/
getContext(clientName: string): EvaluationContext;
getContext(nameOrUndefined?: string): EvaluationContext {
const clientName = stringOrUndefined(nameOrUndefined);
if (clientName) {
const context = this._namedProviderContext.get(clientName);
if (context) {
return context;
} else {
this._logger.debug(`Unable to find context for '${clientName}'.`);
}
}
return this._context;
}

/**
* Resets the global evaluation context to an empty object.
*/
clearContext(): Promise<void>;
/**
* Removes the evaluation context for a specific named client.
* @param {string} clientName The name to identify the client
*/
clearContext(clientName: string): Promise<void>;
async clearContext(nameOrUndefined?: string): Promise<void> {
const clientName = stringOrUndefined(nameOrUndefined);
if (clientName) {
const provider = this._clientProviders.get(clientName);
if (provider) {
const oldContext = this.getContext(clientName);
this._namedProviderContext.delete(clientName);
const newContext = this.getContext();
await this.runProviderContextChangeHandler(provider, oldContext, newContext);
} else {
this._namedProviderContext.delete(clientName);
}
} else {
return this.setContext({});
}
}

/**
* Resets the global evaluation context and removes the evaluation context for
* all named clients.
*/
async clearContexts(): Promise<void> {
// Default context must be cleared first to avoid calling the onContextChange
// handler multiple times for named clients.
await this.clearContext();

// Use allSettled so a promise rejection doesn't affect others
await Promise.allSettled(Array.from(this._clientProviders.keys()).map((name) => this.clearContext(name)));
}

/**
* A factory function for creating new named OpenFeature clients. Clients can contain
* their own state (e.g. logger, hook, context). Multiple clients can be used
Expand All @@ -84,8 +184,21 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
* Clears all registered providers and resets the default provider.
* @returns {Promise<void>}
*/
clearProviders(): Promise<void> {
return super.clearProvidersAndSetDefault(NOOP_PROVIDER);
async clearProviders(): Promise<void> {
await super.clearProvidersAndSetDefault(NOOP_PROVIDER);
this._namedProviderContext.clear();
}

private async runProviderContextChangeHandler(
provider: Provider,
oldContext: EvaluationContext,
newContext: EvaluationContext,
): Promise<void> {
try {
return await provider.onContextChange?.(oldContext, newContext);
} catch (err) {
this._logger?.error(`Error running ${provider.metadata.name}'s context change handler:`, err);
}
}
}

Expand Down
116 changes: 107 additions & 9 deletions packages/client/test/evaluation-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,75 @@ class MockProvider implements Provider {
}

describe('Evaluation Context', () => {
afterEach(async () => {
await OpenFeature.clearContexts();
});

describe('Requirement 3.2.2', () => {
it('the API MUST have a method for setting the global evaluation context', () => {
it('the API MUST have a method for setting the global evaluation context', async () => {
const context: EvaluationContext = { property1: false };
OpenFeature.setContext(context);
await OpenFeature.setContext(context);
expect(OpenFeature.getContext()).toEqual(context);
});

it('the API MUST have a method for setting evaluation context for a named client', async () => {
const context: EvaluationContext = { property1: false };
const clientName = 'valid';
await OpenFeature.setContext(clientName, context);
expect(OpenFeature.getContext(clientName)).toEqual(context);
});

it('the API MUST return the default context if not match is found', async () => {
const defaultContext: EvaluationContext = { name: 'test' };
const nameContext: EvaluationContext = { property1: false };
await OpenFeature.setContext(defaultContext);
await OpenFeature.setContext('test', nameContext);
expect(OpenFeature.getContext('invalid')).toEqual(defaultContext);
});

describe('Context Management', () => {
it('should reset global context', async () => {
const globalContext: EvaluationContext = { scope: 'global' };
await OpenFeature.setContext(globalContext);
expect(OpenFeature.getContext()).toEqual(globalContext);
await OpenFeature.clearContext();
expect(OpenFeature.getContext()).toEqual({});
});

it('should remove context from a name provider', async () => {
const globalContext: EvaluationContext = { scope: 'global' };
const testContext: EvaluationContext = { scope: 'test' };
const clientName = 'test';
await OpenFeature.setContext(globalContext);
await OpenFeature.setContext(clientName, testContext);
expect(OpenFeature.getContext(clientName)).toEqual(testContext);
await OpenFeature.clearContext(clientName);
expect(OpenFeature.getContext(clientName)).toEqual(globalContext);
});

it('should only call a providers onContextChange once when clearing context', async () => {
const globalContext: EvaluationContext = { scope: 'global' };
const testContext: EvaluationContext = { scope: 'test' };
const clientName = 'test';
await OpenFeature.setContext(globalContext);
await OpenFeature.setContext(clientName, testContext);

const defaultProvider = new MockProvider();
const provider1 = new MockProvider();

OpenFeature.setProvider(defaultProvider);
OpenFeature.setProvider(clientName, provider1);

// Spy on context changed handlers of all providers
const contextChangedSpies = [defaultProvider, provider1].map((provider) =>
jest.spyOn(provider, 'onContextChange'),
);

await OpenFeature.clearContexts();

contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledTimes(1));
});
});
});

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

// Spy on context changed handlers of all providers
const contextChangedSpys = [defaultProvider, provider1, provider2].map((provider) =>
jest.spyOn(provider, 'onContextChange')
const contextChangedSpies = [defaultProvider, provider1, provider2].map((provider) =>
jest.spyOn(provider, 'onContextChange'),
);

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

contextChangedSpys.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
});

it('on only the providers using the default context', async () => {
// Set initial context
const context: EvaluationContext = { property1: false };
await OpenFeature.setContext(context);

// Set some providers
const defaultProvider = new MockProvider();
const provider1 = new MockProvider();
const provider2 = new MockProvider();

const client1 = 'client1';
const client2 = 'client2';

OpenFeature.setProvider(defaultProvider);
OpenFeature.setProvider(client1, provider1);
OpenFeature.setProvider(client2, provider2);

// Set context for client1
await OpenFeature.setContext(client1, { property1: 'test' });

// Spy on context changed handlers of all providers
const contextShouldChangeSpies = [defaultProvider, provider2].map((provider) =>
jest.spyOn(provider, 'onContextChange'),
);

const contextShouldntChangeSpies = jest.spyOn(provider1, 'onContextChange');

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

contextShouldChangeSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
expect(contextShouldntChangeSpies).not.toHaveBeenCalled();
});

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

// Spy on context changed handlers of all providers
const contextChangedSpys = [defaultProvider, provider1, provider2].map((provider) =>
jest.spyOn(provider, 'onContextChange')
const contextChangedSpies = [defaultProvider, provider1, provider2].map((provider) =>
jest.spyOn(provider, 'onContextChange'),
);

// Let first handler fail
contextChangedSpys[0].mockImplementation(() => Promise.reject(new Error('Error')));
contextChangedSpies[0].mockImplementation(() => Promise.reject(new Error('Error')));

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

contextChangedSpys.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
});
});
});
Expand Down

0 comments on commit 95524f4

Please sign in to comment.