diff --git a/Directory.Packages.props b/Directory.Packages.props index 97c4baa6..9b6fa335 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + @@ -18,6 +19,7 @@ + diff --git a/OpenFeature.sln b/OpenFeature.sln index 6f1cce8d..c5c571ee 100644 --- a/OpenFeature.sln +++ b/OpenFeature.sln @@ -73,8 +73,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{65FBA159-2 EndProject 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("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Tests", "test\OpenFeature.Tests\OpenFeature.Tests.csproj", "{49BB42BA-10A6-4DA3-A7D5-38C968D57837}" 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 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Benchmarks", "test\OpenFeature.Benchmarks\OpenFeature.Benchmarks.csproj", "{90E7EAD3-251E-4490-AF78-E758E33518E5}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.E2ETests", "test\OpenFeature.E2ETests\OpenFeature.E2ETests.csproj", "{7398C446-2630-4F8C-9278-4E807720E9E5}" @@ -89,10 +93,18 @@ 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 {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.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 {90E7EAD3-251E-4490-AF78-E758E33518E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {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 @@ -107,7 +119,9 @@ Global 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} + {4588FE3C-EB7E-4BA5-BD77-E15131DB3B29} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} {90E7EAD3-251E-4490-AF78-E758E33518E5} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} {7398C446-2630-4F8C-9278-4E807720E9E5} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} {C4746B8C-FE19-440B-922C-C2377F906FE8} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} diff --git a/build/Common.prod.props b/build/Common.prod.props index b797ea0d..4378dfeb 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -28,4 +28,7 @@ + + + diff --git a/src/OpenFeature.Extensions.Hosting/Internal/OpenFeatureHostedService.cs b/src/OpenFeature.Extensions.Hosting/Internal/OpenFeatureHostedService.cs new file mode 100644 index 00000000..a43aa1e2 --- /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.SetProviderAsync(provider.GetMetadata().Name, provider).ConfigureAwait(false); + + if (this._api.GetProviderMetadata() is { Name: "No-op Provider" }) + await this._api.SetProviderAsync(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..cb66072d --- /dev/null +++ b/src/OpenFeature.Extensions.Hosting/OpenFeature.Extensions.Hosting.csproj @@ -0,0 +1,25 @@ + + + + enable + netstandard2.0;net6.0;net7.0;net8.0;net462 + + + + README.md + OpenFeature + + + + + + + + + + + + + + + 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..078e9a4d --- /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 AddContext( + this OpenFeatureBuilder builder, + Action configure) + { + Check.NotNull(builder); + Check.NotNull(configure); + + AddContext(builder, null, (b, _, _) => configure(b)); + + return builder; + } + + /// + /// + /// + /// + /// + /// + /// + /// + public static OpenFeatureBuilder AddContext( + this OpenFeatureBuilder builder, + Action configure) + { + Check.NotNull(builder); + Check.NotNull(configure); + + AddContext(builder, null, (b, _, s) => configure(b, s)); + + return builder; + } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static OpenFeatureBuilder AddContext( + 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..d5f99594 --- /dev/null +++ b/src/Shared/Check.cs @@ -0,0 +1,22 @@ +#nullable enable +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..48777c76 --- /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 + + + + + + + + + + + + + + + +