diff --git a/README.md b/README.md index b0063d3a4..d21c111e6 100644 --- a/README.md +++ b/README.md @@ -433,9 +433,18 @@ For a basic configuration, you can use the InMemoryProvider. This provider is si builder.Services.AddOpenFeature(featureBuilder => { featureBuilder .AddHostedFeatureLifecycle() // From Hosting package + .AddInMemoryProvider(); +}); +``` + +You can add EvaluationContext, hooks, and handlers at a global/API level as needed. + +```csharp +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) .AddHook() - .AddInMemoryProvider(); + .AddHandler(ProviderEventTypes.ProviderReady, (eventDetails) => { /* Handle event */ }); }); ``` diff --git a/src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs b/src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs new file mode 100644 index 000000000..d31b3355c --- /dev/null +++ b/src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs @@ -0,0 +1,8 @@ +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.DependencyInjection.Internal; + +internal record EventHandlerDelegateWrapper( + ProviderEventTypes ProviderEventType, + EventHandlerDelegate EventHandlerDelegate); diff --git a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs index f2c914f23..1ecac4349 100644 --- a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs +++ b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs @@ -43,6 +43,12 @@ public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToke } _featureApi.AddHooks(hooks); + + var handlers = _serviceProvider.GetServices(); + foreach (var handler in handlers) + { + _featureApi.AddHandler(handler.ProviderEventType, handler.EventHandlerDelegate); + } } /// diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs index 8f79f3946..317589606 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using OpenFeature.Constant; using OpenFeature.DependencyInjection; +using OpenFeature.DependencyInjection.Internal; using OpenFeature.Model; namespace OpenFeature; @@ -303,4 +305,34 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, return builder; } + + /// + /// Add a to 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 + /// + /// The instance. + /// The type to handle. + /// The handler which reacts to . + /// The instance. + public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, EventHandlerDelegate eventHandlerDelegate) + { + return AddHandler(builder, type, _ => eventHandlerDelegate); + } + + /// + /// Add a to 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 + /// + /// The instance. + /// The type to handle. + /// The handler factory for creating a handler which reacts to . + /// The instance. + public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, Func implementationFactory) + { + builder.Services.AddSingleton((serviceProvider) => + { + var handler = implementationFactory(serviceProvider); + return new EventHandlerDelegateWrapper(type, handler); + }); + + return builder; + } } diff --git a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs index 47cc7df5c..db9ac4e00 100644 --- a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging.Abstractions; +using OpenFeature.Constant; using OpenFeature.DependencyInjection.Internal; +using OpenFeature.Model; using Xunit; namespace OpenFeature.DependencyInjection.Tests; @@ -81,4 +83,43 @@ public async Task EnsureInitializedAsync_ShouldSetHook_WhenHooksAreRegistered() var actual = Api.Instance.GetHooks().FirstOrDefault(); Assert.Equal(hook, actual); } + + [Fact] + public async Task EnsureInitializedAsync_ShouldSetHandler_WhenHandlersAreRegistered() + { + // Arrange + EventHandlerDelegate eventHandlerDelegate = (_) => { }; + var featureProvider = new NoOpFeatureProvider(); + var handler = new EventHandlerDelegateWrapper(ProviderEventTypes.ProviderReady, eventHandlerDelegate); + + _serviceCollection.AddSingleton(featureProvider) + .AddSingleton(_ => handler); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); + + // Act + await sut.EnsureInitializedAsync().ConfigureAwait(true); + } + + [Fact] + public async Task EnsureInitializedAsync_ShouldSetHandler_WhenMultipleHandlersAreRegistered() + { + // Arrange + EventHandlerDelegate eventHandlerDelegate1 = (_) => { }; + EventHandlerDelegate eventHandlerDelegate2 = (_) => { }; + var featureProvider = new NoOpFeatureProvider(); + var handler1 = new EventHandlerDelegateWrapper(ProviderEventTypes.ProviderReady, eventHandlerDelegate1); + var handler2 = new EventHandlerDelegateWrapper(ProviderEventTypes.ProviderReady, eventHandlerDelegate2); + + _serviceCollection.AddSingleton(featureProvider) + .AddSingleton(_ => handler1) + .AddSingleton(_ => handler2); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); + + // Act + await sut.EnsureInitializedAsync().ConfigureAwait(true); + } } diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs index 07597703a..f742f98d8 100644 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using OpenFeature.DependencyInjection.Internal; using OpenFeature.Model; using Xunit; @@ -301,4 +302,58 @@ public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService() // Assert Assert.NotNull(hook); } + + [Fact] + public void AddHandler_AddsEventHandlerDelegateWrapperAsKeyedService() + { + // Arrange + EventHandlerDelegate eventHandler = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetService(); + + // Assert + Assert.NotNull(handler); + Assert.Equal(eventHandler, handler.EventHandlerDelegate); + } + + [Fact] + public void AddHandlerTwice_MultipleEventHandlerDelegateWrappersAsKeyedServices() + { + // Arrange + EventHandlerDelegate eventHandler1 = (eventDetails) => { }; + EventHandlerDelegate eventHandler2 = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler1); + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler2); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetServices(); + + // Assert + Assert.NotEmpty(handler); + Assert.Equal(eventHandler1, handler.ElementAt(0).EventHandlerDelegate); + Assert.Equal(eventHandler2, handler.ElementAt(1).EventHandlerDelegate); + } + + [Fact] + public void AddHandler_WithImplementationFactory_AddsEventHandlerDelegateWrapperAsKeyedService() + { + // Arrange + EventHandlerDelegate eventHandler = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, _ => eventHandler); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetService(); + + // Assert + Assert.NotNull(handler); + Assert.Equal(eventHandler, handler.EventHandlerDelegate); + } } diff --git a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs index 9e1f4bca9..d911135e6 100644 --- a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs +++ b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs @@ -5,7 +5,10 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; +using OpenFeature.Constant; +using OpenFeature.DependencyInjection; using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; using OpenFeature.IntegrationTests.Services; @@ -29,8 +32,7 @@ public class FeatureFlagIntegrationTest public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string userId, bool expectedResult, ServiceLifetime serviceLifetime) { // Arrange - var logger = new FakeLogger(); - using var server = await CreateServerAsync(serviceLifetime, logger, services => + using var server = await CreateServerAsync(serviceLifetime, services => { switch (serviceLifetime) { @@ -67,10 +69,17 @@ public async Task VerifyLoggingHookIsRegisteredAsync() { // Arrange var logger = new FakeLogger(); - using var server = await CreateServerAsync(ServiceLifetime.Transient, logger, services => + Action configureServices = services => { services.AddTransient(); - }).ConfigureAwait(true); + }; + + Action openFeatureBuilder = cfg => + { + cfg.AddHook(_ => new LoggingHook(logger)); + }; + + using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder).ConfigureAwait(true); var client = server.CreateClient(); var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; @@ -89,8 +98,103 @@ public async Task VerifyLoggingHookIsRegisteredAsync() }); } - private static async Task CreateServerAsync(ServiceLifetime serviceLifetime, FakeLogger logger, - Action? configureServices = null) + [Fact] + public async Task VerifyHandlerIsRegisteredAsync() + { + // Arrange + Action configureServices = services => + { + services.AddTransient(); + }; + + var handlerSuccess = false; + Action openFeatureBuilder = cfg => + { + cfg.AddHandler(ProviderEventTypes.ProviderReady, (_) => { handlerSuccess = true; }); + }; + + using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder) + .ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.True(handlerSuccess); + } + + [Fact] + public async Task VerifyMultipleHandlersAreRegisteredAsync() + { + // Arrange + Action configureServices = services => + { + services.AddTransient(); + }; + + var @lock = new Lock(); + var counter = 0; + Action openFeatureBuilder = cfg => + { + cfg.AddHandler(ProviderEventTypes.ProviderReady, (_) => { lock (@lock) { counter++; } }); + cfg.AddHandler(ProviderEventTypes.ProviderReady, (_) => { lock (@lock) { counter++; } }); + }; + + using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder) + .ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.Equal(2, counter); + } + + [Fact] + public async Task VerifyHandlersAreRegisteredWithServiceProviderAsync() + { + // Arrange + var logs = string.Empty; + Action configureServices = services => + { + services.AddFakeLogging(a => a.OutputSink = log => logs = string.Join('|', logs, log)); + services.AddTransient(); + }; + + Action openFeatureBuilder = cfg => + { + cfg.AddHandler(ProviderEventTypes.ProviderReady, sp => (@event) => + { + var innerLoger = sp.GetService>(); + innerLoger!.LogInformation("Handler invoked from builder!"); + }); + }; + + using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder) + .ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.Contains("Handler invoked from builder!", logs); + } + + private static async Task CreateServerAsync(ServiceLifetime serviceLifetime, + Action? configureServices = null, + Action? openFeatureBuilder = null) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -125,7 +229,11 @@ private static async Task CreateServerAsync(ServiceLifetime serviceL return flagService.GetFlags(); } }); - cfg.AddHook(serviceProvider => new LoggingHook(logger)); + + if (openFeatureBuilder is not null) + { + openFeatureBuilder(cfg); + } }); var app = builder.Build();