diff --git a/OpenFeature.slnx b/OpenFeature.slnx index d6778e50..05aaad11 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -62,6 +62,7 @@ + diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index e8faf5a5..de0498f2 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -2,8 +2,8 @@ using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; using OpenFeature; -using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; +using OpenFeature.Hosting.Providers.Memory; using OpenFeature.Model; using OpenFeature.Providers.Memory; using OpenTelemetry.Metrics; @@ -38,7 +38,7 @@ .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) .Build(); - featureBuilder.AddHostedFeatureLifecycle() + featureBuilder .AddHook(sp => new LoggingHook(sp.GetRequiredService>())) .AddHook(_ => new MetricsHook(metricsHookOptions)) .AddHook() diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index b6223bd0..fd8791fc 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -7,7 +7,6 @@ - diff --git a/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs b/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs new file mode 100644 index 00000000..f7ecf81c --- /dev/null +++ b/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs @@ -0,0 +1,38 @@ +namespace OpenFeature.Hosting.Diagnostics; + +/// +/// Contains identifiers for experimental features and diagnostics in the OpenFeature framework. +/// +/// +/// Experimental - This class includes identifiers that allow developers to track and conditionally enable +/// experimental features. Each identifier follows a structured code format to indicate the feature domain, +/// maturity level, and unique identifier. Note that experimental features are subject to change or removal +/// in future releases. +/// +/// Basic Information
+/// These identifiers conform to OpenFeature’s Diagnostics Specifications, allowing developers to recognize +/// and manage experimental features effectively. +///
+///
+/// +/// +/// Code Structure: +/// - "OF" - Represents the OpenFeature library. +/// - "DI" - Indicates the Dependency Injection domain. +/// - "001" - Unique identifier for a specific feature. +/// +/// +internal static class FeatureCodes +{ + /// + /// Identifier for the experimental Dependency Injection features within the OpenFeature framework. + /// + /// + /// OFDI001 identifier marks experimental features in the Dependency Injection (DI) domain. + /// + /// Usage: + /// Developers can use this identifier to conditionally enable or test experimental DI features. + /// It is part of the OpenFeature diagnostics system to help track experimental functionality. + /// + public const string NewDi = "OFDI001"; +} diff --git a/src/OpenFeature.Hosting/Guard.cs b/src/OpenFeature.Hosting/Guard.cs new file mode 100644 index 00000000..2d37ef54 --- /dev/null +++ b/src/OpenFeature.Hosting/Guard.cs @@ -0,0 +1,14 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace OpenFeature.Hosting; + +[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.Hosting/HostedFeatureLifecycleService.cs b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs index 5209a525..4411c21b 100644 --- a/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs +++ b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using OpenFeature.DependencyInjection; namespace OpenFeature.Hosting; diff --git a/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs b/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs new file mode 100644 index 00000000..54f791fb --- /dev/null +++ b/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs @@ -0,0 +1,24 @@ +namespace OpenFeature.Hosting; + +/// +/// 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.Hosting/Internal/EventHandlerDelegateWrapper.cs b/src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs new file mode 100644 index 00000000..34e000ce --- /dev/null +++ b/src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs @@ -0,0 +1,8 @@ +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.Hosting.Internal; + +internal record EventHandlerDelegateWrapper( + ProviderEventTypes ProviderEventType, + EventHandlerDelegate EventHandlerDelegate); diff --git a/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs b/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs new file mode 100644 index 00000000..4d915946 --- /dev/null +++ b/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace OpenFeature.Hosting.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 options = _serviceProvider.GetRequiredService>().Value; + if (options.HasDefaultProvider) + { + var featureProvider = _serviceProvider.GetRequiredService(); + await _featureApi.SetProviderAsync(featureProvider).ConfigureAwait(false); + } + + foreach (var name in options.ProviderNames) + { + var featureProvider = _serviceProvider.GetRequiredKeyedService(name); + await _featureApi.SetProviderAsync(name, featureProvider).ConfigureAwait(false); + } + + var hooks = new List(); + foreach (var hookName in options.HookNames) + { + var hook = _serviceProvider.GetRequiredKeyedService(hookName); + hooks.Add(hook); + } + + _featureApi.AddHooks(hooks); + + var handlers = _serviceProvider.GetServices(); + foreach (var handler in handlers) + { + _featureApi.AddHandler(handler.ProviderEventType, handler.EventHandlerDelegate); + } + } + + /// + 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.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs b/src/OpenFeature.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs new file mode 100644 index 00000000..afbec6b0 --- /dev/null +++ b/src/OpenFeature.Hosting/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.Hosting/MultiTarget/IsExternalInit.cs b/src/OpenFeature.Hosting/MultiTarget/IsExternalInit.cs new file mode 100644 index 00000000..87714111 --- /dev/null +++ b/src/OpenFeature.Hosting/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.Hosting/OpenFeature.Hosting.csproj b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj index 1d54ff02..85131a0f 100644 --- a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj +++ b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj @@ -1,7 +1,7 @@ - + - net8.0;net9.0 + netstandard2.0;net8.0;net9.0;net462 OpenFeature @@ -10,7 +10,12 @@ - + + + + + + diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilder.cs b/src/OpenFeature.Hosting/OpenFeatureBuilder.cs new file mode 100644 index 00000000..177a9fac --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeatureBuilder.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenFeature.Hosting; + +/// +/// 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; } + + /// + /// Indicates whether the policy has been configured. + /// + public bool IsPolicyConfigured { get; internal set; } + + /// + /// Gets a value indicating whether a default provider has been registered. + /// + public bool HasDefaultProvider { get; internal set; } + + /// + /// Gets the count of domain-bound providers that have been registered. + /// This count does not include the default provider. + /// + public int DomainBoundProviderRegistrationCount { get; internal set; } + + /// + /// Validates the current configuration, ensuring that a policy is set when multiple providers are registered + /// or when a default provider is registered alongside another provider. + /// + /// + /// Thrown if multiple providers are registered without a policy, or if both a default provider + /// and an additional provider are registered without a policy configuration. + /// + public void Validate() + { + if (!IsPolicyConfigured) + { + if (DomainBoundProviderRegistrationCount > 1) + { + throw new InvalidOperationException("Multiple providers have been registered, but no policy has been configured."); + } + + if (HasDefaultProvider && DomainBoundProviderRegistrationCount == 1) + { + throw new InvalidOperationException("A default provider and an additional provider have been registered without a policy configuration."); + } + } + } +} diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs index 80e760d9..52c66c42 100644 --- a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs @@ -1,6 +1,10 @@ using Microsoft.Extensions.DependencyInjection; -using OpenFeature.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenFeature.Constant; using OpenFeature.Hosting; +using OpenFeature.Hosting.Internal; +using OpenFeature.Model; namespace OpenFeature; @@ -9,6 +13,370 @@ namespace OpenFeature; /// 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. + /// Thrown when the or action is null. + 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. + /// Thrown when the or action is null. + 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 using a factory method without additional configuration options. + /// This method adds the feature provider as a transient service and sets it as the default provider within the application. + /// + /// The used to configure feature flags. + /// + /// A factory method that creates and returns a + /// instance based on the provided service provider. + /// + /// The updated instance with the default feature provider set and configured. + /// Thrown if the is null, as a valid builder is required to add and configure providers. + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func implementationFactory) + => AddProvider(builder, implementationFactory, null); + + /// + /// Adds a feature provider using a factory method to create the provider instance and optionally configures its settings. + /// This method adds the feature provider as a transient service and sets it as the default provider within the application. + /// + /// Type derived from used to configure the feature provider. + /// The used to configure feature flags. + /// + /// A factory method that creates and returns a + /// instance based on the provided service provider. + /// + /// An optional delegate to configure the provider-specific options. + /// The updated instance with the default feature provider set and configured. + /// Thrown if the is null, as a valid builder is required to add and configure providers. + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func implementationFactory, Action? configureOptions) + where TOptions : OpenFeatureOptions + { + Guard.ThrowIfNull(builder); + + builder.HasDefaultProvider = true; + builder.Services.PostConfigure(options => options.AddDefaultProviderName()); + if (configureOptions != null) + { + builder.Services.Configure(configureOptions); + } + + builder.Services.TryAddTransient(implementationFactory); + builder.AddClient(); + return builder; + } + + /// + /// Adds a feature provider for a specific domain using provided options and a configuration builder. + /// + /// Type derived from used to configure the feature provider. + /// The used to configure feature flags. + /// The unique name of the provider. + /// + /// A factory method that creates a feature provider instance. + /// It adds the provider as a transient service unless it is already added. + /// + /// An optional delegate to configure the provider-specific options. + /// The updated instance with the new feature provider configured. + /// + /// Thrown if either or is null or if the is empty. + /// + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, string domain, Func implementationFactory, Action? configureOptions) + where TOptions : OpenFeatureOptions + { + Guard.ThrowIfNull(builder); + + builder.DomainBoundProviderRegistrationCount++; + + builder.Services.PostConfigure(options => options.AddProviderName(domain)); + if (configureOptions != null) + { + builder.Services.Configure(domain, configureOptions); + } + + builder.Services.TryAddKeyedTransient(domain, (provider, key) => + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + return implementationFactory(provider, key.ToString()!); + }); + + builder.AddClient(domain); + return builder; + } + + /// + /// Adds a feature provider for a specified domain using the default options. + /// This method configures a feature provider without custom options, delegating to the more generic AddProvider method. + /// + /// The used to configure feature flags. + /// The unique name of the provider. + /// + /// A factory method that creates a feature provider instance. + /// It adds the provider as a transient service unless it is already added. + /// + /// The updated instance with the new feature provider configured. + /// + /// Thrown if either or is null or if the is empty. + /// + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, string domain, Func implementationFactory) + => AddProvider(builder, domain, implementationFactory, configureOptions: null); + + /// + /// Adds a feature client to the service collection, configuring it to work with a specific context if provided. + /// + /// The instance. + /// Optional: The name for the feature client instance. + /// The instance. + internal static OpenFeatureBuilder AddClient(this OpenFeatureBuilder builder, string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + if (builder.IsContextConfigured) + { + builder.Services.TryAddScoped(static provider => + { + var api = provider.GetRequiredService(); + var client = api.GetClient(); + var context = provider.GetRequiredService(); + client.SetContext(context); + return client; + }); + } + else + { + builder.Services.TryAddScoped(static provider => + { + var api = provider.GetRequiredService(); + return api.GetClient(); + }); + } + } + else + { + if (builder.IsContextConfigured) + { + builder.Services.TryAddKeyedScoped(name, static (provider, key) => + { + var api = provider.GetRequiredService(); + var client = api.GetClient(key!.ToString()); + var context = provider.GetRequiredService(); + client.SetContext(context); + return client; + }); + } + else + { + builder.Services.TryAddKeyedScoped(name, static (provider, key) => + { + var api = provider.GetRequiredService(); + return api.GetClient(key!.ToString()); + }); + } + } + + return builder; + } + + /// + /// Adds a default to the based on the policy name options. + /// This method configures the dependency injection container to resolve the appropriate + /// depending on the policy name selected. + /// If no name is selected (i.e., null), it retrieves the default client. + /// + /// The instance. + /// The configured instance. + internal static OpenFeatureBuilder AddPolicyBasedClient(this OpenFeatureBuilder builder) + { + builder.Services.AddScoped(provider => + { + var policy = provider.GetRequiredService>().Value; + var name = policy.DefaultNameSelector(provider); + if (name == null) + { + return provider.GetRequiredService(); + } + return provider.GetRequiredKeyedService(name); + }); + + return builder; + } + + /// + /// Configures policy name options for OpenFeature using the specified options type. + /// + /// The type of options used to configure . + /// The instance. + /// A delegate to configure . + /// The configured instance. + /// Thrown when the or is null. + public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions) + where TOptions : PolicyNameOptions + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configureOptions); + + builder.IsPolicyConfigured = true; + + builder.Services.Configure(configureOptions); + return builder; + } + + /// + /// Configures the default policy name options for OpenFeature. + /// + /// The instance. + /// A delegate to configure . + /// The configured instance. + public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions) + => AddPolicyName(builder, configureOptions); + + /// + /// Adds a feature hook to the service collection using a factory method. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// Optional factory for controlling how will be created in the DI container. + /// The instance. + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, Func? implementationFactory = null) + where THook : Hook + { + return builder.AddHook(typeof(THook).Name, implementationFactory); + } + + /// + /// Adds a feature hook to the service collection. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// Instance of Hook to inject into the OpenFeature context. + /// The instance. + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, THook hook) + where THook : Hook + { + return builder.AddHook(typeof(THook).Name, hook); + } + + /// + /// Adds a feature hook to the service collection with a specified name. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// The name of the that is being added. + /// Instance of Hook to inject into the OpenFeature context. + /// The instance. + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, string hookName, THook hook) + where THook : Hook + { + return builder.AddHook(hookName, _ => hook); + } + + /// + /// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// The name of the that is being added. + /// Optional factory for controlling how will be created in the DI container. + /// The instance. + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook> + (this OpenFeatureBuilder builder, string hookName, Func? implementationFactory = null) + where THook : Hook + { + builder.Services.PostConfigure(options => options.AddHookName(hookName)); + + if (implementationFactory is not null) + { + builder.Services.TryAddKeyedSingleton(hookName, (serviceProvider, key) => + { + return implementationFactory(serviceProvider); + }); + } + else + { + builder.Services.TryAddKeyedSingleton(hookName); + } + + return builder; + } + + /// + /// Add a to allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions + /// + /// The instance. + /// The type to handle. + /// The handler which reacts to . + /// The instance. + public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, EventHandlerDelegate eventHandlerDelegate) + { + return AddHandler(builder, type, _ => eventHandlerDelegate); + } + + /// + /// Add a to allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions + /// + /// The instance. + /// The type to handle. + /// The handler factory for creating a handler which reacts to . + /// The instance. + public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, Func implementationFactory) + { + builder.Services.AddSingleton((serviceProvider) => + { + var handler = implementationFactory(serviceProvider); + return new EventHandlerDelegateWrapper(type, handler); + }); + + return builder; + } + /// /// Adds the to the OpenFeatureBuilder, /// which manages the lifecycle of features within the application. It also allows @@ -17,6 +385,7 @@ public static partial class OpenFeatureBuilderExtensions /// The instance. /// An optional action to configure . /// The instance. + [Obsolete("Calling AddHostedFeatureLifecycle() is no longer necessary. OpenFeature will inject this automatically when you call AddOpenFeature().")] public static OpenFeatureBuilder AddHostedFeatureLifecycle(this OpenFeatureBuilder builder, Action? configureOptions = null) { if (configureOptions is not null) diff --git a/src/OpenFeature.Hosting/OpenFeatureOptions.cs b/src/OpenFeature.Hosting/OpenFeatureOptions.cs new file mode 100644 index 00000000..9d3dd818 --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeatureOptions.cs @@ -0,0 +1,61 @@ +namespace OpenFeature.Hosting; + +/// +/// Options to configure OpenFeature +/// +public class OpenFeatureOptions +{ + private readonly HashSet _providerNames = []; + + /// + /// Determines if a default provider has been registered. + /// + public bool HasDefaultProvider { get; private set; } + + /// + /// The type of the configured feature provider. + /// + public Type FeatureProviderType { get; protected internal set; } = null!; + + /// + /// Gets a read-only list of registered provider names. + /// + public IReadOnlyCollection ProviderNames => _providerNames; + + /// + /// Registers the default provider name if no specific name is provided. + /// Sets to true. + /// + protected internal void AddDefaultProviderName() => AddProviderName(null); + + /// + /// Registers a new feature provider name. This operation is thread-safe. + /// + /// The name of the feature provider to register. Registers as default if null. + protected internal void AddProviderName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + HasDefaultProvider = true; + } + else + { + lock (_providerNames) + { + _providerNames.Add(name!); + } + } + } + + private readonly HashSet _hookNames = []; + + internal IReadOnlyCollection HookNames => _hookNames; + + internal void AddHookName(string name) + { + lock (_hookNames) + { + _hookNames.Add(name); + } + } +} diff --git a/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs new file mode 100644 index 00000000..236dc62b --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenFeature.Hosting; +using OpenFeature.Hosting.Internal; + +namespace OpenFeature; + +/// +/// Contains extension methods for the class. +/// +public static partial class OpenFeatureServiceCollectionExtensions +{ + /// + /// Adds and configures OpenFeature services to the provided . + /// + /// The instance. + /// A configuration action for customizing OpenFeature setup via + /// The modified instance + /// Thrown if or is null. + public static IServiceCollection AddOpenFeature(this IServiceCollection services, Action configure) + { + Guard.ThrowIfNull(services); + Guard.ThrowIfNull(configure); + + // Register core OpenFeature services as singletons. + services.TryAddSingleton(Api.Instance); + services.TryAddSingleton(); + + var builder = new OpenFeatureBuilder(services); + configure(builder); + + builder.Services.AddHostedService(); + + // If a default provider is specified without additional providers, + // return early as no extra configuration is needed. + if (builder.HasDefaultProvider && builder.DomainBoundProviderRegistrationCount == 0) + { + return services; + } + + // Validate builder configuration to ensure consistency and required setup. + builder.Validate(); + + if (!builder.IsPolicyConfigured) + { + // Add a default name selector policy to use the first registered provider name as the default. + builder.AddPolicyName(options => + { + options.DefaultNameSelector = provider => + { + var options = provider.GetRequiredService>().Value; + return options.ProviderNames.First(); + }; + }); + } + + builder.AddPolicyBasedClient(); + + return services; + } +} diff --git a/src/OpenFeature.Hosting/PolicyNameOptions.cs b/src/OpenFeature.Hosting/PolicyNameOptions.cs new file mode 100644 index 00000000..3dfa76f8 --- /dev/null +++ b/src/OpenFeature.Hosting/PolicyNameOptions.cs @@ -0,0 +1,12 @@ +namespace OpenFeature.Hosting; + +/// +/// Options to configure the default feature client name. +/// +public class PolicyNameOptions +{ + /// + /// A delegate to select the default feature client name. + /// + public Func DefaultNameSelector { get; set; } = null!; +} diff --git a/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs new file mode 100644 index 00000000..d63009d6 --- /dev/null +++ b/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.Hosting.Providers.Memory; + +/// +/// Extension methods for configuring feature providers with . +/// +#if NET8_0_OR_GREATER +[System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif +public static partial class FeatureBuilderExtensions +{ + /// + /// Adds an in-memory feature provider to the with a factory for flags. + /// + /// The instance to configure. + /// + /// A factory function to provide an of flags. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, Func?> flagsFactory) + => builder.AddProvider(provider => + { + var flags = flagsFactory(provider); + if (flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(flags); + }); + + /// + /// Adds an in-memory feature provider to the with a domain and factory for flags. + /// + /// The instance to configure. + /// The unique domain of the provider. + /// + /// A factory function to provide an of flags. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Func?> flagsFactory) + => builder.AddInMemoryProvider(domain, (provider, _) => flagsFactory(provider)); + + /// + /// Adds an in-memory feature provider to the with a domain and contextual flag factory. + /// If null, an empty provider will be created. + /// + /// The instance to configure. + /// The unique domain of the provider. + /// + /// A factory function to provide an of flags based on service provider and domain. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Func?> flagsFactory) + => builder.AddProvider(domain, (provider, key) => + { + var flags = flagsFactory(provider, key); + if (flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(flags); + }); + + /// + /// Adds an in-memory feature provider to the with optional flag configuration. + /// + /// The instance to configure. + /// + /// An optional delegate to configure feature flags in the in-memory provider. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, Action>? configure = null) + => builder.AddProvider(CreateProvider, options => ConfigureFlags(options, configure)); + + /// + /// Adds an in-memory feature provider with a specific domain to the with optional flag configuration. + /// + /// The instance to configure. + /// The unique domain of the provider + /// + /// An optional delegate to configure feature flags in the in-memory provider. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Action>? configure = null) + => builder.AddProvider(domain, CreateProvider, options => ConfigureFlags(options, configure)); + + private static FeatureProvider CreateProvider(IServiceProvider provider, string domain) + { + var options = provider.GetRequiredService>().Get(domain); + if (options.Flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(options.Flags); + } + + private static FeatureProvider CreateProvider(IServiceProvider provider) + { + var options = provider.GetRequiredService>().Value; + if (options.Flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(options.Flags); + } + + private static void ConfigureFlags(InMemoryProviderOptions options, Action>? configure) + { + if (configure != null) + { + options.Flags = new Dictionary(); + configure.Invoke(options.Flags); + } + } +} diff --git a/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs b/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs new file mode 100644 index 00000000..3e7431ee --- /dev/null +++ b/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs @@ -0,0 +1,19 @@ +using OpenFeature.Providers.Memory; + +namespace OpenFeature.Hosting.Providers.Memory; + +/// +/// Options for configuring the in-memory feature flag provider. +/// +public class InMemoryProviderOptions : OpenFeatureOptions +{ + /// + /// Gets or sets the feature flags to be used by the in-memory provider. + /// + /// + /// This property allows you to specify a dictionary of flags where the key is the flag name + /// and the value is the corresponding instance. + /// If no flags are provided, the in-memory provider will start with an empty set of flags. + /// + public IDictionary? Flags { get; set; } +} diff --git a/test/OpenFeature.Hosting.Tests/GuardTests.cs b/test/OpenFeature.Hosting.Tests/GuardTests.cs new file mode 100644 index 00000000..13b8883d --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/GuardTests.cs @@ -0,0 +1,30 @@ +namespace OpenFeature.Hosting.Tests; + +public class GuardTests +{ + [Fact] + public void ThrowIfNull_WithNullArgument_ThrowsArgumentNullException() + { + // Arrange + object? argument = null; + + // Act + var exception = Assert.Throws(() => Guard.ThrowIfNull(argument)); + + // Assert + Assert.Equal("argument", exception.ParamName); + } + + [Fact] + public void ThrowIfNull_WithNotNullArgument_DoesNotThrowArgumentNullException() + { + // Arrange + object? argument = "Test argument"; + + // Act + var ex = Record.Exception(() => Guard.ThrowIfNull(argument)); + + // Assert + Assert.Null(ex); + } +} diff --git a/test/OpenFeature.Hosting.Tests/Internal/FeatureLifecycleManagerTests.cs b/test/OpenFeature.Hosting.Tests/Internal/FeatureLifecycleManagerTests.cs new file mode 100644 index 00000000..2d379fc4 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/Internal/FeatureLifecycleManagerTests.cs @@ -0,0 +1,203 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using OpenFeature.Constant; +using OpenFeature.Hosting.Internal; + +namespace OpenFeature.Hosting.Tests.Internal; + +public class FeatureLifecycleManagerTests : IAsyncLifetime +{ + [Fact] + public async Task EnsureInitializedAsync_SetsProvider() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + }); + services.AddSingleton(provider); + + var api = Api.Instance; + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + var actualProvider = api.GetProvider(); + Assert.Equal(provider, actualProvider); + } + + [Fact] + public async Task EnsureInitializedAsync_SetsMultipleProvider() + { + // Arrange + var services = new ServiceCollection(); + var provider1 = new NoOpFeatureProvider(); + var provider2 = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName("provider1"); + options.AddProviderName("provider2"); + }); + services.AddKeyedSingleton("provider1", provider1); + services.AddKeyedSingleton("provider2", provider2); + + var api = Api.Instance; + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + Assert.Equal(provider1, api.GetProvider("provider1")); + Assert.Equal(provider2, api.GetProvider("provider2")); + } + + [Fact] + public async Task EnsureInitializedAsync_AddsHooks() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + var hook = new NoOpHook(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + options.AddHookName("TestHook"); + }); + services.AddSingleton(provider); + services.AddKeyedSingleton("TestHook", hook); + + var api = Api.Instance; + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + var actualHooks = api.GetHooks(); + Assert.Single(actualHooks); + Assert.Contains(hook, actualHooks); + } + + [Fact] + public async Task EnsureInitializedAsync_AddHandlers() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + }); + services.AddSingleton(provider); + + bool hookExecuted = false; + services.AddSingleton(new EventHandlerDelegateWrapper(ProviderEventTypes.ProviderReady, (p) => { hookExecuted = true; })); + + var api = Api.Instance; + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + Assert.True(hookExecuted); + } + + [Fact] + public async Task ShutdownAsync_ResetsApi() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + + var api = Api.Instance; + await api.SetProviderAsync(provider); + api.AddHooks(new NoOpHook()); + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.ShutdownAsync(); + + // Assert + var actualProvider = api.GetProvider(); + Assert.NotEqual(provider, actualProvider); // Default provider should be set after shutdown + Assert.Empty(api.GetHooks()); // Hooks should be cleared + } + + [Fact] + public async Task EnsureInitializedAsync_LogStartingInitialization() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + }); + services.AddSingleton(provider); + + var api = Api.Instance; + var logger = new FakeLogger(); + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, logger); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + var log = logger.LatestRecord; + Assert.Equal(200, log.Id); + Assert.Equal("Starting initialization of the feature provider", log.Message); + Assert.Equal(LogLevel.Information, log.Level); + } + + [Fact] + public async Task ShutdownAsync_LogShuttingDown() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + }); + services.AddSingleton(provider); + + var api = Api.Instance; + var logger = new FakeLogger(); + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, logger); + await lifecycleManager.ShutdownAsync(); + + // Assert + var log = logger.LatestRecord; + Assert.Equal(200, log.Id); + Assert.Equal("Shutting down the feature provider", log.Message); + Assert.Equal(LogLevel.Information, log.Level); + } + + public async Task InitializeAsync() + { + await Api.Instance.ShutdownAsync(); + } + + // Make sure the singleton is cleared between tests + public async Task DisposeAsync() + { + await Api.Instance.ShutdownAsync().ConfigureAwait(false); + } +} diff --git a/test/OpenFeature.Hosting.Tests/NoOpFeatureProvider.cs b/test/OpenFeature.Hosting.Tests/NoOpFeatureProvider.cs new file mode 100644 index 00000000..a19a78b3 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/NoOpFeatureProvider.cs @@ -0,0 +1,52 @@ +using OpenFeature.Model; + +namespace OpenFeature.Hosting.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.Hosting.Tests/NoOpHook.cs b/test/OpenFeature.Hosting.Tests/NoOpHook.cs new file mode 100644 index 00000000..a0085f3b --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/NoOpHook.cs @@ -0,0 +1,26 @@ +using OpenFeature.Model; + +namespace OpenFeature.Hosting.Tests; + +internal class NoOpHook : Hook +{ + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.BeforeAsync(context, hints, cancellationToken); + } + + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.AfterAsync(context, details, hints, cancellationToken); + } + + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } + + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.ErrorAsync(context, error, hints, cancellationToken); + } +} diff --git a/test/OpenFeature.Hosting.Tests/NoOpProvider.cs b/test/OpenFeature.Hosting.Tests/NoOpProvider.cs new file mode 100644 index 00000000..423cd361 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/NoOpProvider.cs @@ -0,0 +1,8 @@ +namespace OpenFeature.Hosting.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.Hosting.Tests/OpenFeature.Hosting.Tests.csproj b/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj new file mode 100644 index 00000000..ae8707a8 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0;net9.0 + $(TargetFrameworks);net462 + OpenFeature.Hosting.Tests + + + + + + 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.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs new file mode 100644 index 00000000..1a284c91 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -0,0 +1,562 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenFeature.Hosting.Internal; +using OpenFeature.Model; + +namespace OpenFeature.Hosting.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 featureBuilder = useServiceProviderDelegate ? + _systemUnderTest.AddContext(_ => { }) : + _systemUnderTest.AddContext((_, _) => { }); + + // Assert + Assert.Equal(_systemUnderTest, featureBuilder); + Assert.True(_systemUnderTest.IsContextConfigured, "The context should be configured."); + Assert.Single(_services, serviceDescriptor => + serviceDescriptor.ServiceType == typeof(EvaluationContext) && + serviceDescriptor.Lifetime == ServiceLifetime.Transient); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddContext_Delegate_ShouldCorrectlyHandles(bool useServiceProviderDelegate) + { + // Arrange + var delegateCalled = false; + + _ = useServiceProviderDelegate ? + _systemUnderTest.AddContext(_ => delegateCalled = true) : + _systemUnderTest.AddContext((_, _) => delegateCalled = true); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var context = serviceProvider.GetService(); + + // Assert + Assert.True(_systemUnderTest.IsContextConfigured, "The context should be configured."); + Assert.NotNull(context); + Assert.True(delegateCalled, "The delegate should be invoked."); + } + +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif + [Theory] + [InlineData(1, true, 0)] + [InlineData(2, false, 1)] + [InlineData(3, true, 0)] + [InlineData(4, false, 1)] + public void AddProvider_ShouldAddProviderToCollection(int providerRegistrationType, bool expectsDefaultProvider, int expectsDomainBoundProvider) + { + // Act + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider()), + 2 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider(), o => { }), + 4 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.False(_systemUnderTest.IsContextConfigured, "The context should not be configured."); + Assert.Equal(expectsDefaultProvider, _systemUnderTest.HasDefaultProvider); + Assert.False(_systemUnderTest.IsPolicyConfigured, "The policy should not be configured."); + Assert.Equal(expectsDomainBoundProvider, _systemUnderTest.DomainBoundProviderRegistrationCount); + Assert.Equal(_systemUnderTest, featureBuilder); + Assert.Single(_services, serviceDescriptor => + serviceDescriptor.ServiceType == typeof(FeatureProvider) && + serviceDescriptor.Lifetime == ServiceLifetime.Transient); + } + + class TestOptions : OpenFeatureOptions { } + +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void AddProvider_ShouldResolveCorrectProvider(int providerRegistrationType) + { + // Arrange + _ = providerRegistrationType switch + { + 1 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider()), + 2 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider(), o => { }), + 4 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var provider = providerRegistrationType switch + { + 1 or 3 => serviceProvider.GetService(), + 2 or 4 => serviceProvider.GetKeyedService("test"), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Theory] + [InlineData(1, true, 1)] + [InlineData(2, true, 1)] + [InlineData(3, false, 2)] + [InlineData(4, true, 1)] + [InlineData(5, true, 1)] + [InlineData(6, false, 2)] + [InlineData(7, true, 2)] + [InlineData(8, true, 2)] + public void AddProvider_VerifiesDefaultAndDomainBoundProvidersBasedOnConfiguration(int providerRegistrationType, bool expectsDefaultProvider, int expectsDomainBoundProvider) + { + // Act + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 2 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 4 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 5 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 6 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 7 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 8 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.False(_systemUnderTest.IsContextConfigured, "The context should not be configured."); + Assert.Equal(expectsDefaultProvider, _systemUnderTest.HasDefaultProvider); + Assert.False(_systemUnderTest.IsPolicyConfigured, "The policy should not be configured."); + Assert.Equal(expectsDomainBoundProvider, _systemUnderTest.DomainBoundProviderRegistrationCount); + Assert.Equal(_systemUnderTest, featureBuilder); + } + + [Theory] + [InlineData(1, null)] + [InlineData(2, "test")] + [InlineData(3, "test2")] + [InlineData(4, "test")] + [InlineData(5, null)] + [InlineData(6, "test1")] + [InlineData(7, "test2")] + [InlineData(8, null)] + public void AddProvider_ConfiguresPolicyNameAcrossMultipleProviderSetups(int providerRegistrationType, string? policyName) + { + // Arrange + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 2 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 3 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 4 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 5 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 6 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 7 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 8 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var policy = serviceProvider.GetRequiredService>().Value; + var name = policy.DefaultNameSelector(serviceProvider); + var provider = name == null ? + serviceProvider.GetService() : + serviceProvider.GetRequiredKeyedService(name); + + // Assert + Assert.True(featureBuilder.IsPolicyConfigured, "The policy should be configured."); + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void AddProvider_WithNullKey_ThrowsArgumentNullException() + { + // Arrange & Act + _systemUnderTest.AddProvider(null!, (sp, domain) => new NoOpFeatureProvider()); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var ex = Assert.Throws(() => serviceProvider.GetKeyedService(null)); + + Assert.Equal("key", ex.ParamName); + } + + [Fact] + public void AddHook_AddsHookAsKeyedService() + { + // Arrange + _systemUnderTest.AddHook(); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("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>(); + + // Assert + Assert.Contains(options.Value.HookNames, t => t == "NoOpHook"); + } + + [Fact] + public void AddHook_WithSpecifiedNameToOpenFeatureOptions() + { + // Arrange + _systemUnderTest.AddHook("my-custom-name"); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("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("my-custom-name"); + + // Assert + Assert.NotNull(hook); + } + + [Fact] + public void AddHook_WithInstance_AddsHookAsKeyedService() + { + // Arrange + var expectedHook = new NoOpHook(); + _systemUnderTest.AddHook(expectedHook); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var actualHook = serviceProvider.GetKeyedService("NoOpHook"); + + // Assert + Assert.NotNull(actualHook); + Assert.Equal(expectedHook, actualHook); + } + + [Fact] + public void AddHook_WithSpecifiedNameAndInstance_AddsHookAsKeyedService() + { + // Arrange + var expectedHook = new NoOpHook(); + _systemUnderTest.AddHook("custom-hook", expectedHook); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var actualHook = serviceProvider.GetKeyedService("custom-hook"); + + // Assert + Assert.NotNull(actualHook); + Assert.Equal(expectedHook, actualHook); + } + + [Fact] + public void AddHandler_AddsEventHandlerDelegateWrapperAsKeyedService() + { + // Arrange + EventHandlerDelegate eventHandler = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetService(); + + // Assert + Assert.NotNull(handler); + Assert.Equal(eventHandler, handler.EventHandlerDelegate); + } + + [Fact] + public void AddHandlerTwice_MultipleEventHandlerDelegateWrappersAsKeyedServices() + { + // Arrange + EventHandlerDelegate eventHandler1 = (eventDetails) => { }; + EventHandlerDelegate eventHandler2 = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler1); + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler2); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetServices(); + + // Assert + Assert.NotEmpty(handler); + Assert.Equal(eventHandler1, handler.ElementAt(0).EventHandlerDelegate); + Assert.Equal(eventHandler2, handler.ElementAt(1).EventHandlerDelegate); + } + + [Fact] + public void AddHandler_WithImplementationFactory_AddsEventHandlerDelegateWrapperAsKeyedService() + { + // Arrange + EventHandlerDelegate eventHandler = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, _ => eventHandler); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetService(); + + // Assert + Assert.NotNull(handler); + Assert.Equal(eventHandler, handler.EventHandlerDelegate); + } + + [Fact] + public void AddClient_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + // Act + _systemUnderTest.AddClient(); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetService(); + + Assert.NotNull(client); + } + + [Fact] + public void AddClient_WithContext_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _systemUnderTest + .AddContext((a) => a.Set("region", "euw")) + .AddProvider(_systemUnderTest => new NoOpFeatureProvider()); + + // Act + _systemUnderTest.AddClient(); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetService(); + + Assert.NotNull(client); + + var context = client.GetContext(); + var region = context.GetValue("region"); + Assert.Equal("euw", region.AsString); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AddClient_WithInvalidName_AddsFeatureClient(string? name) + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + // Act + _systemUnderTest.AddClient(name); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetService(); + Assert.NotNull(client); + + var keyedClients = serviceProvider.GetKeyedServices(name); + Assert.Empty(keyedClients); + } + + [Fact] + public void AddClient_WithNullName_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + // Act + _systemUnderTest.AddClient(null); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetService(); + Assert.NotNull(client); + } + + [Fact] + public void AddClient_WithName_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + // Act + _systemUnderTest.AddClient("client-name"); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetKeyedService("client-name"); + + Assert.NotNull(client); + } + + [Fact] + public void AddClient_WithNameAndContext_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _systemUnderTest + .AddContext((a) => a.Set("region", "euw")) + .AddProvider(_systemUnderTest => new NoOpFeatureProvider()); + + // Act + _systemUnderTest.AddClient("client-name"); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetKeyedService("client-name"); + + Assert.NotNull(client); + + var context = client.GetContext(); + var region = context.GetValue("region"); + Assert.Equal("euw", region.AsString); + } + + [Fact] + public void AddPolicyBasedClient_AddsScopedFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _services.AddOptions() + .Configure(options => options.DefaultNameSelector = _ => "default-name"); + + _systemUnderTest.AddProvider("default-name", (_, key) => new NoOpFeatureProvider()); + + // Act + _systemUnderTest.AddPolicyBasedClient(); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var client = scope.ServiceProvider.GetService(); + Assert.NotNull(client); + } + + [Fact(Skip = "Bug due to https://github.com/open-feature/dotnet-sdk/issues/543")] + public void AddPolicyBasedClient_WithNoDefaultName_AddsScopedFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _services.AddOptions() + .Configure(options => options.DefaultNameSelector = sp => null); + + _systemUnderTest.AddProvider("default", (_, key) => new NoOpFeatureProvider()); + + // Act + _systemUnderTest.AddPolicyBasedClient(); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var client = scope.ServiceProvider.GetService(); + Assert.NotNull(client); + } +} diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderTests.cs new file mode 100644 index 00000000..6c4ea993 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderTests.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenFeature.Hosting.Tests; + +public class OpenFeatureBuilderTests +{ + [Fact] + public void Validate_DoesNotThrowException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + var ex = Record.Exception(builder.Validate); + + // Assert + Assert.Null(ex); + } + + [Fact] + public void Validate_WithPolicySet_DoesNotThrowException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services) + { + IsPolicyConfigured = true + }; + + // Act + var ex = Record.Exception(builder.Validate); + + // Assert + Assert.Null(ex); + } + + [Fact] + public void Validate_WithMultipleDomainProvidersRegistered_ThrowInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services) + { + IsPolicyConfigured = false, + DomainBoundProviderRegistrationCount = 2 + }; + + // Act + var ex = Assert.Throws(builder.Validate); + + // Assert + Assert.Equal("Multiple providers have been registered, but no policy has been configured.", ex.Message); + } + + [Fact] + public void Validate_WithDefaultAndDomainProvidersRegistered_ThrowInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services) + { + IsPolicyConfigured = false, + DomainBoundProviderRegistrationCount = 1, + HasDefaultProvider = true + }; + + // Act + var ex = Assert.Throws(builder.Validate); + + // Assert + Assert.Equal("A default provider and an additional provider have been registered without a policy configuration.", ex.Message); + } + + [Fact] + public void Validate_WithNoDefaultProviderRegistered_DoesNotThrow() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services) + { + IsPolicyConfigured = false, + DomainBoundProviderRegistrationCount = 1, + HasDefaultProvider = false + }; + + // Act + var ex = Record.Exception(builder.Validate); + + // Assert + Assert.Null(ex); + } +} diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureOptionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureOptionsTests.cs new file mode 100644 index 00000000..d39d4059 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureOptionsTests.cs @@ -0,0 +1,73 @@ +namespace OpenFeature.Hosting.Tests; + +public class OpenFeatureOptionsTests +{ + [Fact] + public void AddProviderName_DoesNotSetHasDefaultProvider() + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddProviderName("TestProvider"); + + // Assert + Assert.False(options.HasDefaultProvider); + } + + [Fact] + public void AddProviderName_WithNullName_SetsHasDefaultProvider() + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddProviderName(null); + + // Assert + Assert.True(options.HasDefaultProvider); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AddProviderName_WithEmptyName_SetsHasDefaultProvider(string name) + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddProviderName(name); + + // Assert + Assert.True(options.HasDefaultProvider); + } + + [Fact] + public void AddProviderName_WithSameName_OnlyRegistersNameOnce() + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddProviderName("test-provider"); + options.AddProviderName("test-provider"); + options.AddProviderName("test-provider"); + + // Assert + Assert.Single(options.ProviderNames); + } + + [Fact] + public void AddHookName_RegistersHookName() + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddHookName("test-hook"); + + // Assert + Assert.Single(options.HookNames); + } +} diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..dc3cc934 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace OpenFeature.Hosting.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); + + Assert.Single(_systemUnderTest, s => s.ServiceType == typeof(Api) && s.Lifetime == ServiceLifetime.Singleton); + Assert.Single(_systemUnderTest, s => s.ServiceType == typeof(IFeatureLifecycleManager) && s.Lifetime == ServiceLifetime.Singleton); + Assert.Single(_systemUnderTest, 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()); + } + + [Fact] + public void AddOpenFeature_WithDefaultProvider() + { + // Act + _systemUnderTest.AddOpenFeature(builder => + { + builder.AddProvider(_ => new NoOpFeatureProvider()); + }); + + // Assert + using var serviceProvider = _systemUnderTest.BuildServiceProvider(); + var featureClient = serviceProvider.GetRequiredService(); + Assert.NotNull(featureClient); + } + + [Fact] + public void AddOpenFeature_WithNamedDefaultProvider() + { + // Act + _systemUnderTest.AddOpenFeature(builder => + { + builder.AddProvider("no-opprovider", (_, key) => new NoOpFeatureProvider()); + }); + + // Assert + using var serviceProvider = _systemUnderTest.BuildServiceProvider(); + var featureClient = serviceProvider.GetRequiredService(); + Assert.NotNull(featureClient); + } + + [Fact] + public void AddOpenFeature_WithNamedDefaultProvider_InvokesAddPolicyName() + { + // Arrange + var provider1 = new NoOpFeatureProvider(); + var provider2 = new NoOpFeatureProvider(); + + // Act + _systemUnderTest.AddOpenFeature(builder => + { + builder + .AddPolicyName(ss => + { + ss.DefaultNameSelector = (sp) => "no-opprovider"; + }) + .AddProvider("no-opprovider", (_, key) => provider1) + .AddProvider("no-opprovider-2", (_, key) => provider2); + }); + + // Assert + using var serviceProvider = _systemUnderTest.BuildServiceProvider(); + var client = serviceProvider.GetKeyedService("no-opprovider"); + Assert.NotNull(client); + + var otherClient = serviceProvider.GetService(); + Assert.NotNull(otherClient); + } +} diff --git a/test/OpenFeature.Hosting.Tests/Providers/Memory/FeatureBuilderExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/Providers/Memory/FeatureBuilderExtensionsTests.cs new file mode 100644 index 00000000..b36dc82d --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/Providers/Memory/FeatureBuilderExtensionsTests.cs @@ -0,0 +1,257 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenFeature.Hosting.Providers.Memory; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.Hosting.Tests.Providers.Memory; + +public class FeatureBuilderExtensionsTests +{ + [Fact] + public void AddInMemoryProvider_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider(); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public async Task AddInMemoryProvider_WithFlags_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + Func enableForAlphaGroup = ctx => ctx.GetValue("group").AsString == "alpha" ? "on" : "off"; + var flags = new Dictionary + { + { "feature1", new Flag(new Dictionary { { "on", true }, { "off", false } }, "on") }, + { "feature2", new Flag(new Dictionary { { "on", true }, { "off", false } }, "on", enableForAlphaGroup) } + }; + + // Act + builder.AddInMemoryProvider((sp) => flags); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + + var result = await featureProvider.ResolveBooleanValueAsync("feature1", false); + Assert.True(result.Value); + } + + [Fact] + public void AddInMemoryProvider_WithNullFlagsFactory_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider((sp) => null); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public void AddInMemoryProvider_WithNullConfigure_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider((Action>?)null); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public void AddInMemoryProvider_WithDomain_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain"); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public async Task AddInMemoryProvider_WithDomainAndFlags_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + Func enableForAlphaGroup = ctx => ctx.GetValue("group").AsString == "alpha" ? "on" : "off"; + var flags = new Dictionary + { + { "feature1", new Flag(new Dictionary { { "on", true }, { "off", false } }, "on") }, + { "feature2", new Flag(new Dictionary { { "on", true }, { "off", false } }, "off", enableForAlphaGroup) } + }; + + // Act + builder.AddInMemoryProvider("domain", (sp) => flags); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + + var context = EvaluationContext.Builder().Set("group", "alpha").Build(); + var result = await featureProvider.ResolveBooleanValueAsync("feature2", false, context); + Assert.True(result.Value); + } + + [Fact] + public void AddInMemoryProvider_WithDomainAndNullFlagsFactory_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain", (sp) => null); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public async Task AddInMemoryProvider_WithOptions_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddOptions() + .Configure((opts) => + { + opts.Flags = new Dictionary + { + { "new-feature", new Flag(new Dictionary { { "on", true }, { "off", false } }, "off") }, + }; + }); + + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider(); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + + var result = await featureProvider.ResolveBooleanValueAsync("new-feature", true); + Assert.False(result.Value); + } + + [Fact] + public async Task AddInMemoryProvider_WithDomainAndOptions_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddOptions("domain-name") + .Configure((opts) => + { + opts.Flags = new Dictionary + { + { "new-feature", new Flag(new Dictionary { { "on", true }, { "off", false } }, "off") }, + }; + }); + + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain-name"); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain-name"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + + var result = await featureProvider.ResolveBooleanValueAsync("new-feature", true); + Assert.False(result.Value); + } + + [Fact] + public void AddInMemoryProvider_WithDomainAndNullOptions_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddOptions("domain-name") + .Configure((opts) => + { + opts.Flags = null; + }); + + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain-name"); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain-name"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public void AddInMemoryProvider_WithDomainAndNullConfigure_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain-name", (Action>?)null); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain-name"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } +} diff --git a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs index ff717f9f..9638ff8c 100644 --- a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs +++ b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs @@ -8,9 +8,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using OpenFeature.Constant; -using OpenFeature.DependencyInjection; -using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; +using OpenFeature.Hosting; +using OpenFeature.Hosting.Providers.Memory; using OpenFeature.IntegrationTests.Services; using OpenFeature.Providers.Memory; @@ -211,7 +211,6 @@ private static async Task CreateServerAsync(ServiceLifetime serviceL builder.Services.AddHttpContextAccessor(); builder.Services.AddOpenFeature(cfg => { - cfg.AddHostedFeatureLifecycle(); cfg.AddContext((builder, provider) => { // Retrieve the HttpContext from IHttpContextAccessor, ensuring it's not null. diff --git a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj index aabe1a59..46f99e21 100644 --- a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj +++ b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj @@ -5,7 +5,14 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -18,7 +25,6 @@ -