From 53fdc1fffe3afa7357158c3f779abc1fee869cb2 Mon Sep 17 00:00:00 2001 From: Austin Drenski Date: Tue, 16 Jan 2024 13:34:29 -0500 Subject: [PATCH] Add OpenFeature.Extensions.Hosting package See: open-feature/dotnet-sdk-contrib#127 Signed-off-by: Austin Drenski --- .github/workflows/ci.yml | 4 +- OpenFeature.sln | 14 ++ build/Common.prod.props | 4 + build/Common.props | 4 +- build/Common.tests.props | 1 + .../Internal/OpenFeatureHostedService.cs | 36 +++++ .../OpenFeature.Extensions.Hosting.csproj | 21 +++ .../OpenFeatureBuilder.cs | 9 ++ .../OpenFeatureBuilderExtensions.cs | 145 ++++++++++++++++++ .../OpenFeatureServiceCollectionExtensions.cs | 44 ++++++ .../CallerArgumentExpressionAttribute.cs | 24 +++ src/Shared/Check.cs | 21 +++ src/Shared/IsExternalInit.cs | 24 +++ .../HostingTest.cs | 87 +++++++++++ ...penFeature.Extensions.Hosting.Tests.csproj | 25 +++ 15 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 src/OpenFeature.Extensions.Hosting/Internal/OpenFeatureHostedService.cs create mode 100644 src/OpenFeature.Extensions.Hosting/OpenFeature.Extensions.Hosting.csproj create mode 100644 src/OpenFeature.Extensions.Hosting/OpenFeatureBuilder.cs create mode 100644 src/OpenFeature.Extensions.Hosting/OpenFeatureBuilderExtensions.cs create mode 100644 src/OpenFeature.Extensions.Hosting/OpenFeatureServiceCollectionExtensions.cs create mode 100644 src/Shared/CallerArgumentExpressionAttribute.cs create mode 100644 src/Shared/Check.cs create mode 100644 src/Shared/IsExternalInit.cs create mode 100644 test/OpenFeature.Extensions.Hosting.Tests/HostingTest.cs create mode 100644 test/OpenFeature.Extensions.Hosting.Tests/OpenFeature.Extensions.Hosting.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9ff3649..8a77fdf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: 7.0.x - name: Run Tests - run: dotnet test test/OpenFeature.Tests/ --configuration Release --logger GitHubActions + run: dotnet test --logger GitHubActions unit-tests-windows: runs-on: windows-latest @@ -43,4 +43,4 @@ jobs: 7.0.x - name: Run Tests - run: dotnet test test\OpenFeature.Tests\ --configuration Release --logger GitHubActions + run: dotnet test --logger GitHubActions diff --git a/OpenFeature.sln b/OpenFeature.sln index 5ed0e809..c7ac68a2 100644 --- a/OpenFeature.sln +++ b/OpenFeature.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.4.33213.308 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature", "src\OpenFeature\OpenFeature.csproj", "{07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Extensions.Hosting", "src\OpenFeature.Extensions.Hosting\OpenFeature.Extensions.Hosting.csproj", "{F2DB11D0-15E7-4C1F-936A-37D23EECECC0}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{72005F60-C2E8-40BF-AE95-893635134D7D}" ProjectSection(SolutionItems) = preProject .github\workflows\code-coverage.yml = .github\workflows\code-coverage.yml @@ -38,6 +40,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Benchmarks", "t EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.E2ETests", "test\OpenFeature.E2ETests\OpenFeature.E2ETests.csproj", "{90E7EAD3-251E-4490-AF78-E758E33518E5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Extensions.Hosting.Tests", "test\OpenFeature.Extensions.Hosting.Tests\OpenFeature.Extensions.Hosting.Tests.csproj", "{4588FE3C-EB7E-4BA5-BD77-E15131DB3B29}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,6 +52,10 @@ Global {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}.Debug|Any CPU.Build.0 = Debug|Any CPU {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}.Release|Any CPU.ActiveCfg = Release|Any CPU {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}.Release|Any CPU.Build.0 = Release|Any CPU + {F2DB11D0-15E7-4C1F-936A-37D23EECECC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2DB11D0-15E7-4C1F-936A-37D23EECECC0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2DB11D0-15E7-4C1F-936A-37D23EECECC0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2DB11D0-15E7-4C1F-936A-37D23EECECC0}.Release|Any CPU.Build.0 = Release|Any CPU {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Debug|Any CPU.Build.0 = Debug|Any CPU {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -56,14 +64,20 @@ Global {90E7EAD3-251E-4490-AF78-E758E33518E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {90E7EAD3-251E-4490-AF78-E758E33518E5}.Release|Any CPU.ActiveCfg = Release|Any CPU {90E7EAD3-251E-4490-AF78-E758E33518E5}.Release|Any CPU.Build.0 = Release|Any CPU + {4588FE3C-EB7E-4BA5-BD77-E15131DB3B29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4588FE3C-EB7E-4BA5-BD77-E15131DB3B29}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4588FE3C-EB7E-4BA5-BD77-E15131DB3B29}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4588FE3C-EB7E-4BA5-BD77-E15131DB3B29}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} + {F2DB11D0-15E7-4C1F-936A-37D23EECECC0} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} {49BB42BA-10A6-4DA3-A7D5-38C968D57837} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} {90E7EAD3-251E-4490-AF78-E758E33518E5} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} + {4588FE3C-EB7E-4BA5-BD77-E15131DB3B29} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {41F01B78-FB06-404F-8AD0-6ED6973F948F} diff --git a/build/Common.prod.props b/build/Common.prod.props index 63a52d44..f0c094b1 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -39,4 +39,8 @@ true + + + + diff --git a/build/Common.props b/build/Common.props index 65bf984d..eee96e25 100644 --- a/build/Common.props +++ b/build/Common.props @@ -1,8 +1,9 @@ - 7.3 + latest true true + enable @@ -19,6 +20,7 @@ Please sort alphabetically. Refer to https://docs.microsoft.com/nuget/concepts/package-versioning for semver syntax. --> + [8.0.0,) [2.0,) [1.0.0,2.0) diff --git a/build/Common.tests.props b/build/Common.tests.props index 060e749d..cec4da3f 100644 --- a/build/Common.tests.props +++ b/build/Common.tests.props @@ -29,6 +29,7 @@ [3.1.2] [6.7.0] [2.3.3] + [8.0.0] [17.2.0] [5.0.0] [2.4.3,3.0) diff --git a/src/OpenFeature.Extensions.Hosting/Internal/OpenFeatureHostedService.cs b/src/OpenFeature.Extensions.Hosting/Internal/OpenFeatureHostedService.cs new file mode 100644 index 00000000..633746d4 --- /dev/null +++ b/src/OpenFeature.Extensions.Hosting/Internal/OpenFeatureHostedService.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +namespace OpenFeature.Internal; + +/// +/// +/// +public sealed class OpenFeatureHostedService(Api api, IEnumerable providers) : IHostedLifecycleService +{ + readonly Api _api = Check.NotNull(api); + readonly IEnumerable _providers = Check.NotNull(providers); + + async Task IHostedLifecycleService.StartingAsync(CancellationToken cancellationToken) + { + foreach (var provider in this._providers) + { + await this._api.SetProvider(provider.GetMetadata().Name, provider).ConfigureAwait(false); + + if (this._api.GetProviderMetadata() is {Name: "No-op Provider"}) + await this._api.SetProvider(provider).ConfigureAwait(false); + } + } + + Task IHostedService.StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + Task IHostedLifecycleService.StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + Task IHostedLifecycleService.StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + Task IHostedService.StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + Task IHostedLifecycleService.StoppedAsync(CancellationToken cancellationToken) => this._api.Shutdown(); +} diff --git a/src/OpenFeature.Extensions.Hosting/OpenFeature.Extensions.Hosting.csproj b/src/OpenFeature.Extensions.Hosting/OpenFeature.Extensions.Hosting.csproj new file mode 100644 index 00000000..2ac2113b --- /dev/null +++ b/src/OpenFeature.Extensions.Hosting/OpenFeature.Extensions.Hosting.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0;net6.0;net7.0;net8.0;net462 + OpenFeature + README.md + + + + + + + + + + + + + + + diff --git a/src/OpenFeature.Extensions.Hosting/OpenFeatureBuilder.cs b/src/OpenFeature.Extensions.Hosting/OpenFeatureBuilder.cs new file mode 100644 index 00000000..61928faf --- /dev/null +++ b/src/OpenFeature.Extensions.Hosting/OpenFeatureBuilder.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenFeature; + +/// +/// +/// +/// +public sealed record OpenFeatureBuilder(IServiceCollection Services); diff --git a/src/OpenFeature.Extensions.Hosting/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.Extensions.Hosting/OpenFeatureBuilderExtensions.cs new file mode 100644 index 00000000..70bf40fd --- /dev/null +++ b/src/OpenFeature.Extensions.Hosting/OpenFeatureBuilderExtensions.cs @@ -0,0 +1,145 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using OpenFeature.Internal; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// +/// +public static class OpenFeatureBuilderExtensions +{ + /// + /// + /// + /// + /// + /// + /// + /// + public static OpenFeatureBuilder AddEvaluationContext( + this OpenFeatureBuilder builder, + Action configure) + { + Check.NotNull(builder); + Check.NotNull(configure); + + AddEvaluationContext(builder, null, (b, _, _) => configure(b)); + + return builder; + } + + /// + /// + /// + /// + /// + /// + /// + /// + public static OpenFeatureBuilder AddEvaluationContext( + this OpenFeatureBuilder builder, + Action configure) + { + Check.NotNull(builder); + Check.NotNull(configure); + + AddEvaluationContext(builder, null, (b, _, s) => configure(b, s)); + + return builder; + } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static OpenFeatureBuilder AddEvaluationContext( + this OpenFeatureBuilder builder, + string? providerName, + Action configure) + { + Check.NotNull(builder); + Check.NotNull(configure); + + builder.Services.AddKeyedSingleton(providerName, (services, key) => + { + var b = EvaluationContext.Builder(); + + configure(b, key as string, services); + + return b.Build(); + }); + + return builder; + } + + /// + /// + /// + /// + /// + public static void TryAddOpenFeatureClient(this OpenFeatureBuilder builder, string? providerName = null) + { + Check.NotNull(builder); + + builder.Services.AddHostedService(); + + builder.Services.TryAddKeyedSingleton(providerName, static (services, providerName) => + { + var api = providerName switch + { + null => Api.Instance, + not null => services.GetRequiredKeyedService(null) + }; + + api.AddHooks(services.GetKeyedServices(providerName)); + api.SetContext(services.GetRequiredKeyedService(providerName).Build()); + + return api; + }); + + builder.Services.TryAddKeyedSingleton(providerName, static (services, providerName) => providerName switch + { + null => services.GetRequiredService>(), + not null => services.GetRequiredService().CreateLogger($"OpenFeature.FeatureClient.{providerName}") + }); + + builder.Services.TryAddKeyedTransient(providerName, static (services, providerName) => + { + var builder = providerName switch + { + null => EvaluationContext.Builder(), + not null => services.GetRequiredKeyedService(null) + }; + + foreach (var c in services.GetKeyedServices(providerName)) + { + builder.Merge(c); + } + + return builder; + }); + + builder.Services.TryAddKeyedTransient(providerName, static (services, providerName) => + { + var api = services.GetRequiredService(); + + return api.GetClient( + api.GetProviderMetadata(providerName as string).Name, + null, + services.GetRequiredKeyedService(providerName), + services.GetRequiredKeyedService(providerName).Build()); + }); + + if (providerName is not null) + builder.Services.Replace(ServiceDescriptor.Transient(services => services.GetRequiredKeyedService(providerName))); + } +} diff --git a/src/OpenFeature.Extensions.Hosting/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.Extensions.Hosting/OpenFeatureServiceCollectionExtensions.cs new file mode 100644 index 00000000..edf2266a --- /dev/null +++ b/src/OpenFeature.Extensions.Hosting/OpenFeatureServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using System; +using OpenFeature; + +#pragma warning disable IDE0130 // Namespace does not match folder structure +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// +/// +public static class OpenFeatureServiceCollectionExtensions +{ + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddOpenFeature(this IServiceCollection services, Action configure) + { + Check.NotNull(services); + Check.NotNull(configure); + + configure(AddOpenFeature(services)); + + return services; + } + + /// + /// + /// + /// + /// + public static OpenFeatureBuilder AddOpenFeature(this IServiceCollection services) + { + Check.NotNull(services); + + var builder = new OpenFeatureBuilder(services); + + builder.TryAddOpenFeatureClient(); + + return builder; + } +} diff --git a/src/Shared/CallerArgumentExpressionAttribute.cs b/src/Shared/CallerArgumentExpressionAttribute.cs new file mode 100644 index 00000000..b8b364bf --- /dev/null +++ b/src/Shared/CallerArgumentExpressionAttribute.cs @@ -0,0 +1,24 @@ +// @formatter:off +// ReSharper disable All +#if NETCOREAPP3_0_OR_GREATER +// https://github.com/dotnet/runtime/issues/96197 +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.CallerArgumentExpressionAttribute))] +#else +#pragma warning disable +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] + internal sealed class CallerArgumentExpressionAttribute : Attribute + { + public CallerArgumentExpressionAttribute(string parameterName) + { + ParameterName = parameterName; + } + + public string ParameterName { get; } + } +} +#endif diff --git a/src/Shared/Check.cs b/src/Shared/Check.cs new file mode 100644 index 00000000..cf74b9d9 --- /dev/null +++ b/src/Shared/Check.cs @@ -0,0 +1,21 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace OpenFeature; + +[DebuggerStepThrough] +static class Check +{ + public static T NotNull(T? value, [CallerArgumentExpression("value")] string name = null!) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value, name); +#else + if (value is null) + throw new ArgumentNullException(name); +#endif + + return value; + } +} diff --git a/src/Shared/IsExternalInit.cs b/src/Shared/IsExternalInit.cs new file mode 100644 index 00000000..a020657f --- /dev/null +++ b/src/Shared/IsExternalInit.cs @@ -0,0 +1,24 @@ +// @formatter:off +// ReSharper disable All +#if NET5_0_OR_GREATER +// https://github.com/dotnet/runtime/issues/96197 +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] +#else +#pragma warning disable +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices +{ + /// + /// Reserved to be used by the compiler for tracking metadata. + /// This class should not be used by developers in source code. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + static class IsExternalInit + { + } +} +#endif diff --git a/test/OpenFeature.Extensions.Hosting.Tests/HostingTest.cs b/test/OpenFeature.Extensions.Hosting.Tests/HostingTest.cs new file mode 100644 index 00000000..f5e96625 --- /dev/null +++ b/test/OpenFeature.Extensions.Hosting.Tests/HostingTest.cs @@ -0,0 +1,87 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests; + +public sealed class HostingTest +{ + [Fact] + public async Task Can_register_no_op() + { + var builder = Host.CreateApplicationBuilder(); + + builder.Services.AddOpenFeature(); + + using var app = builder.Build(); + + await app.StartAsync().ConfigureAwait(false); + + Assert.Equal(Api.Instance, app.Services.GetRequiredService()); + Assert.Equal(Api.Instance.GetProviderMetadata().Name, app.Services.GetRequiredService().GetMetadata().Name); + + Assert.Empty(Api.Instance.GetContext().AsDictionary()); + Assert.Empty(app.Services.GetRequiredService().Build().AsDictionary()); + Assert.Empty(app.Services.GetServices()); + Assert.Empty(app.Services.GetServices()); + Assert.Empty(app.Services.GetServices()); + + await app.StopAsync().ConfigureAwait(false); + } + + [Fact] + public async Task Can_register_some_feature_provider() + { + var builder = Host.CreateApplicationBuilder(); + + builder.Services.AddOpenFeature(static builder => + { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.TryAddOpenFeatureClient(SomeFeatureProvider.Name); + }); + + using var app = builder.Build(); + + Assert.Equal(Api.Instance, app.Services.GetRequiredService()); + Assert.Equal("No-op Provider", app.Services.GetRequiredService().GetProviderMetadata().Name); + + await app.StartAsync().ConfigureAwait(false); + + Assert.Equal(Api.Instance, app.Services.GetRequiredService()); + Assert.Equal(SomeFeatureProvider.Name, app.Services.GetRequiredService().GetProviderMetadata().Name); + Assert.Equal(SomeFeatureProvider.Name, app.Services.GetRequiredService().GetMetadata().Name); + + Assert.Empty(Api.Instance.GetContext().AsDictionary()); + Assert.Empty(app.Services.GetRequiredService().Build().AsDictionary()); + Assert.Empty(app.Services.GetServices()); + Assert.Empty(app.Services.GetServices()); + Assert.NotEmpty(app.Services.GetServices()); + + await app.StopAsync().ConfigureAwait(false); + } + + sealed class SomeFeatureProvider : FeatureProvider + { + public const string Name = "some_feature_provider"; + + public override Metadata GetMetadata() => new(Name); + + public override Task> ResolveBooleanValue(string flagKey, bool defaultValue, EvaluationContext? context = null) + => Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override Task> ResolveStringValue(string flagKey, string defaultValue, EvaluationContext? context = null) + => Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override Task> ResolveIntegerValue(string flagKey, int defaultValue, EvaluationContext? context = null) + => Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override Task> ResolveDoubleValue(string flagKey, double defaultValue, EvaluationContext? context = null) + => Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override Task> ResolveStructureValue(string flagKey, Value defaultValue, EvaluationContext? context = null) + => Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } +} diff --git a/test/OpenFeature.Extensions.Hosting.Tests/OpenFeature.Extensions.Hosting.Tests.csproj b/test/OpenFeature.Extensions.Hosting.Tests/OpenFeature.Extensions.Hosting.Tests.csproj new file mode 100644 index 00000000..e6758e62 --- /dev/null +++ b/test/OpenFeature.Extensions.Hosting.Tests/OpenFeature.Extensions.Hosting.Tests.csproj @@ -0,0 +1,25 @@ + + + + net6.0;net7.0;net8.0 + $(TargetFrameworks);net462 + + + + OpenFeature + + + + + + + + + + + + + + + +