diff --git a/Directory.Packages.props b/Directory.Packages.props index 7227000a..8e4347e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,17 +1,19 @@ - + true - + + + - + @@ -26,10 +28,11 @@ + - + - + diff --git a/OpenFeature.sln b/OpenFeature.sln index 6f1cce8d..e8191acd 100644 --- a/OpenFeature.sln +++ b/OpenFeature.sln @@ -77,7 +77,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Tests", "test\O 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}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.E2ETests", "test\OpenFeature.E2ETests\OpenFeature.E2ETests.csproj", "{7398C446-2630-4F8C-9278-4E807720E9E5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.DependencyInjection", "src\OpenFeature.DependencyInjection\OpenFeature.DependencyInjection.csproj", "{C5415057-2700-48B5-940A-7A10969FA639}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.DependencyInjection.Tests", "test\OpenFeature.DependencyInjection.Tests\OpenFeature.DependencyInjection.Tests.csproj", "{EB35F9F6-8A79-410E-A293-9387BC4AC9A7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Hosting", "src\OpenFeature.Hosting\OpenFeature.Hosting.csproj", "{C99DA02A-3981-45A6-B3F8-4A1A48653DEE}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -101,21 +107,36 @@ Global {7398C446-2630-4F8C-9278-4E807720E9E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {7398C446-2630-4F8C-9278-4E807720E9E5}.Release|Any CPU.ActiveCfg = Release|Any CPU {7398C446-2630-4F8C-9278-4E807720E9E5}.Release|Any CPU.Build.0 = Release|Any CPU + {C5415057-2700-48B5-940A-7A10969FA639}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5415057-2700-48B5-940A-7A10969FA639}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5415057-2700-48B5-940A-7A10969FA639}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5415057-2700-48B5-940A-7A10969FA639}.Release|Any CPU.Build.0 = Release|Any CPU + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Release|Any CPU.Build.0 = Release|Any CPU + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {9392E03B-4E6B-434C-8553-B859424388B1} = {E8916D4F-B97E-42D6-8620-ED410A106F94} + {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} = {E8916D4F-B97E-42D6-8620-ED410A106F94} + {C4746B8C-FE19-440B-922C-C2377F906FE8} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} + {09BAB3A2-E94C-490A-861C-7D1E11BB7024} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} + {4BB69DB3-9653-4197-9589-37FA6D658CB7} = {E8916D4F-B97E-42D6-8620-ED410A106F94} + {72005F60-C2E8-40BF-AE95-893635134D7D} = {E8916D4F-B97E-42D6-8620-ED410A106F94} {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223} = {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} {7398C446-2630-4F8C-9278-4E807720E9E5} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} - {C4746B8C-FE19-440B-922C-C2377F906FE8} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} - {09BAB3A2-E94C-490A-861C-7D1E11BB7024} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} - {72005F60-C2E8-40BF-AE95-893635134D7D} = {E8916D4F-B97E-42D6-8620-ED410A106F94} - {9392E03B-4E6B-434C-8553-B859424388B1} = {E8916D4F-B97E-42D6-8620-ED410A106F94} - {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} = {E8916D4F-B97E-42D6-8620-ED410A106F94} - {4BB69DB3-9653-4197-9589-37FA6D658CB7} = {E8916D4F-B97E-42D6-8620-ED410A106F94} + {C5415057-2700-48B5-940A-7A10969FA639} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {41F01B78-FB06-404F-8AD0-6ED6973F948F} diff --git a/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs b/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs new file mode 100644 index 00000000..2085bda4 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs @@ -0,0 +1,24 @@ +namespace OpenFeature; + +/// +/// Defines the contract for managing the lifecycle of a feature api. +/// +public interface IFeatureLifecycleManager +{ + /// + /// Ensures that the feature provider is properly initialized and ready to be used. + /// This method should handle all necessary checks, configuration, and setup required to prepare the feature provider. + /// + /// Propagates notification that operations should be canceled. + /// A Task representing the asynchronous operation of initializing the feature provider. + /// Thrown when the feature provider is not registered or is in an invalid state. + ValueTask EnsureInitializedAsync(CancellationToken cancellationToken = default); + + /// + /// Gracefully shuts down the feature api, ensuring all resources are properly disposed of and any persistent state is saved. + /// This method should handle all necessary cleanup and shutdown operations for the feature provider. + /// + /// Propagates notification that operations should be canceled. + /// A Task representing the asynchronous operation of shutting down the feature provider. + ValueTask ShutdownAsync(CancellationToken cancellationToken = default); +} diff --git a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs new file mode 100644 index 00000000..9472b4f9 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace OpenFeature.Internal; + +internal sealed partial class FeatureLifecycleManager : IFeatureLifecycleManager +{ + private readonly Api _featureApi; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public FeatureLifecycleManager(Api featureApi, IServiceProvider serviceProvider, ILogger logger) + { + _featureApi = featureApi; + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToken = default) + { + this.LogStartingInitializationOfFeatureProvider(); + var featureProvider = _serviceProvider.GetService(); + if (featureProvider == null) + { + throw new InvalidOperationException("Feature provider is not registered in the service collection."); + } + await _featureApi.SetProviderAsync(featureProvider).ConfigureAwait(false); + } + + /// + public async ValueTask ShutdownAsync(CancellationToken cancellationToken = default) + { + this.LogShuttingDownFeatureProvider(); + await _featureApi.ShutdownAsync().ConfigureAwait(false); + } + + [LoggerMessage(200, LogLevel.Information, "Starting initialization of the feature provider")] + partial void LogStartingInitializationOfFeatureProvider(); + + [LoggerMessage(200, LogLevel.Information, "Shutting down the feature provider")] + partial void LogShuttingDownFeatureProvider(); +} diff --git a/src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs b/src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs new file mode 100644 index 00000000..afbec6b0 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs @@ -0,0 +1,23 @@ +// @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/OpenFeature.DependencyInjection/MultiTarget/Guard.cs b/src/OpenFeature.DependencyInjection/MultiTarget/Guard.cs new file mode 100644 index 00000000..8d2726b9 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/MultiTarget/Guard.cs @@ -0,0 +1,14 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace OpenFeature; + +[DebuggerStepThrough] +internal static class Guard +{ + public static void ThrowIfNull(object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (argument is null) + throw new ArgumentNullException(paramName); + } +} diff --git a/src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs b/src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs new file mode 100644 index 00000000..87714111 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs @@ -0,0 +1,21 @@ +// @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/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj new file mode 100644 index 00000000..fcdfd6ad --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj @@ -0,0 +1,31 @@ + + + + netstandard2.0;net6.0;net8.0;net462 + enable + enable + OpenFeature + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs new file mode 100644 index 00000000..6d55dcd0 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenFeature; + +/// +/// Describes a backed by an . +/// +/// The services being configured. +public class OpenFeatureBuilder(IServiceCollection services) +{ + /// The services being configured. + public IServiceCollection Services { get; } = services; + + /// + /// Indicates whether the evaluation context has been configured. + /// This property is used to determine if specific configurations or services + /// should be initialized based on the presence of an evaluation context. + /// + public bool IsContextConfigured { get; internal set; } +} diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs new file mode 100644 index 00000000..8cc8e07a --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// Contains extension methods for the class. +/// +public static partial class OpenFeatureBuilderExtensions +{ + /// + /// This method is used to add a new context to the service collection. + /// + /// The instance. + /// the desired configuration + /// The instance. + public static OpenFeatureBuilder AddContext( + this OpenFeatureBuilder builder, + Action configure) + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configure); + + return builder.AddContext((b, _) => configure(b)); + } + + /// + /// This method is used to add a new context to the service collection. + /// + /// The instance. + /// the desired configuration + /// The instance. + public static OpenFeatureBuilder AddContext( + this OpenFeatureBuilder builder, + Action configure) + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configure); + + builder.IsContextConfigured = true; + builder.Services.TryAddTransient(provider => + { + var contextBuilder = EvaluationContext.Builder(); + configure(contextBuilder, provider); + return contextBuilder.Build(); + }); + + return builder; + } + + /// + /// Adds a feature provider to the service collection. + /// + /// The type of the feature provider, which must inherit from . + /// The instance. + /// A factory method to create the feature provider, using the service provider. + /// The instance. + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func providerFactory) + where T : FeatureProvider + { + Guard.ThrowIfNull(builder); + builder.Services.TryAddSingleton(providerFactory); + return builder; + } +} diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs new file mode 100644 index 00000000..28fb000f --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenFeature.Internal; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// Contains extension methods for the class. +/// +public static class OpenFeatureServiceCollectionExtensions +{ + /// + /// This method is used to add OpenFeature to the service collection. + /// OpenFeature will be registered as a singleton. + /// + /// + /// the desired configuration + /// the current instance + public static IServiceCollection AddOpenFeature(this IServiceCollection services, Action configure) + { + Guard.ThrowIfNull(services); + Guard.ThrowIfNull(configure); + + services.TryAddSingleton(Api.Instance); + services.TryAddSingleton(); + + var builder = new OpenFeatureBuilder(services); + configure(builder); + + if (builder.IsContextConfigured) + { + services.TryAddScoped(static provider => + { + var api = provider.GetRequiredService(); + var client = api.GetClient(); + var context = provider.GetRequiredService(); + client.SetContext(context); + return client; + }); + } + else + { + services.TryAddScoped(static provider => + { + var api = provider.GetRequiredService(); + return api.GetClient(); + }); + } + + return services; + } +} diff --git a/src/OpenFeature.Hosting/FeatureLifecycleStateOptions.cs b/src/OpenFeature.Hosting/FeatureLifecycleStateOptions.cs new file mode 100644 index 00000000..91e3047d --- /dev/null +++ b/src/OpenFeature.Hosting/FeatureLifecycleStateOptions.cs @@ -0,0 +1,18 @@ +namespace OpenFeature; + +/// +/// Represents the lifecycle state options for a feature, +/// defining the states during the start and stop lifecycle. +/// +public class FeatureLifecycleStateOptions +{ + /// + /// Gets or sets the state during the feature startup lifecycle. + /// + public FeatureStartState StartState { get; set; } = FeatureStartState.Starting; + + /// + /// Gets or sets the state during the feature shutdown lifecycle. + /// + public FeatureStopState StopState { get; set; } = FeatureStopState.Stopping; +} diff --git a/src/OpenFeature.Hosting/FeatureStartState.cs b/src/OpenFeature.Hosting/FeatureStartState.cs new file mode 100644 index 00000000..8001b9c2 --- /dev/null +++ b/src/OpenFeature.Hosting/FeatureStartState.cs @@ -0,0 +1,22 @@ +namespace OpenFeature; + +/// +/// Defines the various states for starting a feature. +/// +public enum FeatureStartState +{ + /// + /// The feature is in the process of starting. + /// + Starting, + + /// + /// The feature is at the start state. + /// + Start, + + /// + /// The feature has fully started. + /// + Started +} diff --git a/src/OpenFeature.Hosting/FeatureStopState.cs b/src/OpenFeature.Hosting/FeatureStopState.cs new file mode 100644 index 00000000..d8d6a28c --- /dev/null +++ b/src/OpenFeature.Hosting/FeatureStopState.cs @@ -0,0 +1,22 @@ +namespace OpenFeature; + +/// +/// Defines the various states for stopping a feature. +/// +public enum FeatureStopState +{ + /// + /// The feature is in the process of stopping. + /// + Stopping, + + /// + /// The feature is at the stop state. + /// + Stop, + + /// + /// The feature has fully stopped. + /// + Stopped +} diff --git a/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs new file mode 100644 index 00000000..dd9bcbee --- /dev/null +++ b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace OpenFeature; + +/// +/// A hosted service that manages the lifecycle of features within the application. +/// It ensures that features are properly initialized when the service starts +/// and gracefully shuts down when the service stops. +/// +public sealed partial class HostedFeatureLifecycleService : IHostedLifecycleService +{ + private readonly ILogger _logger; + private readonly IFeatureLifecycleManager _featureLifecycleManager; + private readonly IOptions _featureLifecycleStateOptions; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used to log lifecycle events. + /// The feature lifecycle manager responsible for initialization and shutdown. + /// Options that define the start and stop states of the feature lifecycle. + public HostedFeatureLifecycleService( + ILogger logger, + IFeatureLifecycleManager featureLifecycleManager, + IOptions featureLifecycleStateOptions) + { + _logger = logger; + _featureLifecycleManager = featureLifecycleManager; + _featureLifecycleStateOptions = featureLifecycleStateOptions; + } + + /// + /// Ensures that the feature is properly initialized when the service starts. + /// + public async Task StartingAsync(CancellationToken cancellationToken) + => await InitializeIfStateMatchesAsync(FeatureStartState.Starting, cancellationToken).ConfigureAwait(false); + + /// + /// Ensures that the feature is in the "Start" state. + /// + public async Task StartAsync(CancellationToken cancellationToken) + => await InitializeIfStateMatchesAsync(FeatureStartState.Start, cancellationToken).ConfigureAwait(false); + + /// + /// Ensures that the feature is fully started and operational. + /// + public async Task StartedAsync(CancellationToken cancellationToken) + => await InitializeIfStateMatchesAsync(FeatureStartState.Started, cancellationToken).ConfigureAwait(false); + + /// + /// Gracefully shuts down the feature when the service is stopping. + /// + public async Task StoppingAsync(CancellationToken cancellationToken) + => await ShutdownIfStateMatchesAsync(FeatureStopState.Stopping, cancellationToken).ConfigureAwait(false); + + /// + /// Ensures that the feature is in the "Stop" state. + /// + public async Task StopAsync(CancellationToken cancellationToken) + => await ShutdownIfStateMatchesAsync(FeatureStopState.Stop, cancellationToken).ConfigureAwait(false); + + /// + /// Ensures that the feature is fully stopped and no longer operational. + /// + public async Task StoppedAsync(CancellationToken cancellationToken) + => await ShutdownIfStateMatchesAsync(FeatureStopState.Stopped, cancellationToken).ConfigureAwait(false); + + /// + /// Initializes the feature lifecycle if the current state matches the expected start state. + /// + private async Task InitializeIfStateMatchesAsync(FeatureStartState expectedState, CancellationToken cancellationToken) + { + if (_featureLifecycleStateOptions.Value.StartState == expectedState) + { + this.LogInitializingFeatureLifecycleManager(expectedState); + await _featureLifecycleManager.EnsureInitializedAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Shuts down the feature lifecycle if the current state matches the expected stop state. + /// + private async Task ShutdownIfStateMatchesAsync(FeatureStopState expectedState, CancellationToken cancellationToken) + { + if (_featureLifecycleStateOptions.Value.StopState == expectedState) + { + this.LogShuttingDownFeatureLifecycleManager(expectedState); + await _featureLifecycleManager.ShutdownAsync(cancellationToken).ConfigureAwait(false); + } + } + + [LoggerMessage(200, LogLevel.Information, "Initializing the Feature Lifecycle Manager for state {State}.")] + partial void LogInitializingFeatureLifecycleManager(FeatureStartState state); + + [LoggerMessage(200, LogLevel.Information, "Shutting down the Feature Lifecycle Manager for state {State}")] + partial void LogShuttingDownFeatureLifecycleManager(FeatureStopState state); +} diff --git a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj new file mode 100644 index 00000000..48730084 --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj @@ -0,0 +1,18 @@ + + + + net6.0;net8.0 + enable + enable + OpenFeature + + + + + + + + + + + diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs new file mode 100644 index 00000000..384d0471 --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenFeature; + +/// +/// Extension methods for configuring the hosted feature lifecycle in the . +/// +public static partial class OpenFeatureBuilderExtensions +{ + /// + /// Adds the to the OpenFeatureBuilder, + /// which manages the lifecycle of features within the application. It also allows + /// configuration of the . + /// + /// The instance. + /// An optional action to configure . + /// The instance. + public static OpenFeatureBuilder AddHostedFeatureLifecycle(this OpenFeatureBuilder builder, Action? configureOptions = null) + { + if (configureOptions == null) + { + builder.Services.Configure(cfg => + { + cfg.StartState = FeatureStartState.Starting; + cfg.StopState = FeatureStopState.Stopping; + }); + } + else + { + builder.Services.Configure(configureOptions); + } + + builder.Services.AddHostedService(); + return builder; + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs new file mode 100644 index 00000000..c18495d3 --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using OpenFeature.Internal; +using Xunit; + +namespace OpenFeature.DependencyInjection.Tests; + +public class FeatureLifecycleManagerTests +{ + private readonly FeatureLifecycleManager _systemUnderTest; + private readonly IServiceProvider _mockServiceProvider; + + public FeatureLifecycleManagerTests() + { + Api.Instance.SetContext(null); + Api.Instance.ClearHooks(); + + _mockServiceProvider = Substitute.For(); + + _systemUnderTest = new FeatureLifecycleManager( + Api.Instance, + _mockServiceProvider, + Substitute.For>()); + } + + [Fact] + public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExists() + { + // Arrange + var featureProvider = new NoOpFeatureProvider(); + _mockServiceProvider.GetService(typeof(FeatureProvider)) + .Returns(featureProvider); + + // Act + await _systemUnderTest.EnsureInitializedAsync().ConfigureAwait(true); + + // Assert + Api.Instance.GetProvider().Should().BeSameAs(featureProvider); + } + + [Fact] + public async Task EnsureInitializedAsync_ShouldThrowException_WhenProviderDoesNotExist() + { + // Arrange + _mockServiceProvider.GetService(typeof(FeatureProvider)).Returns(null as FeatureProvider); + + // Act + var act = () => _systemUnderTest.EnsureInitializedAsync().AsTask(); + + // Assert + var exception = await Assert.ThrowsAsync(act).ConfigureAwait(true); + exception.Message.Should().Be("Feature provider is not registered in the service collection."); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs new file mode 100644 index 00000000..ac3e5209 --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs @@ -0,0 +1,52 @@ +using OpenFeature.Model; + +namespace OpenFeature.DependencyInjection.Tests; + +// This class replicates the NoOpFeatureProvider implementation from src/OpenFeature/NoOpFeatureProvider.cs. +// It is used here to facilitate unit testing without relying on the internal NoOpFeatureProvider class. +// If the InternalsVisibleTo attribute is added to the OpenFeature project, +// this class can be removed and the original NoOpFeatureProvider can be directly accessed for testing. +internal sealed class NoOpFeatureProvider : FeatureProvider +{ + private readonly Metadata _metadata = new Metadata(NoOpProvider.NoOpProviderName); + + public override Metadata GetMetadata() + { + return this._metadata; + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + private static ResolutionDetails NoOpResponse(string flagKey, T defaultValue) + { + return new ResolutionDetails( + flagKey, + defaultValue, + reason: NoOpProvider.ReasonNoOp, + variant: NoOpProvider.Variant + ); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs new file mode 100644 index 00000000..7bf20bca --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs @@ -0,0 +1,8 @@ +namespace OpenFeature.DependencyInjection.Tests; + +internal static class NoOpProvider +{ + public const string NoOpProviderName = "No-op Provider"; + public const string ReasonNoOp = "No-op"; + public const string Variant = "No-op"; +} diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj b/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj new file mode 100644 index 00000000..9937e1bc --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj @@ -0,0 +1,37 @@ + + + + net6.0;net8.0 + enable + enable + + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs new file mode 100644 index 00000000..d4726db5 --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -0,0 +1,92 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.DependencyInjection.Tests; + +public partial class OpenFeatureBuilderExtensionsTests +{ + private readonly IServiceCollection _services; + private readonly OpenFeatureBuilder _systemUnderTest; + + public OpenFeatureBuilderExtensionsTests() + { + _services = new ServiceCollection(); + _systemUnderTest = new OpenFeatureBuilder(_services); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddContext_Delegate_ShouldAddServiceToCollection(bool useServiceProviderDelegate) + { + // Act + var result = useServiceProviderDelegate ? + _systemUnderTest.AddContext(_ => { }) : + _systemUnderTest.AddContext((_, _) => { }); + + // Assert + result.Should().BeSameAs(_systemUnderTest, "The method should return the same builder instance."); + _systemUnderTest.IsContextConfigured.Should().BeTrue("The context should be configured."); + _services.Should().ContainSingle(serviceDescriptor => + serviceDescriptor.ServiceType == typeof(EvaluationContext) && + serviceDescriptor.Lifetime == ServiceLifetime.Transient, + "A transient service of type EvaluationContext should be added."); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddContext_Delegate_ShouldCorrectlyHandles(bool useServiceProviderDelegate) + { + // Arrange + bool delegateCalled = false; + + _ = useServiceProviderDelegate ? + _systemUnderTest.AddContext(_ => delegateCalled = true) : + _systemUnderTest.AddContext((_, _) => delegateCalled = true); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var context = serviceProvider.GetService(); + + // Assert + _systemUnderTest.IsContextConfigured.Should().BeTrue("The context should be configured."); + context.Should().NotBeNull("The EvaluationContext should be resolvable."); + delegateCalled.Should().BeTrue("The delegate should be invoked."); + } + + [Fact] + public void AddProvider_ShouldAddProviderToCollection() + { + // Act + var result = _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider()); + + // Assert + _systemUnderTest.IsContextConfigured.Should().BeFalse("The context should not be configured."); + result.Should().BeSameAs(_systemUnderTest, "The method should return the same builder instance."); + _services.Should().ContainSingle(serviceDescriptor => + serviceDescriptor.ServiceType == typeof(FeatureProvider) && + serviceDescriptor.Lifetime == ServiceLifetime.Singleton, + "A singleton service of type FeatureProvider should be added."); + } + + [Fact] + public void AddProvider_ShouldResolveCorrectProvider() + { + // Arrange + _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider()); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var provider = serviceProvider.GetService(); + + // Assert + _systemUnderTest.IsContextConfigured.Should().BeFalse("The context should not be configured."); + provider.Should().NotBeNull("The FeatureProvider should be resolvable."); + provider.Should().BeOfType("The resolved provider should be of type DefaultFeatureProvider."); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..4149b8a8 --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace OpenFeature.Tests; + +public class OpenFeatureServiceCollectionExtensionsTests +{ + private readonly IServiceCollection _systemUnderTest; + private readonly Action _configureAction; + + public OpenFeatureServiceCollectionExtensionsTests() + { + _systemUnderTest = new ServiceCollection(); + _configureAction = Substitute.For>(); + } + + [Fact] + public void AddOpenFeature_ShouldRegisterApiInstanceAndLifecycleManagerAsSingleton() + { + // Act + _systemUnderTest.AddOpenFeature(_configureAction); + + _systemUnderTest.Should().HaveCount(3); + _systemUnderTest.Should().ContainSingle(s => s.ServiceType == typeof(Api) && s.Lifetime == ServiceLifetime.Singleton); + _systemUnderTest.Should().ContainSingle(s => s.ServiceType == typeof(IFeatureLifecycleManager) && s.Lifetime == ServiceLifetime.Singleton); + _systemUnderTest.Should().ContainSingle(s => s.ServiceType == typeof(IFeatureClient) && s.Lifetime == ServiceLifetime.Scoped); + } + + [Fact] + public void AddOpenFeature_ShouldInvokeConfigureAction() + { + // Act + _systemUnderTest.AddOpenFeature(_configureAction); + + // Assert + _configureAction.Received(1).Invoke(Arg.Any()); + } +}