Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ builder.Services.AddOpenFeature(featureBuilder => {
featureBuilder
.AddHostedFeatureLifecycle() // From Hosting package
.AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ })
.AddHook<LoggingHook>()
.AddInMemoryProvider();
});
```
Expand All @@ -446,6 +447,7 @@ builder.Services.AddOpenFeature(featureBuilder => {
featureBuilder
.AddHostedFeatureLifecycle()
.AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ })
.AddHook((serviceProvider) => new LoggingHook( /* Custom configuration */ ))
.AddInMemoryProvider("name1")
.AddInMemoryProvider("name2")
.AddPolicyName(options => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToke
var featureProvider = _serviceProvider.GetRequiredKeyedService<FeatureProvider>(name);
await _featureApi.SetProviderAsync(name, featureProvider).ConfigureAwait(false);
}

var hooks = new List<Hook>();
foreach (var hookName in options.HookNames)
{
var hook = _serviceProvider.GetRequiredKeyedService<Hook>(hookName);
hooks.Add(hook);
}

_featureApi.AddHooks(hooks);
}

/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,45 @@ public static OpenFeatureBuilder AddPolicyName<TOptions>(this OpenFeatureBuilder
/// <returns>The configured <see cref="OpenFeatureBuilder"/> instance.</returns>
public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action<PolicyNameOptions> configureOptions)
=> AddPolicyName<PolicyNameOptions>(builder, configureOptions);

/// <summary>
/// Adds a feature hook to the service collection using a factory method. Hooks added here are not domain-bound.
/// </summary>
/// <typeparam name="THook">The type of<see cref="Hook"/> to be added.</typeparam>
/// <param name="builder">The <see cref="OpenFeatureBuilder"/> instance.</param>
/// <param name="implementationFactory">Optional factory for controlling how <typeparamref name="THook"/> will be created in the DI container.</param>
/// <returns>The <see cref="OpenFeatureBuilder"/> instance.</returns>
public static OpenFeatureBuilder AddHook<THook>(this OpenFeatureBuilder builder, Func<IServiceProvider, THook>? implementationFactory = null)
where THook : Hook
{
return builder.AddHook(typeof(THook).Name, implementationFactory);
}

/// <summary>
/// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound.
/// </summary>
/// <typeparam name="THook">The type of<see cref="Hook"/> to be added.</typeparam>
/// <param name="builder">The <see cref="OpenFeatureBuilder"/> instance.</param>
/// <param name="hookName">The name of the <see cref="Hook"/> that is being added.</param>
/// <param name="implementationFactory">Optional factory for controlling how <typeparamref name="THook"/> will be created in the DI container.</param>
/// <returns>The <see cref="OpenFeatureBuilder"/> instance.</returns>
public static OpenFeatureBuilder AddHook<THook>(this OpenFeatureBuilder builder, string hookName, Func<IServiceProvider, THook>? implementationFactory = null)
where THook : Hook
{
builder.Services.PostConfigure<OpenFeatureOptions>(options => options.AddHookName(hookName));

if (implementationFactory is not null)
{
builder.Services.TryAddKeyedSingleton<Hook>(hookName, (serviceProvider, key) =>
{
return implementationFactory(serviceProvider);
});
}
else
{
builder.Services.TryAddKeyedSingleton<Hook, THook>(hookName);
}

return builder;
}
}
12 changes: 12 additions & 0 deletions src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,16 @@ protected internal void AddProviderName(string? name)
}
}
}

private readonly HashSet<string> _hookNames = [];

internal IReadOnlyCollection<string> HookNames => _hookNames;

internal void AddHookName(string name)
{
lock (_hookNames)
{
_hookNames.Add(name);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,46 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Abstractions;
using OpenFeature.DependencyInjection.Internal;
using Xunit;

namespace OpenFeature.DependencyInjection.Tests;

public class FeatureLifecycleManagerTests
{
private readonly FeatureLifecycleManager _systemUnderTest;
private readonly IServiceProvider _mockServiceProvider;
private readonly IServiceCollection _serviceCollection;

public FeatureLifecycleManagerTests()
{
Api.Instance.SetContext(null);
Api.Instance.ClearHooks();

_mockServiceProvider = Substitute.For<IServiceProvider>();

var options = new OpenFeatureOptions();
options.AddDefaultProviderName();
var optionsMock = Substitute.For<IOptions<OpenFeatureOptions>>();
optionsMock.Value.Returns(options);

_mockServiceProvider.GetService<IOptions<OpenFeatureOptions>>().Returns(optionsMock);

_systemUnderTest = new FeatureLifecycleManager(
Api.Instance,
_mockServiceProvider,
Substitute.For<ILogger<FeatureLifecycleManager>>());
_serviceCollection = new ServiceCollection()
.Configure<OpenFeatureOptions>(options =>
{
options.AddDefaultProviderName();
});
}

[Fact]
public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExists()
{
// Arrange
var featureProvider = new NoOpFeatureProvider();
_mockServiceProvider.GetService(typeof(FeatureProvider)).Returns(featureProvider);
_serviceCollection.AddSingleton<FeatureProvider>(featureProvider);

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

// Act
await _systemUnderTest.EnsureInitializedAsync().ConfigureAwait(true);
await sut.EnsureInitializedAsync().ConfigureAwait(true);

// Assert
Assert.Equal(featureProvider, Api.Instance.GetProvider());
Expand All @@ -50,14 +43,42 @@ public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExi
public async Task EnsureInitializedAsync_ShouldThrowException_WhenProviderDoesNotExist()
{
// Arrange
_mockServiceProvider.GetService(typeof(FeatureProvider)).Returns(null as FeatureProvider);
_serviceCollection.RemoveAll<FeatureProvider>();

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

// Act
var act = () => _systemUnderTest.EnsureInitializedAsync().AsTask();
var act = () => sut.EnsureInitializedAsync().AsTask();

// Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(act).ConfigureAwait(true);
Assert.NotNull(exception);
Assert.False(string.IsNullOrWhiteSpace(exception.Message));
}

[Fact]
public async Task EnsureInitializedAsync_ShouldSetHook_WhenHooksAreRegistered()
{
// Arrange
var featureProvider = new NoOpFeatureProvider();
var hook = new NoOpHook();

_serviceCollection.AddSingleton<FeatureProvider>(featureProvider)
.AddKeyedSingleton<Hook>("NoOpHook", (_, key) => hook)
.Configure<OpenFeatureOptions>(options =>
{
options.AddHookName("NoOpHook");
});

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

// Act
await sut.EnsureInitializedAsync().ConfigureAwait(true);

// Assert
var actual = Api.Instance.GetHooks().FirstOrDefault();
Assert.Equal(hook, actual);
}
}
26 changes: 26 additions & 0 deletions test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using OpenFeature.Model;

namespace OpenFeature.DependencyInjection.Tests;

internal class NoOpHook : Hook
{
public override ValueTask<EvaluationContext> BeforeAsync<T>(HookContext<T> context, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
return base.BeforeAsync(context, hints, cancellationToken);
}

public override ValueTask AfterAsync<T>(HookContext<T> context, FlagEvaluationDetails<T> details, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
return base.AfterAsync(context, details, hints, cancellationToken);
}

public override ValueTask FinallyAsync<T>(HookContext<T> context, FlagEvaluationDetails<T> evaluationDetails, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken);
}

public override ValueTask ErrorAsync<T>(HookContext<T> context, Exception error, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
return base.ErrorAsync(context, error, hints, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,64 @@ public void AddProvider_ConfiguresPolicyNameAcrossMultipleProviderSetups(int pro
Assert.NotNull(provider);
Assert.IsType<NoOpFeatureProvider>(provider);
}

[Fact]
public void AddHook_AddsHookAsKeyedService()
{
// Arrange
_systemUnderTest.AddHook<NoOpHook>();

var serviceProvider = _services.BuildServiceProvider();

// Act
var hook = serviceProvider.GetKeyedService<Hook>("NoOpHook");

// Assert
Assert.NotNull(hook);
}

[Fact]
public void AddHook_AddsHookNameToOpenFeatureOptions()
{
// Arrange
_systemUnderTest.AddHook(sp => new NoOpHook());

var serviceProvider = _services.BuildServiceProvider();

// Act
var options = serviceProvider.GetRequiredService<IOptions<OpenFeatureOptions>>();

// Assert
Assert.Contains(options.Value.HookNames, t => t == "NoOpHook");
}

[Fact]
public void AddHook_WithSpecifiedNameToOpenFeatureOptions()
{
// Arrange
_systemUnderTest.AddHook<NoOpHook>("my-custom-name");

var serviceProvider = _services.BuildServiceProvider();

// Act
var hook = serviceProvider.GetKeyedService<Hook>("my-custom-name");

// Assert
Assert.NotNull(hook);
}

[Fact]
public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService()
{
// Arrange
_systemUnderTest.AddHook("my-custom-name", (serviceProvider) => new NoOpHook());

var serviceProvider = _services.BuildServiceProvider();

// Act
var hook = serviceProvider.GetKeyedService<Hook>("my-custom-name");

// Assert
Assert.NotNull(hook);
}
}
38 changes: 35 additions & 3 deletions test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Testing;
using OpenFeature.DependencyInjection.Providers.Memory;
using OpenFeature.Hooks;
using OpenFeature.IntegrationTests.Services;
using OpenFeature.Providers.Memory;

Expand All @@ -27,7 +29,8 @@ public class FeatureFlagIntegrationTest
public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string userId, bool expectedResult, ServiceLifetime serviceLifetime)
{
// Arrange
using var server = await CreateServerAsync(serviceLifetime, services =>
var logger = new FakeLogger();
using var server = await CreateServerAsync(serviceLifetime, logger, services =>
{
switch (serviceLifetime)
{
Expand All @@ -50,7 +53,7 @@ public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string us

// Act
var response = await client.GetAsync(requestUri).ConfigureAwait(true);
var responseContent = await response.Content.ReadFromJsonAsync<FeatureFlagResponse<bool>>().ConfigureAwait(true); ;
var responseContent = await response.Content.ReadFromJsonAsync<FeatureFlagResponse<bool>>().ConfigureAwait(true);

// Assert
Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK.");
Expand All @@ -59,7 +62,35 @@ public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string us
Assert.Equal(expectedResult, responseContent.FeatureValue);
}

private static async Task<TestServer> CreateServerAsync(ServiceLifetime serviceLifetime, Action<IServiceCollection>? configureServices = null)
[Fact]
public async Task VerifyLoggingHookIsRegisteredAsync()
{
// Arrange
var logger = new FakeLogger();
using var server = await CreateServerAsync(ServiceLifetime.Transient, logger, services =>
{
services.AddTransient<IFeatureFlagConfigurationService, FlagConfigurationService>();
}).ConfigureAwait(true);

var client = server.CreateClient();
var requestUri = $"/features/{TestUserId}/flags/{FeatureA}";

// Act
var response = await client.GetAsync(requestUri).ConfigureAwait(true);
var logs = logger.Collector.GetSnapshot();

// Assert
Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK.");
Assert.Equal(4, logs.Count);
Assert.Multiple(() =>
{
Assert.Contains("Before Flag Evaluation", logs[0].Message);
Assert.Contains("After Flag Evaluation", logs[1].Message);
});
}

private static async Task<TestServer> CreateServerAsync(ServiceLifetime serviceLifetime, FakeLogger logger,
Action<IServiceCollection>? configureServices = null)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
Expand Down Expand Up @@ -94,6 +125,7 @@ private static async Task<TestServer> CreateServerAsync(ServiceLifetime serviceL
return flagService.GetFlags();
}
});
cfg.AddHook(serviceProvider => new LoggingHook(logger));
});

var app = builder.Build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.Testing" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down