Skip to content
Merged
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoggingHook>()
.AddInMemoryProvider();
.AddHandler(ProviderEventTypes.ProviderReady, (eventDetails) => { /* Handle event */ });
});
```

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using OpenFeature.Constant;
using OpenFeature.Model;

namespace OpenFeature.DependencyInjection.Internal;

internal record EventHandlerDelegateWrapper(
ProviderEventTypes ProviderEventType,
EventHandlerDelegate EventHandlerDelegate);
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToke
}

_featureApi.AddHooks(hooks);

var handlers = _serviceProvider.GetServices<EventHandlerDelegateWrapper>();
foreach (var handler in handlers)
{
_featureApi.AddHandler(handler.ProviderEventType, handler.EventHandlerDelegate);
}
}

/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -303,4 +305,34 @@ public static OpenFeatureBuilder AddHook<THook>(this OpenFeatureBuilder builder,

return builder;
}

/// <summary>
/// Add a <see cref="EventHandlerDelegate"/> 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
/// </summary>
/// <param name="builder">The <see cref="OpenFeatureBuilder"/> instance.</param>
/// <param name="type">The type <see cref="ProviderEventTypes"/> to handle.</param>
/// <param name="eventHandlerDelegate">The handler which reacts to <see cref="ProviderEventTypes"/>.</param>
/// <returns>The <see cref="OpenFeatureBuilder"/> instance.</returns>
public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, EventHandlerDelegate eventHandlerDelegate)
{
return AddHandler(builder, type, _ => eventHandlerDelegate);
}

/// <summary>
/// Add a <see cref="EventHandlerDelegate"/> 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
/// </summary>
/// <param name="builder">The <see cref="OpenFeatureBuilder"/> instance.</param>
/// <param name="type">The type <see cref="ProviderEventTypes"/> to handle.</param>
/// <param name="implementationFactory">The handler factory for creating a handler which reacts to <see cref="ProviderEventTypes"/>.</param>
/// <returns>The <see cref="OpenFeatureBuilder"/> instance.</returns>
public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, Func<IServiceProvider, EventHandlerDelegate> implementationFactory)
{
builder.Services.AddSingleton((serviceProvider) =>
{
var handler = implementationFactory(serviceProvider);
return new EventHandlerDelegateWrapper(type, handler);
});

return builder;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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>(featureProvider)
.AddSingleton(_ => handler);

var serviceProvider = _serviceCollection.BuildServiceProvider();
var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger<FeatureLifecycleManager>.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>(featureProvider)
.AddSingleton(_ => handler1)
.AddSingleton(_ => handler2);

var serviceProvider = _serviceCollection.BuildServiceProvider();
var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger<FeatureLifecycleManager>.Instance);

// Act
await sut.EnsureInitializedAsync().ConfigureAwait(true);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OpenFeature.DependencyInjection.Internal;
using OpenFeature.Model;
using Xunit;

Expand Down Expand Up @@ -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<EventHandlerDelegateWrapper>();

// 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<EventHandlerDelegateWrapper>();

// 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<EventHandlerDelegateWrapper>();

// Assert
Assert.NotNull(handler);
Assert.Equal(eventHandler, handler.EventHandlerDelegate);
}
}
122 changes: 115 additions & 7 deletions test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
{
Expand Down Expand Up @@ -67,10 +69,17 @@ public async Task VerifyLoggingHookIsRegisteredAsync()
{
// Arrange
var logger = new FakeLogger();
using var server = await CreateServerAsync(ServiceLifetime.Transient, logger, services =>
Action<IServiceCollection> configureServices = services =>
{
services.AddTransient<IFeatureFlagConfigurationService, FlagConfigurationService>();
}).ConfigureAwait(true);
};

Action<OpenFeatureBuilder> 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}";
Expand All @@ -89,8 +98,103 @@ public async Task VerifyLoggingHookIsRegisteredAsync()
});
}

private static async Task<TestServer> CreateServerAsync(ServiceLifetime serviceLifetime, FakeLogger logger,
Action<IServiceCollection>? configureServices = null)
[Fact]
public async Task VerifyHandlerIsRegisteredAsync()
{
// Arrange
Action<IServiceCollection> configureServices = services =>
{
services.AddTransient<IFeatureFlagConfigurationService, FlagConfigurationService>();
};

var handlerSuccess = false;
Action<OpenFeatureBuilder> 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<IServiceCollection> configureServices = services =>
{
services.AddTransient<IFeatureFlagConfigurationService, FlagConfigurationService>();
};

var @lock = new Lock();
var counter = 0;
Action<OpenFeatureBuilder> 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<IServiceCollection> configureServices = services =>
{
services.AddFakeLogging(a => a.OutputSink = log => logs = string.Join('|', logs, log));
services.AddTransient<IFeatureFlagConfigurationService, FlagConfigurationService>();
};

Action<OpenFeatureBuilder> openFeatureBuilder = cfg =>
{
cfg.AddHandler(ProviderEventTypes.ProviderReady, sp => (@event) =>
{
var innerLoger = sp.GetService<ILogger<FeatureFlagIntegrationTest>>();
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<TestServer> CreateServerAsync(ServiceLifetime serviceLifetime,
Action<IServiceCollection>? configureServices = null,
Action<OpenFeatureBuilder>? openFeatureBuilder = null)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
Expand Down Expand Up @@ -125,7 +229,11 @@ private static async Task<TestServer> CreateServerAsync(ServiceLifetime serviceL
return flagService.GetFlags();
}
});
cfg.AddHook(serviceProvider => new LoggingHook(logger));

if (openFeatureBuilder is not null)
{
openFeatureBuilder(cfg);
}
});

var app = builder.Build();
Expand Down