diff --git a/README.md b/README.md index b625137b..1c48aa9a 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ public async Task Example() | ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | | ✅ | [Logging](#logging) | Integrate with popular logging packages. | | ✅ | [Named clients](#named-clients) | Utilize multiple providers in a single application. | -| ❌ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | +| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | | ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | | ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | @@ -167,6 +167,43 @@ client.AddHooks(new ExampleClientHook()); var value = await client.GetBooleanValue("boolFlag", false, context, new FlagEvaluationOptions(new ExampleInvocationHook())); ``` +### 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. +Initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) are dispatched for every provider. +Some providers support additional events, such as `PROVIDER_CONFIGURATION_CHANGED`. + +Please refer to the documentation of the provider you're using to see what events are supported. + +Example usage of an Event handler: + +```csharp +public static void EventHandler(ProviderEventPayload eventDetails) +{ + Console.WriteLine(eventDetails.Type); +} +``` + +```csharp +EventHandlerDelegate callback = EventHandler; +// add an implementation of the EventHandlerDelegate for the PROVIDER_READY event +Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, callback); +``` + +It is also possible to register an event handler for a specific client, as in the following example: + +```csharp +EventHandlerDelegate callback = EventHandler; + +var myClient = Api.Instance.GetClient("my-client"); + +var provider = new ExampleProvider(); +await Api.Instance.SetProvider(myClient.GetMetadata().Name, provider); + +myClient.AddHandler(ProviderEventTypes.ProviderReady, callback); +``` + ### Logging The .NET SDK uses Microsoft.Extensions.Logging. See the [manual](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging?tabs=command-line) for complete documentation. diff --git a/build/Common.props b/build/Common.props index 4af79a3c..42a91d64 100644 --- a/build/Common.props +++ b/build/Common.props @@ -25,5 +25,6 @@ + diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index 3583572e..8d0e22f5 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using OpenFeature.Constant; using OpenFeature.Model; namespace OpenFeature @@ -13,7 +14,7 @@ namespace OpenFeature /// In the absence of a provider the evaluation API uses the "No-op provider", which simply returns the supplied default flag value. /// /// - public sealed class Api + public sealed class Api : IEventBus { private EvaluationContext _evaluationContext = EvaluationContext.Empty; private readonly ProviderRepository _repository = new ProviderRepository(); @@ -22,6 +23,8 @@ public sealed class Api /// The reader/writer locks are not disposed because the singleton instance should never be disposed. private readonly ReaderWriterLockSlim _evaluationContextLock = new ReaderWriterLockSlim(); + internal readonly EventExecutor EventExecutor = new EventExecutor(); + /// /// Singleton instance of Api @@ -42,6 +45,7 @@ private Api() { } /// Implementation of public async Task SetProvider(FeatureProvider featureProvider) { + this.EventExecutor.RegisterDefaultFeatureProvider(featureProvider); await this._repository.SetProvider(featureProvider, this.GetContext()).ConfigureAwait(false); } @@ -54,6 +58,7 @@ public async Task SetProvider(FeatureProvider featureProvider) /// Implementation of public async Task SetProvider(string clientName, FeatureProvider featureProvider) { + this.EventExecutor.RegisterClientFeatureProvider(clientName, featureProvider); await this._repository.SetProvider(clientName, featureProvider, this.GetContext()).ConfigureAwait(false); } @@ -201,6 +206,28 @@ public EvaluationContext GetContext() public async Task Shutdown() { await this._repository.Shutdown().ConfigureAwait(false); + await this.EventExecutor.Shutdown().ConfigureAwait(false); + } + + /// + public void AddHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + this.EventExecutor.AddApiLevelHandler(type, handler); + } + + /// + public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + this.EventExecutor.RemoveApiLevelHandler(type, handler); + } + + /// + /// Sets the logger for the API + /// + /// The logger to be used + public void SetLogger(ILogger logger) + { + this.EventExecutor.Logger = logger; } } } diff --git a/src/OpenFeature/Constant/EventType.cs b/src/OpenFeature/Constant/EventType.cs new file mode 100644 index 00000000..3d3c9dc8 --- /dev/null +++ b/src/OpenFeature/Constant/EventType.cs @@ -0,0 +1,25 @@ +namespace OpenFeature.Constant +{ + /// + /// The ProviderEventTypes enum represents the available event types of a provider. + /// + public enum ProviderEventTypes + { + /// + /// ProviderReady should be emitted by a provider upon completing its initialisation. + /// + ProviderReady, + /// + /// ProviderError should be emitted by a provider upon encountering an error. + /// + ProviderError, + /// + /// ProviderConfigurationChanged should be emitted by a provider when a flag configuration has been changed. + /// + ProviderConfigurationChanged, + /// + /// ProviderStale should be emitted by a provider when it goes into the stale state. + /// + ProviderStale + } +} diff --git a/src/OpenFeature/EventExecutor.cs b/src/OpenFeature/EventExecutor.cs new file mode 100644 index 00000000..8a6df9a4 --- /dev/null +++ b/src/OpenFeature/EventExecutor.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature +{ + + internal delegate Task ShutdownDelegate(); + + internal class EventExecutor + { + private readonly object _lockObj = new object(); + public readonly Channel EventChannel = Channel.CreateBounded(1); + private FeatureProviderReference _defaultProvider; + private readonly Dictionary _namedProviderReferences = new Dictionary(); + private readonly List _activeSubscriptions = new List(); + private readonly SemaphoreSlim _shutdownSemaphore = new SemaphoreSlim(0); + + private ShutdownDelegate _shutdownDelegate; + + private readonly Dictionary> _apiHandlers = new Dictionary>(); + private readonly Dictionary>> _clientHandlers = new Dictionary>>(); + + internal ILogger Logger { get; set; } + + public EventExecutor() + { + this.Logger = new Logger(new NullLoggerFactory()); + this._shutdownDelegate = this.SignalShutdownAsync; + var eventProcessing = new Thread(this.ProcessEventAsync); + eventProcessing.Start(); + } + + internal void AddApiLevelHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) + { + lock (this._lockObj) + { + if (!this._apiHandlers.TryGetValue(eventType, out var eventHandlers)) + { + eventHandlers = new List(); + this._apiHandlers[eventType] = eventHandlers; + } + + eventHandlers.Add(handler); + + this.EmitOnRegistration(this._defaultProvider, eventType, handler); + } + } + + internal void RemoveApiLevelHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + lock (this._lockObj) + { + if (this._apiHandlers.TryGetValue(type, out var eventHandlers)) + { + eventHandlers.Remove(handler); + } + } + } + + internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) + { + lock (this._lockObj) + { + // check if there is already a list of handlers for the given client and event type + if (!this._clientHandlers.TryGetValue(client, out var registry)) + { + registry = new Dictionary>(); + this._clientHandlers[client] = registry; + } + + if (!this._clientHandlers[client].TryGetValue(eventType, out var eventHandlers)) + { + eventHandlers = new List(); + this._clientHandlers[client][eventType] = eventHandlers; + } + + this._clientHandlers[client][eventType].Add(handler); + + this.EmitOnRegistration( + this._namedProviderReferences.TryGetValue(client, out var clientProviderReference) + ? clientProviderReference + : this._defaultProvider, eventType, handler); + } + } + + internal void RemoveClientHandler(string client, ProviderEventTypes type, EventHandlerDelegate handler) + { + lock (this._lockObj) + { + if (this._clientHandlers.TryGetValue(client, out var clientEventHandlers)) + { + if (clientEventHandlers.TryGetValue(type, out var eventHandlers)) + { + eventHandlers.Remove(handler); + } + } + } + } + + internal void RegisterDefaultFeatureProvider(FeatureProvider provider) + { + if (provider == null) + { + return; + } + lock (this._lockObj) + { + var oldProvider = this._defaultProvider; + + this._defaultProvider = new FeatureProviderReference(provider); + + this.StartListeningAndShutdownOld(this._defaultProvider, oldProvider); + } + } + + internal void RegisterClientFeatureProvider(string client, FeatureProvider provider) + { + if (provider == null) + { + return; + } + lock (this._lockObj) + { + var newProvider = new FeatureProviderReference(provider); + FeatureProviderReference oldProvider = null; + if (this._namedProviderReferences.TryGetValue(client, out var foundOldProvider)) + { + oldProvider = foundOldProvider; + } + + this._namedProviderReferences[client] = newProvider; + + this.StartListeningAndShutdownOld(newProvider, oldProvider); + } + } + + private void StartListeningAndShutdownOld(FeatureProviderReference newProvider, FeatureProviderReference oldProvider) + { + // check if the provider is already active - if not, we need to start listening for its emitted events + if (!this.IsProviderActive(newProvider)) + { + this._activeSubscriptions.Add(newProvider); + var featureProviderEventProcessing = new Thread(this.ProcessFeatureProviderEventsAsync); + featureProviderEventProcessing.Start(newProvider); + } + + if (oldProvider != null && !this.IsProviderBound(oldProvider)) + { + this._activeSubscriptions.Remove(oldProvider); + var channel = oldProvider.Provider.GetEventChannel(); + if (channel != null) + { + channel.Writer.WriteAsync(new ShutdownSignal()); + } + } + } + + private bool IsProviderBound(FeatureProviderReference provider) + { + if (this._defaultProvider == provider) + { + return true; + } + foreach (var providerReference in this._namedProviderReferences.Values) + { + if (providerReference == provider) + { + return true; + } + } + return false; + } + + private bool IsProviderActive(FeatureProviderReference providerRef) + { + return this._activeSubscriptions.Contains(providerRef); + } + + private void EmitOnRegistration(FeatureProviderReference provider, ProviderEventTypes eventType, EventHandlerDelegate handler) + { + if (provider == null) + { + return; + } + var status = provider.Provider.GetStatus(); + + var message = ""; + if (status == ProviderStatus.Ready && eventType == ProviderEventTypes.ProviderReady) + { + message = "Provider is ready"; + } + else if (status == ProviderStatus.Error && eventType == ProviderEventTypes.ProviderError) + { + message = "Provider is in error state"; + } + else if (status == ProviderStatus.Stale && eventType == ProviderEventTypes.ProviderStale) + { + message = "Provider is in stale state"; + } + + if (message != "") + { + try + { + handler.Invoke(new ProviderEventPayload + { + ProviderName = provider.Provider?.GetMetadata()?.Name, + Type = eventType, + Message = message + }); + } + catch (Exception exc) + { + this.Logger?.LogError("Error running handler: " + exc); + } + } + } + + private async void ProcessFeatureProviderEventsAsync(object providerRef) + { + while (true) + { + var typedProviderRef = (FeatureProviderReference)providerRef; + if (typedProviderRef.Provider.GetEventChannel() == null) + { + return; + } + var item = await typedProviderRef.Provider.GetEventChannel().Reader.ReadAsync().ConfigureAwait(false); + + switch (item) + { + case ProviderEventPayload eventPayload: + await this.EventChannel.Writer.WriteAsync(new Event { Provider = typedProviderRef, EventPayload = eventPayload }).ConfigureAwait(false); + break; + case ShutdownSignal _: + typedProviderRef.ShutdownSemaphore.Release(); + return; + } + } + } + + // Method to process events + private async void ProcessEventAsync() + { + while (true) + { + var item = await this.EventChannel.Reader.ReadAsync().ConfigureAwait(false); + + switch (item) + { + case Event e: + lock (this._lockObj) + { + if (this._apiHandlers.TryGetValue(e.EventPayload.Type, out var eventHandlers)) + { + foreach (var eventHandler in eventHandlers) + { + this.InvokeEventHandler(eventHandler, e); + } + } + + // look for client handlers and call invoke method there + foreach (var keyAndValue in this._namedProviderReferences) + { + if (keyAndValue.Value == e.Provider) + { + if (this._clientHandlers.TryGetValue(keyAndValue.Key, out var clientRegistry)) + { + if (clientRegistry.TryGetValue(e.EventPayload.Type, out var clientEventHandlers)) + { + foreach (var eventHandler in clientEventHandlers) + { + this.InvokeEventHandler(eventHandler, e); + } + } + } + } + } + + if (e.Provider != this._defaultProvider) + { + break; + } + // handling the default provider - invoke event handlers for clients which are not bound + // to a particular feature provider + foreach (var keyAndValues in this._clientHandlers) + { + if (this._namedProviderReferences.TryGetValue(keyAndValues.Key, out _)) + { + // if there is an association for the client to a specific feature provider, then continue + continue; + } + if (keyAndValues.Value.TryGetValue(e.EventPayload.Type, out var clientEventHandlers)) + { + foreach (var eventHandler in clientEventHandlers) + { + this.InvokeEventHandler(eventHandler, e); + } + } + } + } + break; + case ShutdownSignal _: + this._shutdownSemaphore.Release(); + return; + } + + } + } + + private void InvokeEventHandler(EventHandlerDelegate eventHandler, Event e) + { + try + { + eventHandler.Invoke(e.EventPayload); + } + catch (Exception exc) + { + this.Logger?.LogError("Error running handler: " + exc); + } + } + + public async Task Shutdown() + { + await this._shutdownDelegate().ConfigureAwait(false); + } + + internal void SetShutdownDelegate(ShutdownDelegate del) + { + this._shutdownDelegate = del; + } + + // Method to signal shutdown + private async Task SignalShutdownAsync() + { + // Enqueue a shutdown signal + await this.EventChannel.Writer.WriteAsync(new ShutdownSignal()).ConfigureAwait(false); + + // Wait for the processing loop to acknowledge the shutdown + await this._shutdownSemaphore.WaitAsync().ConfigureAwait(false); + } + } + + internal class ShutdownSignal + { + } + + internal class FeatureProviderReference + { + internal readonly SemaphoreSlim ShutdownSemaphore = new SemaphoreSlim(0); + internal FeatureProvider Provider { get; } + + public FeatureProviderReference(FeatureProvider provider) + { + this.Provider = provider; + } + } + + internal class Event + { + internal FeatureProviderReference Provider { get; set; } + internal ProviderEventPayload EventPayload { get; set; } + } +} diff --git a/src/OpenFeature/FeatureProvider.cs b/src/OpenFeature/FeatureProvider.cs index c3cc1406..3dd85102 100644 --- a/src/OpenFeature/FeatureProvider.cs +++ b/src/OpenFeature/FeatureProvider.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Threading.Channels; using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.Model; @@ -25,6 +26,11 @@ public abstract class FeatureProvider /// Immutable list of hooks public virtual IImmutableList GetProviderHooks() => ImmutableList.Empty; + /// + /// The event channel of the provider. + /// + protected Channel EventChannel = Channel.CreateBounded(1); + /// /// Metadata describing the provider. /// @@ -105,7 +111,7 @@ public abstract Task> ResolveStructureValue(string flag /// /// /// A provider which supports initialization should override this method as well as - /// . + /// . /// /// /// The provider should return or from @@ -128,5 +134,11 @@ public virtual Task Shutdown() // Intentionally left blank. return Task.CompletedTask; } + + /// + /// Returns the event channel of the provider. + /// + /// The event channel of the provider + public virtual Channel GetEventChannel() => this.EventChannel; } } diff --git a/src/OpenFeature/IEventBus.cs b/src/OpenFeature/IEventBus.cs new file mode 100644 index 00000000..114b66b3 --- /dev/null +++ b/src/OpenFeature/IEventBus.cs @@ -0,0 +1,24 @@ +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature +{ + /// + /// Defines the methods required for handling events. + /// + public interface IEventBus + { + /// + /// Adds an Event Handler for the given event type. + /// + /// The type of the event + /// Implementation of the + void AddHandler(ProviderEventTypes type, EventHandlerDelegate handler); + /// + /// Removes an Event Handler for the given event type. + /// + /// The type of the event + /// Implementation of the + void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler); + } +} diff --git a/src/OpenFeature/IFeatureClient.cs b/src/OpenFeature/IFeatureClient.cs index 2186ca68..1d2e6dfb 100644 --- a/src/OpenFeature/IFeatureClient.cs +++ b/src/OpenFeature/IFeatureClient.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using OpenFeature.Constant; using OpenFeature.Model; namespace OpenFeature @@ -7,7 +8,7 @@ namespace OpenFeature /// /// Interface used to resolve flags of varying types. /// - public interface IFeatureClient + public interface IFeatureClient : IEventBus { /// /// Appends hooks to client diff --git a/src/OpenFeature/Model/ProviderEvents.cs b/src/OpenFeature/Model/ProviderEvents.cs new file mode 100644 index 00000000..da68aef4 --- /dev/null +++ b/src/OpenFeature/Model/ProviderEvents.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using OpenFeature.Constant; + +namespace OpenFeature.Model +{ + /// + /// The EventHandlerDelegate is an implementation of an Event Handler + /// + public delegate void EventHandlerDelegate(ProviderEventPayload eventDetails); + + /// + /// Contains the payload of an OpenFeature Event. + /// + public class ProviderEventPayload + { + /// + /// Name of the provider. + /// + public string ProviderName { get; set; } + + /// + /// Type of the event + /// + public ProviderEventTypes Type { get; set; } + + /// + /// A message providing more information about the event. + /// + public string Message { get; set; } + + /// + /// A List of flags that have been changed. + /// + public List FlagsChanged { get; set; } + + /// + /// Metadata information for the event. + /// + public Dictionary EventMetadata { get; set; } + } +} diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 1cc26802..9ea9b13a 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -93,6 +93,18 @@ public FeatureClient(string name, string version, ILogger logger = null, Evaluat /// Hook that implements the interface public void AddHooks(Hook hook) => this._hooks.Push(hook); + /// + public void AddHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) + { + Api.Instance.EventExecutor.AddClientHandler(this._metadata.Name, eventType, handler); + } + + /// + public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + Api.Instance.EventExecutor.RemoveClientHandler(this._metadata.Name, type, handler); + } + /// public void AddHooks(IEnumerable hooks) => this._hooks.PushRange(hooks.ToArray()); diff --git a/test/OpenFeature.Tests/FeatureProviderTests.cs b/test/OpenFeature.Tests/FeatureProviderTests.cs index aa79cbf9..8d679f94 100644 --- a/test/OpenFeature.Tests/FeatureProviderTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderTests.cs @@ -19,7 +19,7 @@ public void Provider_Must_Have_Metadata() { var provider = new TestProvider(); - provider.GetMetadata().Name.Should().Be(TestProvider.Name); + provider.GetMetadata().Name.Should().Be(TestProvider.DefaultName); } [Fact] diff --git a/test/OpenFeature.Tests/OpenFeatureEventTests.cs b/test/OpenFeature.Tests/OpenFeatureEventTests.cs new file mode 100644 index 00000000..8c183e63 --- /dev/null +++ b/test/OpenFeature.Tests/OpenFeatureEventTests.cs @@ -0,0 +1,445 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; +using Xunit; + +namespace OpenFeature.Tests +{ + [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] + public class OpenFeatureEventTest : ClearOpenFeatureInstanceFixture + { + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + public async Task Event_Executor_Should_Propagate_Events_ToGlobal_Handler() + { + var eventHandler = Substitute.For(); + + var eventExecutor = new EventExecutor(); + + eventExecutor.AddApiLevelHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + + var eventMetadata = new Dictionary { { "foo", "bar" } }; + var myEvent = new Event + { + EventPayload = new ProviderEventPayload + { + Type = ProviderEventTypes.ProviderConfigurationChanged, + Message = "The provider is ready", + EventMetadata = eventMetadata, + FlagsChanged = new List { "flag1", "flag2" } + } + }; + eventExecutor.EventChannel.Writer.TryWrite(myEvent); + + Thread.Sleep(1000); + + eventHandler.Received().Invoke(Arg.Is(payload => payload == myEvent.EventPayload)); + + // shut down the event executor + await eventExecutor.Shutdown(); + + // the next event should not be propagated to the event handler + var newEventPayload = new ProviderEventPayload { Type = ProviderEventTypes.ProviderStale }; + + eventExecutor.EventChannel.Writer.TryWrite(newEventPayload); + + eventHandler.DidNotReceive().Invoke(newEventPayload); + + eventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.Type == ProviderEventTypes.ProviderStale)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task API_Level_Event_Handlers_Should_Be_Registered() + { + var eventHandler = Substitute.For(); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderError, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(testProvider); + + testProvider.SendEvent(ProviderEventTypes.ProviderConfigurationChanged); + testProvider.SendEvent(ProviderEventTypes.ProviderError); + testProvider.SendEvent(ProviderEventTypes.ProviderStale); + + Thread.Sleep(1000); + eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady + )); + + eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged + )); + + eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderError + )); + + eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderStale + )); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering_Provider_Ready() + { + var eventHandler = Substitute.For(); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(testProvider); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + Thread.Sleep(1000); + eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady + )); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.2", "If the provider's `initialize` function terminates abnormally, `PROVIDER_ERROR` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Error_State_After_Registering_Provider_Error() + { + var eventHandler = Substitute.For(); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(testProvider); + + testProvider.SetStatus(ProviderStatus.Error); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderError, eventHandler); + + Thread.Sleep(1000); + eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderError + )); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Stale_State_After_Registering_Provider_Stale() + { + var eventHandler = Substitute.For(); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(testProvider); + + testProvider.SetStatus(ProviderStatus.Stale); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, eventHandler); + + Thread.Sleep(1000); + eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderStale + )); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.6", "Event handlers MUST persist across `provider` changes.")] + public async Task API_Level_Event_Handlers_Should_Be_Exchangeable() + { + var eventHandler = Substitute.For(); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(testProvider); + + testProvider.SendEvent(ProviderEventTypes.ProviderConfigurationChanged); + + var newTestProvider = new TestProvider(); + await Api.Instance.SetProvider(newTestProvider); + + newTestProvider.SendEvent(ProviderEventTypes.ProviderConfigurationChanged); + + Thread.Sleep(1000); + eventHandler.Received(2).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady)); + eventHandler.Received(2).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)); + } + + [Fact] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.7", "The `API` and `client` MUST provide a function allowing the removal of event handlers.")] + public async Task API_Level_Event_Handlers_Should_Be_Removable() + { + var eventHandler = Substitute.For(); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(testProvider); + + Thread.Sleep(1000); + Api.Instance.RemoveHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var newTestProvider = new TestProvider(); + await Api.Instance.SetProvider(newTestProvider); + + eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.5", "If a `handler function` terminates abnormally, other `handler` functions MUST run.")] + public async Task API_Level_Event_Handlers_Should_Be_Executed_When_Other_Handler_Fails() + { + var fixture = new Fixture(); + + var failingEventHandler = Substitute.For(); + var eventHandler = Substitute.For(); + + failingEventHandler.When(x => x.Invoke(Arg.Any())) + .Do(x => throw new Exception()); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, failingEventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(fixture.Create()); + await Api.Instance.SetProvider(testProvider); + + failingEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task Client_Level_Event_Handlers_Should_Be_Registered() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + + var myClient = Api.Instance.GetClient(fixture.Create()); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(myClient.GetMetadata().Name, testProvider); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.5", "If a `handler function` terminates abnormally, other `handler` functions MUST run.")] + public async Task Client_Level_Event_Handlers_Should_Be_Executed_When_Other_Handler_Fails() + { + var fixture = new Fixture(); + + var failingEventHandler = Substitute.For(); + var eventHandler = Substitute.For(); + + failingEventHandler.When(x => x.Invoke(Arg.Any())) + .Do(x => throw new Exception()); + + var myClient = Api.Instance.GetClient(fixture.Create()); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, failingEventHandler); + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(myClient.GetMetadata().Name, testProvider); + + Thread.Sleep(1000); + + failingEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task Client_Level_Event_Handlers_Should_Be_Registered_To_Default_Provider() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + var clientEventHandler = Substitute.For(); + + var myClientWithNoBoundProvider = Api.Instance.GetClient(fixture.Create()); + var myClientWithBoundProvider = Api.Instance.GetClient(fixture.Create()); + + var apiProvider = new TestProvider(fixture.Create()); + var clientProvider = new TestProvider(fixture.Create()); + + // set the default provider on API level, but not specifically to the client + await Api.Instance.SetProvider(apiProvider); + // set the other provider specifically for the client + await Api.Instance.SetProvider(myClientWithBoundProvider.GetMetadata().Name, clientProvider); + + myClientWithNoBoundProvider.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + myClientWithBoundProvider.AddHandler(ProviderEventTypes.ProviderReady, clientEventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == apiProvider.GetMetadata().Name)); + eventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name)); + + clientEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name)); + clientEventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.ProviderName == apiProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.6", "Event handlers MUST persist across `provider` changes.")] + public async Task Client_Level_Event_Handlers_Should_Be_Receive_Events_From_Named_Provider_Instead_of_Default() + { + var fixture = new Fixture(); + var clientEventHandler = Substitute.For(); + + var client = Api.Instance.GetClient(fixture.Create()); + + var defaultProvider = new TestProvider(fixture.Create()); + var clientProvider = new TestProvider(fixture.Create()); + + // set the default provider + await Api.Instance.SetProvider(defaultProvider); + + client.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, clientEventHandler); + + defaultProvider.SendEvent(ProviderEventTypes.ProviderConfigurationChanged); + + Thread.Sleep(1000); + + // verify that the client received the event from the default provider as there is no named provider registered yet + clientEventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == defaultProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)); + + // set the other provider specifically for the client + await Api.Instance.SetProvider(client.GetMetadata().Name, clientProvider); + + // now, send another event for the default handler + defaultProvider.SendEvent(ProviderEventTypes.ProviderConfigurationChanged); + clientProvider.SendEvent(ProviderEventTypes.ProviderConfigurationChanged); + + Thread.Sleep(1000); + + // now the client should have received only the event from the named provider + clientEventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)); + // for the default provider, the number of received events should stay unchanged + clientEventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == defaultProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task Client_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + + var myClient = Api.Instance.GetClient(fixture.Create()); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(myClient.GetMetadata().Name, testProvider); + + // add the event handler after the provider has already transitioned into the ready state + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.7", "The `API` and `client` MUST provide a function allowing the removal of event handlers.")] + public async Task Client_Level_Event_Handlers_Should_Be_Removable() + { + var fixture = new Fixture(); + + var eventHandler = Substitute.For(); + + var myClient = Api.Instance.GetClient(fixture.Create()); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProvider(myClient.GetMetadata().Name, testProvider); + + // wait for the first event to be received + Thread.Sleep(1000); + myClient.RemoveHandler(ProviderEventTypes.ProviderReady, eventHandler); + + // send another event from the provider - this one should not be received + testProvider.SendEvent(ProviderEventTypes.ProviderReady); + + // wait a bit and make sure we only have received the first event, but nothing after removing the event handler + Thread.Sleep(1000); + eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + } +} diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index afcfcd18..3e8b4d3d 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -11,6 +11,11 @@ namespace OpenFeature.Tests { public class OpenFeatureTests : ClearOpenFeatureInstanceFixture { + static async Task EmptyShutdown() + { + await Task.FromResult(0).ConfigureAwait(false); + } + [Fact] [Specification("1.1.1", "The `API`, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the `API` are present at runtime.")] public void OpenFeature_Should_Be_Singleton() @@ -74,6 +79,9 @@ public async Task OpenFeature_Should_Shutdown_Unused_Provider() [Specification("1.6.1", "The API MUST define a mechanism to propagate a shutdown request to active providers.")] public async Task OpenFeature_Should_Support_Shutdown() { + // configure the shutdown method of the event executor to do nothing + // to prevent eventing tests from failing + Api.Instance.EventExecutor.SetShutdownDelegate(EmptyShutdown); var providerA = Substitute.For(); providerA.GetStatus().Returns(ProviderStatus.NotReady); @@ -96,13 +104,13 @@ public void OpenFeature_Should_Not_Change_Named_Providers_When_Setting_Default_P var openFeature = Api.Instance; openFeature.SetProvider(new NoOpFeatureProvider()); - openFeature.SetProvider(TestProvider.Name, new TestProvider()); + openFeature.SetProvider(TestProvider.DefaultName, new TestProvider()); var defaultClient = openFeature.GetProviderMetadata(); - var namedClient = openFeature.GetProviderMetadata(TestProvider.Name); + var namedClient = openFeature.GetProviderMetadata(TestProvider.DefaultName); defaultClient.Name.Should().Be(NoOpProvider.NoOpProviderName); - namedClient.Name.Should().Be(TestProvider.Name); + namedClient.Name.Should().Be(TestProvider.DefaultName); } [Fact] @@ -115,7 +123,7 @@ public void OpenFeature_Should_Set_Default_Provide_When_No_Name_Provided() var defaultClient = openFeature.GetProviderMetadata(); - defaultClient.Name.Should().Be(TestProvider.Name); + defaultClient.Name.Should().Be(TestProvider.DefaultName); } [Fact] diff --git a/test/OpenFeature.Tests/TestImplementations.cs b/test/OpenFeature.Tests/TestImplementations.cs index e2bcf5e9..9683e7ef 100644 --- a/test/OpenFeature.Tests/TestImplementations.cs +++ b/test/OpenFeature.Tests/TestImplementations.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Threading.Tasks; +using OpenFeature.Constant; using OpenFeature.Model; namespace OpenFeature.Tests @@ -36,15 +37,31 @@ public class TestProvider : FeatureProvider { private readonly List _hooks = new List(); - public static string Name => "test-provider"; + public static string DefaultName = "test-provider"; + + public string Name { get; set; } + + private ProviderStatus _status; public void AddHook(Hook hook) => this._hooks.Add(hook); public override IImmutableList GetProviderHooks() => this._hooks.ToImmutableList(); + public TestProvider() + { + this._status = ProviderStatus.NotReady; + this.Name = DefaultName; + } + + public TestProvider(string name) + { + this._status = ProviderStatus.NotReady; + this.Name = name; + } + public override Metadata GetMetadata() { - return new Metadata(Name); + return new Metadata(this.Name); } public override Task> ResolveBooleanValue(string flagKey, bool defaultValue, @@ -76,5 +93,27 @@ public override Task> ResolveStructureValue(string flag { return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); } + + public override ProviderStatus GetStatus() + { + return this._status; + } + + public void SetStatus(ProviderStatus status) + { + this._status = status; + } + + public override Task Initialize(EvaluationContext context) + { + this._status = ProviderStatus.Ready; + this.EventChannel.Writer.WriteAsync(new ProviderEventPayload { Type = ProviderEventTypes.ProviderReady, ProviderName = this.GetMetadata().Name }); + return base.Initialize(context); + } + + internal void SendEvent(ProviderEventTypes eventType) + { + this.EventChannel.Writer.WriteAsync(new ProviderEventPayload { Type = eventType, ProviderName = this.GetMetadata().Name }); + } } }