Skip to content

Commit 9b04485

Browse files
kylejuliandevaskpt
andauthored
feat: Add Extension Method for adding global Hook via DependencyInjection (#459)
<!-- Please use this template for your pull request. --> <!-- Please use the sections that you need and delete other sections --> ## This PR <!-- add the description of the PR here --> - This adds a new OpenFeatureBuilder extension method to add global or not domain-bound hooks to the OpenFeature Api. ### Related Issues <!-- add here the GitHub issue that this PR resolves if applicable --> Fixes #456 ### Notes <!-- any additional notes for this PR --> We inject any provided Hooks as Singletons in the DI container. We use keyed singletons and use the class name as the key. Maybe we'd want to use a different name to avoid conflicts? I've done some manual testing with a sample weatherforecast ASP.NET Core web application ### Follow-up Tasks <!-- anything that is related to this PR but not done here should be noted under this section --> <!-- if there is a need for a new issue, please link it here --> ### How to test <!-- if applicable, add testing instructions under this section --> --------- Signed-off-by: Kyle Julian <[email protected]> Co-authored-by: André Silva <[email protected]>
1 parent 8e3ae54 commit 9b04485

File tree

9 files changed

+229
-25
lines changed

9 files changed

+229
-25
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ builder.Services.AddOpenFeature(featureBuilder => {
434434
featureBuilder
435435
.AddHostedFeatureLifecycle() // From Hosting package
436436
.AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ })
437+
.AddHook<LoggingHook>()
437438
.AddInMemoryProvider();
438439
});
439440
```
@@ -446,6 +447,7 @@ builder.Services.AddOpenFeature(featureBuilder => {
446447
featureBuilder
447448
.AddHostedFeatureLifecycle()
448449
.AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ })
450+
.AddHook((serviceProvider) => new LoggingHook( /* Custom configuration */ ))
449451
.AddInMemoryProvider("name1")
450452
.AddInMemoryProvider("name2")
451453
.AddPolicyName(options => {

src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToke
3434
var featureProvider = _serviceProvider.GetRequiredKeyedService<FeatureProvider>(name);
3535
await _featureApi.SetProviderAsync(name, featureProvider).ConfigureAwait(false);
3636
}
37+
38+
var hooks = new List<Hook>();
39+
foreach (var hookName in options.HookNames)
40+
{
41+
var hook = _serviceProvider.GetRequiredKeyedService<Hook>(hookName);
42+
hooks.Add(hook);
43+
}
44+
45+
_featureApi.AddHooks(hooks);
3746
}
3847

3948
/// <inheritdoc />

src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,45 @@ public static OpenFeatureBuilder AddPolicyName<TOptions>(this OpenFeatureBuilder
262262
/// <returns>The configured <see cref="OpenFeatureBuilder"/> instance.</returns>
263263
public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action<PolicyNameOptions> configureOptions)
264264
=> AddPolicyName<PolicyNameOptions>(builder, configureOptions);
265+
266+
/// <summary>
267+
/// Adds a feature hook to the service collection using a factory method. Hooks added here are not domain-bound.
268+
/// </summary>
269+
/// <typeparam name="THook">The type of<see cref="Hook"/> to be added.</typeparam>
270+
/// <param name="builder">The <see cref="OpenFeatureBuilder"/> instance.</param>
271+
/// <param name="implementationFactory">Optional factory for controlling how <typeparamref name="THook"/> will be created in the DI container.</param>
272+
/// <returns>The <see cref="OpenFeatureBuilder"/> instance.</returns>
273+
public static OpenFeatureBuilder AddHook<THook>(this OpenFeatureBuilder builder, Func<IServiceProvider, THook>? implementationFactory = null)
274+
where THook : Hook
275+
{
276+
return builder.AddHook(typeof(THook).Name, implementationFactory);
277+
}
278+
279+
/// <summary>
280+
/// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound.
281+
/// </summary>
282+
/// <typeparam name="THook">The type of<see cref="Hook"/> to be added.</typeparam>
283+
/// <param name="builder">The <see cref="OpenFeatureBuilder"/> instance.</param>
284+
/// <param name="hookName">The name of the <see cref="Hook"/> that is being added.</param>
285+
/// <param name="implementationFactory">Optional factory for controlling how <typeparamref name="THook"/> will be created in the DI container.</param>
286+
/// <returns>The <see cref="OpenFeatureBuilder"/> instance.</returns>
287+
public static OpenFeatureBuilder AddHook<THook>(this OpenFeatureBuilder builder, string hookName, Func<IServiceProvider, THook>? implementationFactory = null)
288+
where THook : Hook
289+
{
290+
builder.Services.PostConfigure<OpenFeatureOptions>(options => options.AddHookName(hookName));
291+
292+
if (implementationFactory is not null)
293+
{
294+
builder.Services.TryAddKeyedSingleton<Hook>(hookName, (serviceProvider, key) =>
295+
{
296+
return implementationFactory(serviceProvider);
297+
});
298+
}
299+
else
300+
{
301+
builder.Services.TryAddKeyedSingleton<Hook, THook>(hookName);
302+
}
303+
304+
return builder;
305+
}
265306
}

src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,16 @@ protected internal void AddProviderName(string? name)
4646
}
4747
}
4848
}
49+
50+
private readonly HashSet<string> _hookNames = [];
51+
52+
internal IReadOnlyCollection<string> HookNames => _hookNames;
53+
54+
internal void AddHookName(string name)
55+
{
56+
lock (_hookNames)
57+
{
58+
_hookNames.Add(name);
59+
}
60+
}
4961
}
Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
11
using Microsoft.Extensions.DependencyInjection;
2-
using Microsoft.Extensions.Logging;
3-
using Microsoft.Extensions.Options;
4-
using NSubstitute;
2+
using Microsoft.Extensions.DependencyInjection.Extensions;
3+
using Microsoft.Extensions.Logging.Abstractions;
54
using OpenFeature.DependencyInjection.Internal;
65
using Xunit;
76

87
namespace OpenFeature.DependencyInjection.Tests;
98

109
public class FeatureLifecycleManagerTests
1110
{
12-
private readonly FeatureLifecycleManager _systemUnderTest;
13-
private readonly IServiceProvider _mockServiceProvider;
11+
private readonly IServiceCollection _serviceCollection;
1412

1513
public FeatureLifecycleManagerTests()
1614
{
1715
Api.Instance.SetContext(null);
1816
Api.Instance.ClearHooks();
1917

20-
_mockServiceProvider = Substitute.For<IServiceProvider>();
21-
22-
var options = new OpenFeatureOptions();
23-
options.AddDefaultProviderName();
24-
var optionsMock = Substitute.For<IOptions<OpenFeatureOptions>>();
25-
optionsMock.Value.Returns(options);
26-
27-
_mockServiceProvider.GetService<IOptions<OpenFeatureOptions>>().Returns(optionsMock);
28-
29-
_systemUnderTest = new FeatureLifecycleManager(
30-
Api.Instance,
31-
_mockServiceProvider,
32-
Substitute.For<ILogger<FeatureLifecycleManager>>());
18+
_serviceCollection = new ServiceCollection()
19+
.Configure<OpenFeatureOptions>(options =>
20+
{
21+
options.AddDefaultProviderName();
22+
});
3323
}
3424

3525
[Fact]
3626
public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExists()
3727
{
3828
// Arrange
3929
var featureProvider = new NoOpFeatureProvider();
40-
_mockServiceProvider.GetService(typeof(FeatureProvider)).Returns(featureProvider);
30+
_serviceCollection.AddSingleton<FeatureProvider>(featureProvider);
31+
32+
var serviceProvider = _serviceCollection.BuildServiceProvider();
33+
var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger<FeatureLifecycleManager>.Instance);
4134

4235
// Act
43-
await _systemUnderTest.EnsureInitializedAsync().ConfigureAwait(true);
36+
await sut.EnsureInitializedAsync().ConfigureAwait(true);
4437

4538
// Assert
4639
Assert.Equal(featureProvider, Api.Instance.GetProvider());
@@ -50,14 +43,42 @@ public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExi
5043
public async Task EnsureInitializedAsync_ShouldThrowException_WhenProviderDoesNotExist()
5144
{
5245
// Arrange
53-
_mockServiceProvider.GetService(typeof(FeatureProvider)).Returns(null as FeatureProvider);
46+
_serviceCollection.RemoveAll<FeatureProvider>();
47+
48+
var serviceProvider = _serviceCollection.BuildServiceProvider();
49+
var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger<FeatureLifecycleManager>.Instance);
5450

5551
// Act
56-
var act = () => _systemUnderTest.EnsureInitializedAsync().AsTask();
52+
var act = () => sut.EnsureInitializedAsync().AsTask();
5753

5854
// Assert
5955
var exception = await Assert.ThrowsAsync<InvalidOperationException>(act).ConfigureAwait(true);
6056
Assert.NotNull(exception);
6157
Assert.False(string.IsNullOrWhiteSpace(exception.Message));
6258
}
59+
60+
[Fact]
61+
public async Task EnsureInitializedAsync_ShouldSetHook_WhenHooksAreRegistered()
62+
{
63+
// Arrange
64+
var featureProvider = new NoOpFeatureProvider();
65+
var hook = new NoOpHook();
66+
67+
_serviceCollection.AddSingleton<FeatureProvider>(featureProvider)
68+
.AddKeyedSingleton<Hook>("NoOpHook", (_, key) => hook)
69+
.Configure<OpenFeatureOptions>(options =>
70+
{
71+
options.AddHookName("NoOpHook");
72+
});
73+
74+
var serviceProvider = _serviceCollection.BuildServiceProvider();
75+
var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger<FeatureLifecycleManager>.Instance);
76+
77+
// Act
78+
await sut.EnsureInitializedAsync().ConfigureAwait(true);
79+
80+
// Assert
81+
var actual = Api.Instance.GetHooks().FirstOrDefault();
82+
Assert.Equal(hook, actual);
83+
}
6384
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using OpenFeature.Model;
2+
3+
namespace OpenFeature.DependencyInjection.Tests;
4+
5+
internal class NoOpHook : Hook
6+
{
7+
public override ValueTask<EvaluationContext> BeforeAsync<T>(HookContext<T> context, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
8+
{
9+
return base.BeforeAsync(context, hints, cancellationToken);
10+
}
11+
12+
public override ValueTask AfterAsync<T>(HookContext<T> context, FlagEvaluationDetails<T> details, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
13+
{
14+
return base.AfterAsync(context, details, hints, cancellationToken);
15+
}
16+
17+
public override ValueTask FinallyAsync<T>(HookContext<T> context, FlagEvaluationDetails<T> evaluationDetails, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
18+
{
19+
return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken);
20+
}
21+
22+
public override ValueTask ErrorAsync<T>(HookContext<T> context, Exception error, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
23+
{
24+
return base.ErrorAsync(context, error, hints, cancellationToken);
25+
}
26+
}

test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,64 @@ public void AddProvider_ConfiguresPolicyNameAcrossMultipleProviderSetups(int pro
241241
Assert.NotNull(provider);
242242
Assert.IsType<NoOpFeatureProvider>(provider);
243243
}
244+
245+
[Fact]
246+
public void AddHook_AddsHookAsKeyedService()
247+
{
248+
// Arrange
249+
_systemUnderTest.AddHook<NoOpHook>();
250+
251+
var serviceProvider = _services.BuildServiceProvider();
252+
253+
// Act
254+
var hook = serviceProvider.GetKeyedService<Hook>("NoOpHook");
255+
256+
// Assert
257+
Assert.NotNull(hook);
258+
}
259+
260+
[Fact]
261+
public void AddHook_AddsHookNameToOpenFeatureOptions()
262+
{
263+
// Arrange
264+
_systemUnderTest.AddHook(sp => new NoOpHook());
265+
266+
var serviceProvider = _services.BuildServiceProvider();
267+
268+
// Act
269+
var options = serviceProvider.GetRequiredService<IOptions<OpenFeatureOptions>>();
270+
271+
// Assert
272+
Assert.Contains(options.Value.HookNames, t => t == "NoOpHook");
273+
}
274+
275+
[Fact]
276+
public void AddHook_WithSpecifiedNameToOpenFeatureOptions()
277+
{
278+
// Arrange
279+
_systemUnderTest.AddHook<NoOpHook>("my-custom-name");
280+
281+
var serviceProvider = _services.BuildServiceProvider();
282+
283+
// Act
284+
var hook = serviceProvider.GetKeyedService<Hook>("my-custom-name");
285+
286+
// Assert
287+
Assert.NotNull(hook);
288+
}
289+
290+
[Fact]
291+
public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService()
292+
{
293+
// Arrange
294+
_systemUnderTest.AddHook("my-custom-name", (serviceProvider) => new NoOpHook());
295+
296+
var serviceProvider = _services.BuildServiceProvider();
297+
298+
// Act
299+
var hook = serviceProvider.GetKeyedService<Hook>("my-custom-name");
300+
301+
// Assert
302+
Assert.NotNull(hook);
303+
}
244304
}

test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
using Microsoft.AspNetCore.TestHost;
66
using Microsoft.Extensions.DependencyInjection;
77
using Microsoft.Extensions.DependencyInjection.Extensions;
8+
using Microsoft.Extensions.Logging.Testing;
89
using OpenFeature.DependencyInjection.Providers.Memory;
10+
using OpenFeature.Hooks;
911
using OpenFeature.IntegrationTests.Services;
1012
using OpenFeature.Providers.Memory;
1113

@@ -27,7 +29,8 @@ public class FeatureFlagIntegrationTest
2729
public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string userId, bool expectedResult, ServiceLifetime serviceLifetime)
2830
{
2931
// Arrange
30-
using var server = await CreateServerAsync(serviceLifetime, services =>
32+
var logger = new FakeLogger();
33+
using var server = await CreateServerAsync(serviceLifetime, logger, services =>
3134
{
3235
switch (serviceLifetime)
3336
{
@@ -50,7 +53,7 @@ public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string us
5053

5154
// Act
5255
var response = await client.GetAsync(requestUri).ConfigureAwait(true);
53-
var responseContent = await response.Content.ReadFromJsonAsync<FeatureFlagResponse<bool>>().ConfigureAwait(true); ;
56+
var responseContent = await response.Content.ReadFromJsonAsync<FeatureFlagResponse<bool>>().ConfigureAwait(true);
5457

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

62-
private static async Task<TestServer> CreateServerAsync(ServiceLifetime serviceLifetime, Action<IServiceCollection>? configureServices = null)
65+
[Fact]
66+
public async Task VerifyLoggingHookIsRegisteredAsync()
67+
{
68+
// Arrange
69+
var logger = new FakeLogger();
70+
using var server = await CreateServerAsync(ServiceLifetime.Transient, logger, services =>
71+
{
72+
services.AddTransient<IFeatureFlagConfigurationService, FlagConfigurationService>();
73+
}).ConfigureAwait(true);
74+
75+
var client = server.CreateClient();
76+
var requestUri = $"/features/{TestUserId}/flags/{FeatureA}";
77+
78+
// Act
79+
var response = await client.GetAsync(requestUri).ConfigureAwait(true);
80+
var logs = logger.Collector.GetSnapshot();
81+
82+
// Assert
83+
Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK.");
84+
Assert.Equal(4, logs.Count);
85+
Assert.Multiple(() =>
86+
{
87+
Assert.Contains("Before Flag Evaluation", logs[0].Message);
88+
Assert.Contains("After Flag Evaluation", logs[1].Message);
89+
});
90+
}
91+
92+
private static async Task<TestServer> CreateServerAsync(ServiceLifetime serviceLifetime, FakeLogger logger,
93+
Action<IServiceCollection>? configureServices = null)
6394
{
6495
var builder = WebApplication.CreateBuilder();
6596
builder.WebHost.UseTestServer();
@@ -94,6 +125,7 @@ private static async Task<TestServer> CreateServerAsync(ServiceLifetime serviceL
94125
return flagService.GetFlags();
95126
}
96127
});
128+
cfg.AddHook(serviceProvider => new LoggingHook(logger));
97129
});
98130

99131
var app = builder.Build();

test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<PackageReference Include="coverlet.collector" />
1414
<PackageReference Include="Microsoft.NET.Test.Sdk" />
1515
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
16+
<PackageReference Include="Microsoft.Extensions.Diagnostics.Testing" />
1617
<PackageReference Include="xunit" />
1718
<PackageReference Include="xunit.runner.visualstudio">
1819
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

0 commit comments

Comments
 (0)