Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: set context during provider init on web #919

Merged
merged 10 commits into from
May 8, 2024
5 changes: 5 additions & 0 deletions .vscode/settings.json
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"cSpell.words": [
"domainless"
]
}
4 changes: 2 additions & 2 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,13 @@ export default {
displayName: 'react',
testEnvironment: 'jsdom',
preset: 'ts-jest',
testMatch: ['<rootDir>/packages/react/test/**/*.spec.ts*'],
testMatch: ['<rootDir>/packages/react/test/**/*.spec.{ts,tsx}'],
moduleNameMapper: {
'@openfeature/core': '<rootDir>/packages/shared/src',
'@openfeature/web-sdk': '<rootDir>/packages/client/src',
},
transform: {
'^.+\\.tsx$': [
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
tsconfig: '<rootDir>/packages/react/test/tsconfig.json',
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 27 additions & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ To register a provider and ensure it is ready before further actions are taken,

```ts
await OpenFeature.setProviderAndWait(new MyProvider());
```
```

#### Synchronous

Expand Down Expand Up @@ -155,9 +155,16 @@ Sometimes, the value of a flag must consider some dynamic criteria about the app
In OpenFeature, we refer to this as [targeting](https://openfeature.dev/specification/glossary#targeting).
If the flag management system you're using supports targeting, you can provide the input data using the [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context).

```ts
// Sets global context during provider registration
await OpenFeature.setProvider(new MyProvider(), { origin: document.location.host });
```

Change context after the provider has been registered using `setContext`.

```ts
// Set a value to the global context
await OpenFeature.setContext({ origin: document.location.host });
await OpenFeature.setContext({ targetingKey: localStorage.getItem("targetingKey") });
```

Context is global and setting it is `async`.
Expand Down Expand Up @@ -230,6 +237,24 @@ const domainScopedClient = OpenFeature.getClient("my-domain");
Domains can be defined on a provider during registration.
For more details, please refer to the [providers](#providers) section.

#### Manage evaluation context for domains

By default, domain-scoped clients use the global context.
This can be overridden by explicitly setting context when registering the provider or by references the domain when updating context.
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved

```ts
OpenFeature.setProvider("my-domain", new NewCachedProvider(), { isCache: true });
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved
```

Change context after the provider has been registered by using `setContext` with a name.
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved

```ts
await OpenFeature.setContext("my-domain", { targetingKey: localStorage.getItem("targetingKey") })
```

Once context has been defined for a named client, it will override the global context for all clients using the associated provider.
Context can be cleared using for a named provider using `OpenFeature.clearContext("my-domain")` or call `OpenFeature.clearContexts()` to reset all context.

### Eventing

Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions.
Expand Down
155 changes: 148 additions & 7 deletions packages/client/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,17 @@ type DomainRecord = {

const _globalThis = globalThis as OpenFeatureGlobal;

export class OpenFeatureAPI extends OpenFeatureCommonAPI<ClientProviderStatus, Provider, Hook> implements ManageContext<Promise<void>> {
export class OpenFeatureAPI
extends OpenFeatureCommonAPI<ClientProviderStatus, Provider, Hook>
implements ManageContext<Promise<void>>
{
protected _statusEnumType: typeof ProviderStatus = ProviderStatus;
protected _apiEmitter: GenericEventEmitter<ProviderEvents> = new OpenFeatureEventEmitter();
protected _defaultProvider: ProviderWrapper<Provider, ClientProviderStatus> = new ProviderWrapper(NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType);
protected _defaultProvider: ProviderWrapper<Provider, ClientProviderStatus> = new ProviderWrapper(
NOOP_PROVIDER,
ProviderStatus.NOT_READY,
this._statusEnumType,
);
protected _domainScopedProviders: Map<string, ProviderWrapper<Provider, ClientProviderStatus>> = new Map();
protected _createEventEmitter = () => new OpenFeatureEventEmitter();

Expand Down Expand Up @@ -61,6 +68,142 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<ClientProviderStatus, P
return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status;
}

/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(provider: Provider): Promise<void>;
/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(provider: Provider, context: EvaluationContext): Promise<void>;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise<void>;
async setProviderAndWait(
clientOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
contextOrUndefined?: EvaluationContext,
): Promise<void> {
const domain = stringOrUndefined(clientOrProvider);
const provider = domain
? objectOrUndefined<Provider>(providerContextOrUndefined)
: objectOrUndefined<Provider>(clientOrProvider);
const context = domain
? objectOrUndefined<EvaluationContext>(contextOrUndefined)
: objectOrUndefined<EvaluationContext>(providerContextOrUndefined);

if (context) {
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
this._domainScopedContext.set(domain, context);
} else {
this._context = context;
}
}

await this.setAwaitableProvider(domain, provider);
}

/**
* Sets the default provider for flag evaluations.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {this} OpenFeature API
*/
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved
setProvider(provider: Provider): this;
/**
* Sets the default provider and evaluation context for flag evaluations.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param context {EvaluationContext} The evaluation context to use for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(provider: Provider, context: EvaluationContext): this;
/**
* Sets the provider for flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(domain: string, provider: Provider): this;
/**
* Sets the provider and evaluation context flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param context {EvaluationContext} The evaluation context to use for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(domain: string, provider: Provider, context: EvaluationContext): this;
setProvider(
domainOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
contextOrUndefined?: EvaluationContext,
): this {
const domain = stringOrUndefined(domainOrProvider);
const provider = domain
? objectOrUndefined<Provider>(providerContextOrUndefined)
: objectOrUndefined<Provider>(domainOrProvider);
const context = domain
? objectOrUndefined<EvaluationContext>(contextOrUndefined)
: objectOrUndefined<EvaluationContext>(providerContextOrUndefined);

if (context) {
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
this._domainScopedContext.set(domain, context);
} else {
this._context = context;
}
}

const maybePromise = this.setAwaitableProvider(domain, provider);

// The setProvider method doesn't return a promise so we need to catch and
// log any errors that occur during provider initialization to avoid having
// an unhandled promise rejection.
if (maybePromise instanceof Promise) {
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved
maybePromise.catch((err) => {
this._logger.error('Error during provider initialization:', err);
});
}
return this;
}

/**
* Sets the evaluation context globally.
* This will be used by all providers that have not bound to a domain.
Expand Down Expand Up @@ -111,9 +254,7 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<ClientProviderStatus, P
...unboundProviders,
];
await Promise.all(
allDomainRecords.map((dm) =>
this.runProviderContextChangeHandler(dm.domain, dm.wrapper, oldContext, context),
),
allDomainRecords.map((dm) => this.runProviderContextChangeHandler(dm.domain, dm.wrapper, oldContext, context)),
);
}
}
Expand All @@ -129,7 +270,7 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<ClientProviderStatus, P
* @param {string} domain An identifier which logically binds clients with providers
* @returns {EvaluationContext} Evaluation context
*/
getContext(domain?: string): EvaluationContext;
getContext(domain?: string | undefined): EvaluationContext;
getContext(domainOrUndefined?: string): EvaluationContext {
const domain = stringOrUndefined(domainOrUndefined);
if (domain) {
Expand Down Expand Up @@ -222,7 +363,7 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<ClientProviderStatus, P
): Promise<void> {
// this should always be set according to the typings, but let's be defensive considering JS
const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider';

try {
if (typeof wrapper.provider.onContextChange === 'function') {
wrapper.incrementPendingContextChanges();
Expand Down
43 changes: 42 additions & 1 deletion packages/client/test/evaluation-context.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { EvaluationContext, JsonValue, OpenFeature, Provider, ProviderMetadata, ResolutionDetails } from '../src';

const initializeMock = jest.fn();

class MockProvider implements Provider {
readonly metadata: ProviderMetadata;

constructor(options?: { name?: string }) {
this.metadata = { name: options?.name ?? 'mock-provider' };
}

initialize = initializeMock;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> {
return Promise.resolve();
Expand All @@ -15,7 +19,7 @@ class MockProvider implements Provider {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
resolveBooleanEvaluation = jest.fn((flagKey: string, defaultValue: boolean, context: EvaluationContext) => {
return {
value: true
value: true,
};
});

Expand All @@ -35,6 +39,7 @@ class MockProvider implements Provider {
describe('Evaluation Context', () => {
afterEach(async () => {
await OpenFeature.clearContexts();
jest.clearAllMocks();
});

describe('Requirement 3.2.2', () => {
Expand All @@ -59,6 +64,42 @@ describe('Evaluation Context', () => {
expect(OpenFeature.getContext('invalid')).toEqual(defaultContext);
});

describe('Set context during provider registration', () => {
it('should set the context for the default provider', () => {
const context: EvaluationContext = { property1: false };
const provider = new MockProvider();
OpenFeature.setProvider(provider, context);
expect(OpenFeature.getContext()).toEqual(context);
});

it('should set the context for a domain', async () => {
const context: EvaluationContext = { property1: false };
const domain = 'test';
const provider = new MockProvider({ name: domain });
OpenFeature.setProvider(domain, provider, context);
expect(OpenFeature.getContext()).toEqual({});
expect(OpenFeature.getContext(domain)).toEqual(context);
});

it('should set the context for the default provider prior to initialization', async () => {
const context: EvaluationContext = { property1: false };
const provider = new MockProvider();
await OpenFeature.setProviderAndWait(provider, context);
expect(initializeMock).toHaveBeenCalledWith(context);
expect(OpenFeature.getContext()).toEqual(context);
});

it('should set the context for a domain prior to initialization', async () => {
const context: EvaluationContext = { property1: false };
const domain = 'test';
const provider = new MockProvider({ name: domain });
await OpenFeature.setProviderAndWait(domain, provider, context);
expect(OpenFeature.getContext()).toEqual({});
expect(OpenFeature.getContext(domain)).toEqual(context);
expect(initializeMock).toHaveBeenCalledWith(context);
});
});

describe('Context Management', () => {
it('should reset global context', async () => {
const globalContext: EvaluationContext = { scope: 'global' };
Expand Down
2 changes: 1 addition & 1 deletion packages/react/test/options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('normalizeOptions', () => {
});
});

// we fallback the more specific suspense props (`ssuspendUntilReady` and `suspendWhileReconciling`) to `suspend`
// we fallback the more specific suspense props (`suspendUntilReady` and `suspendWhileReconciling`) to `suspend`
describe('suspend fallback', () => {
it('should fallback to true suspend value', () => {
const normalized = normalizeOptions({
Expand Down
Loading
Loading