diff --git a/packages/client/README.md b/packages/client/README.md index 4e9c9aad8..c1a98ec2a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -13,8 +13,8 @@

- - Specification + + Specification diff --git a/packages/client/src/client/open-feature-client.ts b/packages/client/src/client/open-feature-client.ts index 20442e7de..fb22f5780 100644 --- a/packages/client/src/client/open-feature-client.ts +++ b/packages/client/src/client/open-feature-client.ts @@ -1,5 +1,3 @@ -import { Client } from './client'; -import { Provider } from '../provider'; import { ClientMetadata, ErrorCode, @@ -20,8 +18,10 @@ import { SafeLogger, StandardResolutionReasons, } from '@openfeature/shared'; -import { OpenFeature } from '../open-feature'; import { FlagEvaluationOptions } from '../evaluation'; +import { OpenFeature } from '../open-feature'; +import { Provider } from '../provider'; +import { Client } from './client'; type OpenFeatureClientOptions = { name?: string; @@ -56,7 +56,7 @@ export class OpenFeatureClient implements Client { if (eventType === ProviderEvents.Ready && providerReady) { // run immediately, we're ready. try { - handler({ clientName: this.metadata.name }); + handler({ clientName: this.metadata.name, providerName: this._provider.metadata.name }); } catch (err) { this._logger?.error('Error running event handler:', err); } diff --git a/packages/client/test/client.spec.ts b/packages/client/test/client.spec.ts index ac8e3c96c..fb9d1fc1e 100644 --- a/packages/client/test/client.spec.ts +++ b/packages/client/test/client.spec.ts @@ -1,16 +1,16 @@ import { Client, - Provider, ErrorCode, EvaluationDetails, - JsonValue, + FlagNotFoundError, JsonArray, JsonObject, + JsonValue, + OpenFeature, + OpenFeatureClient, + Provider, ResolutionDetails, StandardResolutionReasons, - FlagNotFoundError, - OpenFeatureClient, - OpenFeature, } from '../src'; const BOOLEAN_VALUE = true; @@ -46,7 +46,6 @@ const MOCK_PROVIDER: Provider = { metadata: { name: 'mock', }, - events: undefined, hooks: [], initialize(): Promise { @@ -370,7 +369,6 @@ describe('Evaluation details structure', () => { metadata: { name: 'error-mock', }, - resolveNumberEvaluation: jest.fn((): Promise> => { throw new Error(NON_OPEN_FEATURE_ERROR_MESSAGE); // throw a non-open-feature error }), diff --git a/packages/client/test/events.spec.ts b/packages/client/test/events.spec.ts index 83082eb6e..9efa1dcfc 100644 --- a/packages/client/test/events.spec.ts +++ b/packages/client/test/events.spec.ts @@ -1,3 +1,4 @@ +import { v4 as uuid } from 'uuid'; import { JsonValue, NOOP_PROVIDER, @@ -10,7 +11,6 @@ import { ResolutionDetails, StaleEvent, } from '../src'; -import { v4 as uuid } from 'uuid'; const TIMEOUT = 1000; @@ -82,7 +82,9 @@ describe('Events', () => { jest.clearAllMocks(); clientId = uuid(); // hacky, but it's helpful to clear the handlers between tests + /* eslint-disable @typescript-eslint/no-explicit-any */ (OpenFeature as any)._clientEventHandlers = new Map(); + /* eslint-disable @typescript-eslint/no-explicit-any */ (OpenFeature as any)._clientEvents = new Map(); }); beforeEach(() => { @@ -166,24 +168,23 @@ describe('Events', () => { const provider = new MockProvider(); const client = OpenFeature.getClient(clientId); - let clientHandlerRan = false; - let apiHandlerRan = false; - - client.addHandler(ProviderEvents.Ready, () => { - clientHandlerRan = true; - if (clientHandlerRan && apiHandlerRan) { - done(); - } - }); - - OpenFeature.addHandler(ProviderEvents.Ready, () => { - apiHandlerRan = true; - if (clientHandlerRan && apiHandlerRan) { - done(); - } + Promise.all([ + new Promise((resolve) => { + client.addHandler(ProviderEvents.Error, () => { + resolve(); + }); + }), + new Promise((resolve) => { + OpenFeature.addHandler(ProviderEvents.Error, () => { + resolve(); + }); + }) + ]).then(() => { + done(); }); OpenFeature.setProvider(clientId, provider); + provider.events?.emit(ProviderEvents.Error); }); }); @@ -344,11 +345,13 @@ describe('Events', () => { }); describe('Requirement 5.2.3,', () => { - it('The event details contain the client name associated with the event in the API', (done) => { - const provider = new MockProvider(); + it('The event details MUST contain the provider name associated with the event.', (done) => { + const providerName = '5.2.3'; + const provider = new MockProvider({ name: providerName }); const client = OpenFeature.getClient(clientId); client.addHandler(ProviderEvents.Ready, (details) => { + expect(details?.providerName).toEqual(providerName); expect(details?.clientName).toEqual(clientId); done(); }); @@ -493,20 +496,31 @@ describe('Events', () => { }); describe('Requirement 5.3.3', () => { - it('`PROVIDER_READY` handlers added after the provider is already in a ready state MUST run immediately.', (done) => { - const provider = new MockProvider(); - const client = OpenFeature.getClient(clientId); - - OpenFeature.setProvider(clientId, provider); - expect(provider.initialize).toHaveBeenCalled(); - - let handlerCalled = false; - client.addHandler(ProviderEvents.Ready, () => { - if (!handlerCalled) { - handlerCalled = true; - done(); - } + describe('API', () => { + it('Handlers attached after the provider is already in the associated state, MUST run immediately.', (done) => { + const provider = new MockProvider({ initialStatus: ProviderStatus.ERROR }); + + OpenFeature.setProvider(clientId, provider); + expect(provider.initialize).not.toHaveBeenCalled(); + + OpenFeature.addHandler(ProviderEvents.Error, () => { + done(); + }); }); }); + + describe('client', () => { + it('Handlers attached after the provider is already in the associated state, MUST run immediately.', (done) => { + const provider = new MockProvider({ initialStatus: ProviderStatus.READY }); + const client = OpenFeature.getClient(clientId); + + OpenFeature.setProvider(clientId, provider); + expect(provider.initialize).not.toHaveBeenCalled(); + + client.addHandler(ProviderEvents.Ready, () => { + done(); + }); + }); + }); }); }); diff --git a/packages/server/README.md b/packages/server/README.md index 44fcfa837..f0600cdd2 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -13,8 +13,8 @@

- - Specification + + Specification diff --git a/packages/server/src/client/open-feature-client.ts b/packages/server/src/client/open-feature-client.ts index e6331a121..9e2d4ef24 100644 --- a/packages/server/src/client/open-feature-client.ts +++ b/packages/server/src/client/open-feature-client.ts @@ -6,23 +6,23 @@ import { EventHandler, FlagValue, FlagValueType, + Hook, HookContext, + InternalEventEmitter, JsonValue, Logger, + ManageContext, OpenFeatureError, - InternalEventEmitter, ProviderEvents, - ProviderStatus, ResolutionDetails, - StandardResolutionReasons, SafeLogger, - Hook, - ManageContext, + StandardResolutionReasons, + statusMatchesEvent } from '@openfeature/shared'; +import { FlagEvaluationOptions } from '../evaluation'; import { OpenFeature } from '../open-feature'; -import { Client } from './client'; import { Provider } from '../provider'; -import { FlagEvaluationOptions } from '../evaluation'; +import { Client } from './client'; type OpenFeatureClientOptions = { name?: string; @@ -56,12 +56,12 @@ export class OpenFeatureClient implements Client, ManageContext(eventType: T, handler: EventHandler): void { this.emitterAccessor().addHandler(eventType, handler); - const providerReady = !this._provider.status || this._provider.status === ProviderStatus.READY; + const shouldRunNow = statusMatchesEvent(eventType, this._provider.status); - if (eventType === ProviderEvents.Ready && providerReady) { - // run immediately, we're ready. + if (shouldRunNow) { + // run immediately, we're in the matching state try { - handler({ clientName: this.metadata.name }); + handler({ clientName: this.metadata.name, providerName: this._provider.metadata.name }); } catch (err) { this._logger?.error('Error running event handler:', err); } diff --git a/packages/server/test/client.spec.ts b/packages/server/test/client.spec.ts index f50cf84e5..2eb74e881 100644 --- a/packages/server/test/client.spec.ts +++ b/packages/server/test/client.spec.ts @@ -7,11 +7,11 @@ import { JsonArray, JsonObject, JsonValue, - ResolutionDetails, - StandardResolutionReasons, - Provider, OpenFeature, OpenFeatureClient, + Provider, + ResolutionDetails, + StandardResolutionReasons, TransactionContext, TransactionContextPropagator, } from '../src'; @@ -348,7 +348,6 @@ describe('OpenFeatureClient', () => { metadata: { name: 'error-mock', }, - resolveNumberEvaluation: jest.fn((): Promise> => { throw new Error(NON_OPEN_FEATURE_ERROR_MESSAGE); // throw a non-open-feature error }), diff --git a/packages/server/test/events.spec.ts b/packages/server/test/events.spec.ts index 340691e13..9e8916301 100644 --- a/packages/server/test/events.spec.ts +++ b/packages/server/test/events.spec.ts @@ -1,5 +1,7 @@ +import { v4 as uuid } from 'uuid'; import { JsonValue, + NOOP_PROVIDER, OpenFeature, OpenFeatureEventEmitter, Provider, @@ -7,10 +9,8 @@ import { ProviderMetadata, ProviderStatus, ResolutionDetails, - NOOP_PROVIDER, StaleEvent, } from '../src'; -import { v4 as uuid } from 'uuid'; const TIMEOUT = 1000; @@ -85,7 +85,9 @@ describe('Events', () => { jest.clearAllMocks(); clientId = uuid(); // hacky, but it's helpful to clear the handlers between tests + /* eslint-disable @typescript-eslint/no-explicit-any */ (OpenFeature as any)._clientEventHandlers = new Map(); + /* eslint-disable @typescript-eslint/no-explicit-any */ (OpenFeature as any)._clientEvents = new Map(); }); @@ -170,24 +172,23 @@ describe('Events', () => { const provider = new MockProvider(); const client = OpenFeature.getClient(clientId); - let clientHandlerRan = false; - let apiHandlerRan = false; - - client.addHandler(ProviderEvents.Ready, () => { - clientHandlerRan = true; - if (clientHandlerRan && apiHandlerRan) { - done(); - } - }); - - OpenFeature.addHandler(ProviderEvents.Ready, () => { - apiHandlerRan = true; - if (clientHandlerRan && apiHandlerRan) { - done(); - } + Promise.all([ + new Promise((resolve) => { + client.addHandler(ProviderEvents.Error, () => { + resolve(); + }); + }), + new Promise((resolve) => { + OpenFeature.addHandler(ProviderEvents.Error, () => { + resolve(); + }); + }) + ]).then(() => { + done(); }); OpenFeature.setProvider(clientId, provider); + provider.events?.emit(ProviderEvents.Error); }); }); @@ -348,11 +349,13 @@ describe('Events', () => { }); describe('Requirement 5.2.3,', () => { - it('The event details contain the client name associated with the event in the API', (done) => { - const provider = new MockProvider(); + it('The event details MUST contain the provider name associated with the event.', (done) => { + const providerName = '5.2.3'; + const provider = new MockProvider({ name: providerName }); const client = OpenFeature.getClient(clientId); client.addHandler(ProviderEvents.Ready, (details) => { + expect(details?.providerName).toEqual(providerName); expect(details?.clientName).toEqual(clientId); done(); }); @@ -497,20 +500,31 @@ describe('Events', () => { }); describe('Requirement 5.3.3', () => { - it('`PROVIDER_READY` handlers added after the provider is already in a ready state MUST run immediately.', (done) => { - const provider = new MockProvider(); - const client = OpenFeature.getClient(clientId); - - OpenFeature.setProvider(clientId, provider); - expect(provider.initialize).toHaveBeenCalled(); - - let handlerCalled = false; - client.addHandler(ProviderEvents.Ready, () => { - if (!handlerCalled) { - handlerCalled = true; - done(); - } + describe('API', () => { + it('Handlers attached after the provider is already in the associated state, MUST run immediately.', (done) => { + const provider = new MockProvider({ initialStatus: ProviderStatus.ERROR }); + + OpenFeature.setProvider(clientId, provider); + expect(provider.initialize).not.toHaveBeenCalled(); + + OpenFeature.addHandler(ProviderEvents.Error, () => { + done(); + }); }); }); + + describe('client', () => { + it('Handlers attached after the provider is already in the associated state, MUST run immediately.', (done) => { + const provider = new MockProvider({ initialStatus: ProviderStatus.READY }); + const client = OpenFeature.getClient(clientId); + + OpenFeature.setProvider(clientId, provider); + expect(provider.initialize).not.toHaveBeenCalled(); + + client.addHandler(ProviderEvents.Ready, () => { + done(); + }); + }); + }); }); }); diff --git a/packages/shared/src/events/event-utils.ts b/packages/shared/src/events/event-utils.ts new file mode 100644 index 000000000..eff6cab7d --- /dev/null +++ b/packages/shared/src/events/event-utils.ts @@ -0,0 +1,20 @@ +import { ProviderStatus } from '../provider'; +import { ProviderEvents } from './events'; + +const eventStatusMap = { + [ProviderStatus.READY]: ProviderEvents.Ready, + [ProviderStatus.ERROR]: ProviderEvents.Error, + [ProviderStatus.STALE]: ProviderEvents.Stale, + [ProviderStatus.NOT_READY]: undefined, +}; + +/** + * Returns true if the provider's status corresponds to the event. + * If the provider's status is not defined, it matches READY. + * @param {ProviderEvents} event event to match + * @param {ProviderStatus} status status of provider + * @returns {boolean} boolean indicating if the provider status corresponds to the event. + */ +export const statusMatchesEvent = (event: ProviderEvents, status?: ProviderStatus): boolean => { + return (!status && event === ProviderEvents.Ready) || eventStatusMap[status!] === event; +}; \ No newline at end of file diff --git a/packages/shared/src/events/eventing.ts b/packages/shared/src/events/eventing.ts index 6f2d62ace..cc556c2fd 100644 --- a/packages/shared/src/events/eventing.ts +++ b/packages/shared/src/events/eventing.ts @@ -5,6 +5,7 @@ export type EventMetadata = { }; export type CommonEventDetails = { + providerName: string; clientName?: string; }; diff --git a/packages/shared/src/events/index.ts b/packages/shared/src/events/index.ts index ec3d42fe5..c32213c31 100644 --- a/packages/shared/src/events/index.ts +++ b/packages/shared/src/events/index.ts @@ -1,3 +1,4 @@ -export * from './events'; +export * from './event-utils'; export * from './eventing'; +export * from './events'; export * from './open-feature-event-emitter'; diff --git a/packages/shared/src/events/open-feature-event-emitter.ts b/packages/shared/src/events/open-feature-event-emitter.ts index a143e2205..56cf4d84f 100644 --- a/packages/shared/src/events/open-feature-event-emitter.ts +++ b/packages/shared/src/events/open-feature-event-emitter.ts @@ -1,7 +1,7 @@ -import { Logger, ManageLogger, SafeLogger } from '../logger'; import EventEmitter from 'events'; +import { Logger, ManageLogger, SafeLogger } from '../logger'; +import { CommonEventDetails, EventContext, EventDetails, EventHandler } from './eventing'; import { ProviderEvents } from './events'; -import { EventContext, EventDetails, EventHandler, CommonEventDetails } from './eventing'; abstract class GenericEventEmitter = Record> implements ManageLogger> @@ -24,8 +24,8 @@ abstract class GenericEventEmitter(eventType: T, handler: EventHandler): void { // The handlers have to be wrapped with an async function because if a synchronous functions throws an error, // the other handlers will not run. - const asyncHandler = async (context?: EventDetails) => { - await handler(context); + const asyncHandler = async (details?: EventDetails) => { + await handler(details); }; // The async handler has to be written to the map, because we need to get the wrapper function when deleting a listener this._handlers.set(handler, asyncHandler); diff --git a/packages/shared/src/filter.ts b/packages/shared/src/filter.ts index 66b8a995f..f0ab489fc 100644 --- a/packages/shared/src/filter.ts +++ b/packages/shared/src/filter.ts @@ -2,7 +2,7 @@ * Checks if a value is not null or undefined and returns it as type assertion * @template T * @param {T} input The value to check - * @returns If the value is not null or undefined + * @returns {T} If the value is not null or undefined */ export function isDefined(input?: T | null | undefined): input is T { return typeof input !== 'undefined' && input !== null; diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index b4a15853f..8ca601063 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -1,6 +1,6 @@ import { GeneralError } from './errors'; import { EvaluationContext, FlagValue } from './evaluation'; -import { EventDetails, EventHandler, Eventing, ProviderEvents } from './events'; +import { EventDetails, EventHandler, Eventing, ProviderEvents, statusMatchesEvent } from './events'; import { InternalEventEmitter } from './events/open-feature-event-emitter'; import { isDefined } from './filter'; import { EvaluationLifeCycle, Hook } from './hooks'; @@ -70,11 +70,26 @@ export abstract class OpenFeatureCommonAPI

(eventType: T, handler: EventHandler): void { + [...new Map([[undefined, this._defaultProvider]]), ...this._clientProviders].forEach((keyProviderTuple) => { + const clientName = keyProviderTuple[0]; + const provider = keyProviderTuple[1]; + const shouldRunNow = statusMatchesEvent(eventType, keyProviderTuple[1].status); + + if (shouldRunNow) { + // run immediately, we're in the matching state + try { + handler({ clientName, providerName: provider.metadata.name }); + } catch (err) { + this._logger?.error('Error running event handler:', err); + } + } + }); + this._events.addHandler(eventType, handler); } @@ -124,6 +139,7 @@ export abstract class OpenFeatureCommonAPI

{ // fetch the most recent event emitters, some may have been added during init this.getAssociatedEventEmitters(clientName).forEach((emitter) => { - emitter?.emit(ProviderEvents.Ready, { clientName }); + emitter?.emit(ProviderEvents.Ready, { clientName, providerName }); }); - this._events?.emit(ProviderEvents.Ready, { clientName }); + this._events?.emit(ProviderEvents.Ready, { clientName, providerName }); }) ?.catch((error) => { this.getAssociatedEventEmitters(clientName).forEach((emitter) => { - emitter?.emit(ProviderEvents.Error, { clientName, message: error.message }); + emitter?.emit(ProviderEvents.Error, { clientName, providerName, message: error.message }); }); - this._events?.emit(ProviderEvents.Error, { clientName, message: error.message }); + this._events?.emit(ProviderEvents.Error, { clientName, providerName, message: error.message }); }); } else { emitters.forEach((emitter) => { - emitter?.emit(ProviderEvents.Ready, { clientName }); + emitter?.emit(ProviderEvents.Ready, { clientName, providerName }); }); - this._events?.emit(ProviderEvents.Ready, { clientName }); + this._events?.emit(ProviderEvents.Ready, { clientName, providerName }); } if (clientName) { @@ -208,7 +224,7 @@ export abstract class OpenFeatureCommonAPI

(ProviderEvents).forEach((eventType) => clientProvider.events?.addHandler(eventType, async (details) => { - newEmitter.emit(eventType, { ...details, clientName: name }); + newEmitter.emit(eventType, { ...details, clientName: name, providerName: clientProvider.metadata.name }); }) ); @@ -248,9 +264,9 @@ export abstract class OpenFeatureCommonAPI

) => { // on each event type, fire the associated handlers emitters.forEach((emitter) => { - emitter?.emit(eventType, { ...details, clientName }); + emitter?.emit(eventType, { ...details, clientName, providerName: newProvider.metadata.name }); }); - this._events.emit(eventType, { ...details, clientName }); + this._events.emit(eventType, { ...details, clientName, providerName: newProvider.metadata.name }); }; return [eventType, handler]; diff --git a/packages/shared/src/provider/provider.ts b/packages/shared/src/provider/provider.ts index df42ec33e..e67b78c3d 100644 --- a/packages/shared/src/provider/provider.ts +++ b/packages/shared/src/provider/provider.ts @@ -1,7 +1,6 @@ -import { OpenFeatureEventEmitter } from '../events'; -import { Metadata } from '../types'; import { EvaluationContext } from '../evaluation'; -import { Paradigm } from '../types'; +import { OpenFeatureEventEmitter } from '../events'; +import { Metadata, Paradigm } from '../types'; /** * The state of the provider. @@ -21,6 +20,11 @@ export enum ProviderStatus { * The provider is in an error state and unable to evaluate flags. */ ERROR = 'ERROR', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = 'STALE', } /**