diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..8791b70b5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "domainless" + ] +} \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts index c4b08a5cb..b048809e3 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -175,13 +175,13 @@ export default { displayName: 'react', testEnvironment: 'jsdom', preset: 'ts-jest', - testMatch: ['/packages/react/test/**/*.spec.ts*'], + testMatch: ['/packages/react/test/**/*.spec.{ts,tsx}'], moduleNameMapper: { '@openfeature/core': '/packages/shared/src', '@openfeature/web-sdk': '/packages/client/src', }, transform: { - '^.+\\.tsx$': [ + '^.+\\.(ts|tsx)$': [ 'ts-jest', { tsconfig: '/packages/react/test/tsconfig.json', diff --git a/packages/client/README.md b/packages/client/README.md index 75c196e31..03fb659bf 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -121,7 +121,7 @@ To register a provider and ensure it is ready before further actions are taken, ```ts await OpenFeature.setProviderAndWait(new MyProvider()); -``` +``` #### Synchronous @@ -158,9 +158,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`. @@ -233,6 +240,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: + +```ts +OpenFeature.setProvider("my-domain", new NewCachedProvider(), { targetingKey: localStorage.getItem("targetingKey") }); +``` + +To change context after the provider has been registered, use `setContext` with a name: + +```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. diff --git a/packages/client/src/open-feature.ts b/packages/client/src/open-feature.ts index c0bcde6c2..cae2d2fae 100644 --- a/packages/client/src/open-feature.ts +++ b/packages/client/src/open-feature.ts @@ -69,6 +69,140 @@ export class OpenFeatureAPI 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} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(provider: Provider): Promise; + /** + * 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} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(provider: Provider, context: EvaluationContext): Promise; + /** + * 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} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(domain: string, provider: Provider): Promise; + /** + * 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} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise; + async setProviderAndWait( + clientOrProvider?: string | Provider, + providerContextOrUndefined?: Provider | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): Promise { + const domain = stringOrUndefined(clientOrProvider); + const provider = domain + ? objectOrUndefined(providerContextOrUndefined) + : objectOrUndefined(clientOrProvider); + const context = domain + ? objectOrUndefined(contextOrUndefined) + : objectOrUndefined(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 + */ + 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(providerContextOrUndefined) + : objectOrUndefined(domainOrProvider); + const context = domain + ? objectOrUndefined(contextOrUndefined) + : objectOrUndefined(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. + Promise.resolve(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. @@ -135,7 +269,7 @@ export class OpenFeatureAPI * @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) { diff --git a/packages/client/test/evaluation-context.spec.ts b/packages/client/test/evaluation-context.spec.ts index 1d9c66e4a..4b6b24ed8 100644 --- a/packages/client/test/evaluation-context.spec.ts +++ b/packages/client/test/evaluation-context.spec.ts @@ -1,5 +1,7 @@ import { EvaluationContext, JsonValue, OpenFeature, Provider, ProviderMetadata, ResolutionDetails } from '../src'; +const initializeMock = jest.fn(); + class MockProvider implements Provider { readonly metadata: ProviderMetadata; @@ -7,6 +9,8 @@ class MockProvider implements Provider { this.metadata = { name: options?.name ?? 'mock-provider' }; } + initialize = initializeMock; + // eslint-disable-next-line @typescript-eslint/no-unused-vars onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise { return Promise.resolve(); @@ -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, }; }); @@ -35,6 +39,7 @@ class MockProvider implements Provider { describe('Evaluation Context', () => { afterEach(async () => { await OpenFeature.clearContexts(); + jest.clearAllMocks(); }); describe('Requirement 3.2.2', () => { @@ -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' }; diff --git a/packages/react/test/options.spec.ts b/packages/react/test/options.spec.ts index e5f120a64..c8c04bf83 100644 --- a/packages/react/test/options.spec.ts +++ b/packages/react/test/options.spec.ts @@ -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({ diff --git a/packages/server/README.md b/packages/server/README.md index 6a8eee661..691311ff0 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -161,6 +161,9 @@ const requestContext = { const boolValue = await client.getBooleanValue('some-flag', false, requestContext); ``` +Context is merged by the SDK before a flag evaluation occurs. +The merge order is defined [here](https://openfeature.dev/specification/sections/evaluation-context#requirement-323) in the OpenFeature specification. + ### Hooks [Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle. diff --git a/packages/server/src/open-feature.ts b/packages/server/src/open-feature.ts index 09b00250d..f0313fd86 100644 --- a/packages/server/src/open-feature.ts +++ b/packages/server/src/open-feature.ts @@ -73,6 +73,68 @@ export class OpenFeatureAPI 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} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(provider: Provider): Promise; + /** + * 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} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(domain: string, provider: Provider): Promise; + async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise { + const domain = stringOrUndefined(domainOrProvider); + const provider = domain + ? objectOrUndefined(providerOrUndefined) + : objectOrUndefined(domainOrProvider); + + 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 + */ + setProvider(provider: Provider): 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; + setProvider(clientOrProvider?: string | Provider, providerOrUndefined?: Provider): this { + const domain = stringOrUndefined(clientOrProvider); + const provider = domain + ? objectOrUndefined(providerOrUndefined) + : objectOrUndefined(clientOrProvider); + + 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. + Promise.resolve(maybePromise).catch((err) => { + this._logger.error('Error during provider initialization:', err); + }); + + return this; + } + setContext(context: EvaluationContext): this { this._context = context; return this; diff --git a/packages/server/test/evaluation-context.spec.ts b/packages/server/test/evaluation-context.spec.ts new file mode 100644 index 000000000..1ea2d50ad --- /dev/null +++ b/packages/server/test/evaluation-context.spec.ts @@ -0,0 +1,26 @@ +import { EvaluationContext, OpenFeature } from '../src'; + +describe('Evaluation Context', () => { + afterEach(async () => { + OpenFeature.setContext({}); + jest.clearAllMocks(); + }); + + describe('Requirement 3.2.2', () => { + it('the API MUST have a method for setting the global evaluation context', () => { + const context: EvaluationContext = { property1: false }; + OpenFeature.setContext(context); + expect(OpenFeature.getContext()).toEqual(context); + }); + + it('the API MUST have a method for setting evaluation context for a named client', () => { + const context: EvaluationContext = { property1: false }; + OpenFeature.setContext(context); + expect(OpenFeature.getContext()).toEqual(context); + }); + }); + + describe.skip('Requirement 3.2.4', () => { + // Only applies to the static-context paradigm + }); +}); diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 4ba7e3763..2e7f32a10 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -7,7 +7,7 @@ import { EventHandler, Eventing, GenericEventEmitter, - statusMatchesEvent + statusMatchesEvent, } from './events'; import { isDefined } from './filter'; import { BaseHook, EvaluationLifeCycle } from './hooks'; @@ -25,7 +25,11 @@ type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus; export class ProviderWrapper

, S extends AnyProviderStatus> { private _pendingContextChanges = 0; - constructor(private _provider: P, private _status: S, _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus) { + constructor( + private _provider: P, + private _status: S, + _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus, + ) { // update the providers status with events _provider.events?.addHandler(AllProviderEvents.Ready, () => { // These casts are due to the face we don't "know" what status enum we are dealing with here (client or server). @@ -50,7 +54,7 @@ export class ProviderWrapper

, S exte set provider(provider: P) { this._provider = provider; - } + } get status(): S { return this._status; @@ -73,8 +77,15 @@ export class ProviderWrapper

, S exte } } -export abstract class OpenFeatureCommonAPI = CommonProvider, H extends BaseHook = BaseHook> - implements Eventing, EvaluationLifeCycle>, ManageLogger> +export abstract class OpenFeatureCommonAPI< + S extends AnyProviderStatus, + P extends CommonProvider = CommonProvider, + H extends BaseHook = BaseHook, + > + implements + Eventing, + EvaluationLifeCycle>, + ManageLogger> { // accessor for the type of the ProviderStatus enum (client or server) protected abstract readonly _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus; @@ -185,60 +196,19 @@ export abstract class OpenFeatureCommonAPI} - * @throws Uncaught exceptions thrown by the provider during initialization. - */ - async setProviderAndWait(provider: P): Promise; - /** - * 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 in the same domain. - * @template P - * @param {string} domain An identifier which logically binds clients with providers - * @param {P} provider The provider responsible for flag evaluations. - * @returns {Promise} - * @throws Uncaught exceptions thrown by the provider during initialization. - */ - async setProviderAndWait(domain: string, provider: P): Promise; - async setProviderAndWait(domainOrProvider?: string | P, providerOrUndefined?: P): Promise { - await this.setAwaitableProvider(domainOrProvider, providerOrUndefined); - } + abstract setProviderAndWait( + clientOrProvider?: string | P, + providerContextOrUndefined?: P | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): Promise; - /** - * Sets the default provider for flag evaluations. - * The default 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. - * @template P - * @param {P} provider The provider responsible for flag evaluations. - * @returns {this} OpenFeature API - */ - setProvider(provider: P): this; - /** - * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. - * Setting a provider supersedes the current provider used in new and existing clients in the same domain. - * @template P - * @param {string} domain An identifier which logically binds clients with providers - * @param {P} provider The provider responsible for flag evaluations. - * @returns {this} OpenFeature API - */ - setProvider(domain: string, provider: P): this; - setProvider(domainOrProvider?: string | P, providerOrUndefined?: P): this { - const maybePromise = this.setAwaitableProvider(domainOrProvider, providerOrUndefined); - if (maybePromise) { - maybePromise.catch(() => { - /* ignore, errors are emitted via the event emitter */ - }); - } - return this; - } + abstract setProvider( + clientOrProvider?: string | P, + providerContextOrUndefined?: P | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): this; - private setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void { + protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void { const domain = stringOrUndefined(domainOrProvider); const provider = objectOrUndefined

(domainOrProvider) ?? objectOrUndefined

(providerOrUndefined); @@ -265,7 +235,11 @@ export abstract class OpenFeatureCommonAPI | void = undefined; - const wrappedProvider = new ProviderWrapper(provider, this._statusEnumType.NOT_READY, this._statusEnumType); + const wrappedProvider = new ProviderWrapper( + provider, + this._statusEnumType.NOT_READY, + this._statusEnumType, + ); // initialize the provider if it implements "initialize" and it's not already registered if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) { @@ -438,7 +412,11 @@ export abstract class OpenFeatureCommonAPI(defaultProvider, this._statusEnumType.NOT_READY, this._statusEnumType); + this._defaultProvider = new ProviderWrapper( + defaultProvider, + this._statusEnumType.NOT_READY, + this._statusEnumType, + ); } }