diff --git a/Microsoft.FeatureManagement.sln b/Microsoft.FeatureManagement.sln index cbf51a01..ad4d8a7a 100644 --- a/Microsoft.FeatureManagement.sln +++ b/Microsoft.FeatureManagement.sln @@ -19,6 +19,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "examples\Cons EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TargetingConsoleApp", "examples\TargetingConsoleApp\TargetingConsoleApp.csproj", "{6558C21E-CF20-4278-AA08-EB9D1DF29D66}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CustomAssignmentConsoleApp", "examples\CustomAssignmentConsoleApp\CustomAssignmentConsoleApp.csproj", "{06C10E31-4C33-4567-85DB-00056A2BB511}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,6 +51,10 @@ Global {6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Debug|Any CPU.Build.0 = Debug|Any CPU {6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Release|Any CPU.ActiveCfg = Release|Any CPU {6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Release|Any CPU.Build.0 = Release|Any CPU + {06C10E31-4C33-4567-85DB-00056A2BB511}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06C10E31-4C33-4567-85DB-00056A2BB511}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06C10E31-4C33-4567-85DB-00056A2BB511}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06C10E31-4C33-4567-85DB-00056A2BB511}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -58,6 +64,7 @@ Global {FDBB27BA-C5BA-48A7-BA9B-63159943EA9F} = {8ED6FFEE-4037-49A2-9709-BC519C104A90} {E50FB931-7A42-440E-AC47-B8DFE5E15394} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} {6558C21E-CF20-4278-AA08-EB9D1DF29D66} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} + {06C10E31-4C33-4567-85DB-00056A2BB511} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {84DA6C54-F140-4518-A1B4-E4CF42117FBD} diff --git a/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj new file mode 100644 index 00000000..68f2c659 --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj @@ -0,0 +1,24 @@ + + + + Exe + net5.0 + Consoto.Banking.AccountService + + + + + + + + + + + + + + Always + + + + diff --git a/examples/CustomAssignmentConsoleApp/DailyDiscountOptions.cs b/examples/CustomAssignmentConsoleApp/DailyDiscountOptions.cs new file mode 100644 index 00000000..71964e6f --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/DailyDiscountOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Consoto.Banking.HelpDesk +{ + class DailyDiscountOptions + { + public string ProductName { get; set; } + + public int Discount { get; set; } + } +} diff --git a/examples/CustomAssignmentConsoleApp/Program.cs b/examples/CustomAssignmentConsoleApp/Program.cs new file mode 100644 index 00000000..c1f91629 --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/Program.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Consoto.Banking.AccountService; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Consoto.Banking.HelpDesk +{ + class Program + { + public static async Task Main(string[] args) + { + // + // Setup configuration + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", false, true) + .Build(); + + // + // Setup application services + feature management + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton(typeof(IFeatureVariantAssignerMetadata), typeof(RecurringAssigner)); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + // + // Get the feature manager from application services + using (ServiceProvider serviceProvider = services.BuildServiceProvider()) + { + IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + + DailyDiscountOptions discountOptions = await variantManager + .GetVariantAsync("DailyDiscount", CancellationToken.None); + + // + // Output results + Console.WriteLine($"Today there is a {discountOptions.Discount}% discount on {discountOptions.ProductName}!"); + } + } + } +} diff --git a/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs new file mode 100644 index 00000000..47ded1fc --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.FeatureManagement; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Consoto.Banking.AccountService +{ + [AssignerAlias("Recurring")] + class RecurringAssigner : IFeatureVariantAssigner + { + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken _) + { + FeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; + + FeatureVariant chosenVariant = null; + + string currentDay = DateTimeOffset.UtcNow.DayOfWeek.ToString(); + + foreach (var variant in featureDefinition.Variants) + { + RecurringAssignmentParameters p = variant.AssignmentParameters.Get() ?? + new RecurringAssignmentParameters(); + + if (p.Days != null && + p.Days.Any(d => d.Equals(currentDay, StringComparison.OrdinalIgnoreCase))) + { + chosenVariant = variant; + + break; + } + } + + return new ValueTask(chosenVariant); + } + } +} diff --git a/examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs b/examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs new file mode 100644 index 00000000..34518abe --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; + +namespace Consoto.Banking.AccountService +{ + class RecurringAssignmentParameters + { + public List Days { get; set; } + } +} diff --git a/examples/CustomAssignmentConsoleApp/appsettings.json b/examples/CustomAssignmentConsoleApp/appsettings.json new file mode 100644 index 00000000..8cbd7ee8 --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/appsettings.json @@ -0,0 +1,33 @@ +{ + "FeatureManagement": { + "DailyDiscount": { + "Assigner": "Recurring", + "Variants": [ + { + "Default": true, + "Name": "Default", + "ConfigurationReference": "DailyDiscount:Default" + }, + { + "Name": "Special", + "ConfigurationReference": "DailyDiscount:Special", + "AssignmentParameters": { + "Days": [ + "Tuesday" + ] + } + } + ] + } + }, + "DailyDiscount": { + "Default": { + "Discount": 20, + "ProductName": "Bananas" + }, + "Special": { + "Discount": 30, + "ProductName": "Fish" + } + } +} \ No newline at end of file diff --git a/examples/FeatureFlagDemo/Controllers/HomeController.cs b/examples/FeatureFlagDemo/Controllers/HomeController.cs index a939a88e..e6f47169 100644 --- a/examples/FeatureFlagDemo/Controllers/HomeController.cs +++ b/examples/FeatureFlagDemo/Controllers/HomeController.cs @@ -31,9 +31,9 @@ public async Task About(CancellationToken cancellationToken) { ViewData["Message"] = "Your application description page."; - if (await _featureManager.IsEnabledAsync(nameof(MyFeatureFlags.CustomViewData), cancellationToken)) + if (await _featureManager.IsEnabledAsync(MyFeatureFlags.CustomViewData, cancellationToken)) { - ViewData["Message"] = $"This is FANCY CONTENT you can see only if '{nameof(MyFeatureFlags.CustomViewData)}' is enabled."; + ViewData["Message"] = $"This is FANCY CONTENT you can see only if '{MyFeatureFlags.CustomViewData}' is enabled."; }; return View(); diff --git a/examples/FeatureFlagDemo/DiscountBannerOptions.cs b/examples/FeatureFlagDemo/DiscountBannerOptions.cs new file mode 100644 index 00000000..a458b5b2 --- /dev/null +++ b/examples/FeatureFlagDemo/DiscountBannerOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace FeatureFlagDemo +{ + public class DiscountBannerOptions + { + public int Size { get; set; } + + public string Color { get; set; } + + public string Background { get; set; } + } +} diff --git a/examples/FeatureFlagDemo/MyFeatureFlags.cs b/examples/FeatureFlagDemo/MyFeatureFlags.cs index 9441c531..3fc1be99 100644 --- a/examples/FeatureFlagDemo/MyFeatureFlags.cs +++ b/examples/FeatureFlagDemo/MyFeatureFlags.cs @@ -3,14 +3,12 @@ // namespace FeatureFlagDemo { - // - // Define feature flags in an enum - public enum MyFeatureFlags + static class MyFeatureFlags { - Home, - Beta, - CustomViewData, - ContentEnhancement, - EnhancedPipeline + public const string Home = "Home"; + public const string Beta = "Beta"; + public const string CustomViewData = "CustomViewData"; + public const string ContentEnhancement = "ContentEnhancement"; + public const string EnhancedPipeline = "EnhancedPipeline"; } } diff --git a/examples/FeatureFlagDemo/Startup.cs b/examples/FeatureFlagDemo/Startup.cs index 65c0777c..573d1fe3 100644 --- a/examples/FeatureFlagDemo/Startup.cs +++ b/examples/FeatureFlagDemo/Startup.cs @@ -59,6 +59,7 @@ public void ConfigureServices(IServiceCollection services) .AddFeatureFilter() .AddFeatureFilter() .AddFeatureFilter() + .AddFeatureVariantAssigner() .UseDisabledFeaturesHandler(new FeatureNotEnabledDisabledHandler()); services.AddMvc(o => diff --git a/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml b/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml index 8ca281ef..516eb3ba 100644 --- a/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml +++ b/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml @@ -1,4 +1,10 @@ - +@using Microsoft.FeatureManagement +@inject IFeatureVariantManager variantManager; +@{ + DiscountBannerOptions opts = await variantManager.GetVariantAsync("DiscountBanner", Context.RequestAborted); +} + + @@ -15,6 +21,14 @@ asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute" /> + - +
+ New Sale, 50% Off ! +
@RenderBody()
diff --git a/examples/FeatureFlagDemo/appsettings.json b/examples/FeatureFlagDemo/appsettings.json index d0a37270..919af4bf 100644 --- a/examples/FeatureFlagDemo/appsettings.json +++ b/examples/FeatureFlagDemo/appsettings.json @@ -66,6 +66,51 @@ } } ] + }, + "DiscountBanner": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "Big", + "ConfigurationReference": "DiscountBanner:Big" + }, + { + "Name": "Small", + "ConfigurationReference": "DiscountBanner:Small", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 80 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } + } + } + ] + } + }, + "DiscountBanner": { + "Small": { + "Size": 24, + "Color": "#c9f568", + "Background": "#f35220" + }, + "Big": { + "Size": 48, + "Color": "#007cb3", + "Background": "#ffbb02" } } } diff --git a/examples/TargetingConsoleApp/CartOptions.cs b/examples/TargetingConsoleApp/CartOptions.cs new file mode 100644 index 00000000..eb202dbd --- /dev/null +++ b/examples/TargetingConsoleApp/CartOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Consoto.Banking.HelpDesk +{ + class CartOptions + { + public int Size { get; set; } + + public string Color { get; set; } + } +} diff --git a/examples/TargetingConsoleApp/Program.cs b/examples/TargetingConsoleApp/Program.cs index 10e64223..464cb417 100644 --- a/examples/TargetingConsoleApp/Program.cs +++ b/examples/TargetingConsoleApp/Program.cs @@ -30,7 +30,8 @@ public static async Task Main(string[] args) services.AddSingleton(configuration) .AddFeatureManagement() - .AddFeatureFilter(); + .AddFeatureFilter() + .AddFeatureVariantAssigner(); IUserRepository userRepository = new InMemoryUserRepository(); @@ -39,6 +40,7 @@ public static async Task Main(string[] args) using (ServiceProvider serviceProvider = services.BuildServiceProvider()) { IFeatureManager featureManager = serviceProvider.GetRequiredService(); + IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); // // We'll simulate a task to run on behalf of each known user @@ -49,7 +51,8 @@ public static async Task Main(string[] args) // Mimic work items in a task-driven console application foreach (string userId in userIds) { - const string FeatureName = "Beta"; + const string FeatureFlagName = "Beta"; + const string DynamicFeatureName = "ShoppingCart"; // // Get user @@ -63,11 +66,27 @@ public static async Task Main(string[] args) Groups = user.Groups }; - bool enabled = await featureManager.IsEnabledAsync(FeatureName, targetingContext, CancellationToken.None); + // + // Evaluate feature flag using targeting + bool enabled = await featureManager + .IsEnabledAsync( + FeatureFlagName, + targetingContext, + CancellationToken.None); + + // + // Retrieve feature variant using targeting + CartOptions cartOptions = await variantManager + .GetVariantAsync( + DynamicFeatureName, + targetingContext, + CancellationToken.None); // // Output results - Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); + Console.WriteLine($"The {FeatureFlagName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); + + Console.WriteLine($"User {user.Id} has a {cartOptions.Color} cart with a size of {cartOptions.Size} pixels."); } } } diff --git a/examples/TargetingConsoleApp/appsettings.json b/examples/TargetingConsoleApp/appsettings.json index a5e827d6..a17902c7 100644 --- a/examples/TargetingConsoleApp/appsettings.json +++ b/examples/TargetingConsoleApp/appsettings.json @@ -24,6 +24,47 @@ } } ] + }, + "ShoppingCart": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "Big", + "ConfigurationReference": "ShoppingCart:Big", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Alec", + "Jeff", + "Alicia" + ] + } + } + }, + { + "Name": "Small", + "ConfigurationReference": "ShoppingCart:Small", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Susan", + "JohnDoe" + ] + } + } + } + ] + } + }, + "ShoppingCart": { + "Big": { + "Size": 400, + "Color": "green" + }, + "Small": { + "Size": 150, + "Color": "gray" } } } \ No newline at end of file diff --git a/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs b/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs new file mode 100644 index 00000000..f7d8f52b --- /dev/null +++ b/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; + +namespace Microsoft.FeatureManagement +{ + /// + /// Allows the name of an to be customized to relate to the name specified in configuration. + /// + public class AssignerAliasAttribute : Attribute + { + /// + /// Creates an assigner alias using the provided alias. + /// + /// The alias of the feature variant assigner. + public AssignerAliasAttribute(string alias) + { + if (string.IsNullOrEmpty(alias)) + { + throw new ArgumentNullException(nameof(alias)); + } + + Alias = alias; + } + + /// + /// The name that will be used to match feature feature variant assigners specified in the configuration. + /// + public string Alias { get; } + } +} diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index bac5a94a..2c8fe03d 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -19,6 +19,7 @@ namespace Microsoft.FeatureManagement sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionProvider, IDisposable { private const string FeatureFiltersSectionName = "EnabledFor"; + private const string FeatureVariantsSectionName = "Variants"; private readonly IConfiguration _configuration; private readonly ConcurrentDictionary _definitions; private IDisposable _changeSubscription; @@ -128,6 +129,10 @@ We support var enabledFor = new List(); + var variants = new List(); + + string assigner = null; + string val = configurationSection.Value; // configuration[$"{featureName}"]; if (string.IsNullOrEmpty(val)) @@ -159,19 +164,39 @@ We support // Are accessed through the configuration system by using the array index as the property name, e.g. "myKey": { "0": "some", "1": "values" } if (int.TryParse(section.Key, out int i) && !string.IsNullOrEmpty(section[nameof(FeatureFilterConfiguration.Name)])) { - enabledFor.Add(new FeatureFilterConfiguration() + enabledFor.Add(new FeatureFilterConfiguration { Name = section[nameof(FeatureFilterConfiguration.Name)], Parameters = section.GetSection(nameof(FeatureFilterConfiguration.Parameters)) }); } } + + IEnumerable variantSections = configurationSection.GetSection(FeatureVariantsSectionName).GetChildren(); + + foreach (IConfigurationSection section in variantSections) + { + if (int.TryParse(section.Key, out int i) && !string.IsNullOrEmpty(section[nameof(FeatureVariant.Name)])) + { + variants.Add(new FeatureVariant + { + Default = section.GetValue(nameof(FeatureVariant.Default)), + Name = section.GetValue(nameof(FeatureVariant.Name)), + ConfigurationReference = section.GetValue(nameof(FeatureVariant.ConfigurationReference)), + AssignmentParameters = section.GetSection(nameof(FeatureVariant.AssignmentParameters)) + }); + } + } + + assigner = configurationSection.GetValue(nameof(FeatureDefinition.Assigner)); } return new FeatureDefinition() { Name = configurationSection.Key, - EnabledFor = enabledFor + EnabledFor = enabledFor, + Variants = variants, + Assigner = assigner }; } diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs new file mode 100644 index 00000000..dcb32a0e --- /dev/null +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A feature variant options resolver that resolves options by reading configuration from the .NET Core system. + /// + sealed class ConfigurationFeatureVariantOptionsResolver : IFeatureVariantOptionsResolver + { + private readonly IConfiguration _configuration; + + public ConfigurationFeatureVariantOptionsResolver(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public ValueTask GetOptionsAsync(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken) + { + if (variant == null) + { + throw new ArgumentNullException(nameof(variant)); + } + + IConfiguration configuration = _configuration.GetSection($"{variant.ConfigurationReference}"); + + return new ValueTask(configuration.Get()); + } + } +} diff --git a/src/Microsoft.FeatureManagement/ContextualFeatureFilterEvaluator.cs b/src/Microsoft.FeatureManagement/ContextualFeatureFilterEvaluator.cs index 53df9f9c..9f75edd9 100644 --- a/src/Microsoft.FeatureManagement/ContextualFeatureFilterEvaluator.cs +++ b/src/Microsoft.FeatureManagement/ContextualFeatureFilterEvaluator.cs @@ -46,6 +46,11 @@ public ContextualFeatureFilterEvaluator(IFeatureFilterMetadata filter, Type appC public Task EvaluateAsync(FeatureFilterEvaluationContext evaluationContext, object context, CancellationToken cancellationToken) { + if (evaluationContext == null) + { + throw new ArgumentNullException(nameof(evaluationContext)); + } + if (_evaluateFunc == null) { return Task.FromResult(false); @@ -56,6 +61,16 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext evaluationContext public static bool IsContextualFilter(IFeatureFilterMetadata filter, Type appContextType) { + if (filter == null) + { + throw new ArgumentNullException(nameof(filter)); + } + + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + return GetContextualFilterInterface(filter, appContextType) != null; } diff --git a/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs b/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs new file mode 100644 index 00000000..edfbd8de --- /dev/null +++ b/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a performance efficient method of evaluating without knowing what the generic type parameter is. + /// + sealed class ContextualFeatureVariantAssignerEvaluator : IContextualFeatureVariantAssigner + { + private IFeatureVariantAssignerMetadata _assigner; + private Func> _evaluateFunc; + + public ContextualFeatureVariantAssignerEvaluator(IFeatureVariantAssignerMetadata assigner, Type appContextType) + { + if (assigner == null) + { + throw new ArgumentNullException(nameof(assigner)); + } + + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + + Type targetInterface = GetContextualAssignerInterface(assigner, appContextType); + + // + // Extract IContextualFeatureVariantAssigner.AssignVariantAsync method. + if (targetInterface != null) + { + MethodInfo evaluateMethod = targetInterface.GetMethod(nameof(IContextualFeatureVariantAssigner.AssignVariantAsync), BindingFlags.Public | BindingFlags.Instance); + + _evaluateFunc = TypeAgnosticEvaluate(assigner.GetType(), evaluateMethod); + } + + _assigner = assigner; + } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext assignmentContext, object context, CancellationToken cancellationToken) + { + if (assignmentContext == null) + { + throw new ArgumentNullException(nameof(assignmentContext)); + } + + if (_evaluateFunc == null) + { + return new ValueTask((FeatureVariant)null); + } + + return _evaluateFunc(_assigner, assignmentContext, context, cancellationToken); + } + + public static bool IsContextualVariantAssigner(IFeatureVariantAssignerMetadata assigner, Type appContextType) + { + if (assigner == null) + { + throw new ArgumentNullException(nameof(assigner)); + } + + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + + return GetContextualAssignerInterface(assigner, appContextType) != null; + } + + private static Type GetContextualAssignerInterface(IFeatureVariantAssignerMetadata assigner, Type appContextType) + { + IEnumerable contextualAssignerInterfaces = assigner.GetType().GetInterfaces().Where(i => i.IsGenericType && i.GetGenericTypeDefinition().IsAssignableFrom(typeof(IContextualFeatureVariantAssigner<>))); + + Type targetInterface = null; + + if (contextualAssignerInterfaces != null) + { + targetInterface = contextualAssignerInterfaces.FirstOrDefault(i => i.GetGenericArguments()[0].IsAssignableFrom(appContextType)); + } + + return targetInterface; + } + + private static Func> TypeAgnosticEvaluate(Type assignerType, MethodInfo method) + { + // + // Get the generic version of the evaluation helper method + MethodInfo genericHelper = typeof(ContextualFeatureVariantAssignerEvaluator).GetMethod(nameof(GenericTypeAgnosticEvaluate), + BindingFlags.Static | BindingFlags.NonPublic); + + // + // Create a type specific version of the evaluation helper method + MethodInfo constructedHelper = genericHelper.MakeGenericMethod + (assignerType, + method.GetParameters()[0].ParameterType, + method.GetParameters()[1].ParameterType, + method.GetParameters()[2].ParameterType, + method.ReturnType); + + // + // Invoke the method to get the func + object typeAgnosticDelegate = constructedHelper.Invoke(null, new object[] { method }); + + return (Func>)typeAgnosticDelegate; + } + + private static Func> GenericTypeAgnosticEvaluate(MethodInfo method) + { + Func> func = + (Func>) + Delegate.CreateDelegate(typeof(Func>), method); + + Func> genericDelegate = + (object target, FeatureVariantAssignmentContext param1, object param2, CancellationToken param3) => + func((TTarget)target, param1, (TParam2)param2, param3); + + return genericDelegate; + } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureDefinition.cs b/src/Microsoft.FeatureManagement/FeatureDefinition.cs index 4931fd5b..19287df6 100644 --- a/src/Microsoft.FeatureManagement/FeatureDefinition.cs +++ b/src/Microsoft.FeatureManagement/FeatureDefinition.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using System.Collections.Generic; +using System.Linq; namespace Microsoft.FeatureManagement { @@ -18,6 +19,16 @@ public class FeatureDefinition /// /// The feature filters that the feature can be enabled for. /// - public IEnumerable EnabledFor { get; set; } = new List(); + public IEnumerable EnabledFor { get; set; } = Enumerable.Empty(); + + /// + /// The assigner used to pick the variant that should be used when a variant is requested + /// + public string Assigner { get; set; } + + /// + /// The feature variants listed for this feature. + /// + public IEnumerable Variants { get; set; } = Enumerable.Empty(); } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs index a433b2bc..a0cbb8f3 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs @@ -43,6 +43,29 @@ public IFeatureManagementBuilder AddFeatureFilter() where T : IFeatureFilterM return this; } + public IFeatureManagementBuilder AddFeatureVariantAssigner() where T : IFeatureVariantAssignerMetadata + { + Type serviceType = typeof(IFeatureVariantAssignerMetadata); + + Type implementationType = typeof(T); + + IEnumerable featureVariantAssignerImplementations = implementationType.GetInterfaces() + .Where(i => i == typeof(IFeatureVariantAssigner) || + (i.IsGenericType && i.GetGenericTypeDefinition().IsAssignableFrom(typeof(IContextualFeatureVariantAssigner<>)))); + + if (featureVariantAssignerImplementations.Count() > 1) + { + throw new ArgumentException($"A single feature variant assigner cannot implement more than one feature variant assigner interface.", nameof(T)); + } + + if (!Services.Any(descriptor => descriptor.ServiceType == serviceType && descriptor.ImplementationType == implementationType)) + { + Services.AddSingleton(typeof(IFeatureVariantAssignerMetadata), typeof(T)); + } + + return this; + } + public IFeatureManagementBuilder AddSessionManager() where T : ISessionManager { Services.AddSingleton(typeof(ISessionManager), typeof(T)); diff --git a/src/Microsoft.FeatureManagement/FeatureManagementError.cs b/src/Microsoft.FeatureManagement/FeatureManagementError.cs index a1e3319f..60f8a81d 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementError.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementError.cs @@ -16,6 +16,51 @@ public enum FeatureManagementError /// /// A feature filter configured for the feature being evaluated is an ambiguous reference to multiple registered feature filters. /// - AmbiguousFeatureFilter + AmbiguousFeatureFilter, + + /// + /// A feature filter being used in feature evaluation is invalid. + /// + InvalidFeatureFilter, + + /// + /// A feature variant assigner that was listed for variant assignment was not found. + /// + MissingFeatureVariantAssigner, + + /// + /// The feature variant assigner configured for the feature being evaluated is an ambiguous reference to multiple registered feature variant assigners. + /// + AmbiguousFeatureVariantAssigner, + + /// + /// An assigned feature variant does not have a valid configuration reference. + /// + MissingConfigurationReference, + + /// + /// A feature variant assigner being used in feature evaluation is invalid. + /// + InvalidFeatureVariantAssigner, + + /// + /// A feature that was requested for evaluation was not found. + /// + MissingFeature, + + /// + /// A dynamic feature does not have any feature variants registered. + /// + MissingFeatureVariant, + + /// + /// A dynamic feature has multiple default feature variants configured. + /// + AmbiguousDefaultFeatureVariant, + + /// + /// A dynamic feature does not have a default feature variant configured. + /// + MissingDefaultFeatureVariant } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index c57f03c5..60c9855a 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; @@ -16,29 +17,39 @@ namespace Microsoft.FeatureManagement /// /// Used to evaluate whether a feature is enabled or disabled. /// - class FeatureManager : IFeatureManager + class FeatureManager : IFeatureManager, IFeatureVariantManager { private readonly IFeatureDefinitionProvider _featureDefinitionProvider; private readonly IEnumerable _featureFilters; + private readonly IEnumerable _variantAssigners; + private readonly IFeatureVariantOptionsResolver _variantOptionsResolver; private readonly IEnumerable _sessionManagers; private readonly ILogger _logger; private readonly ConcurrentDictionary _filterMetadataCache; + private readonly ConcurrentDictionary _assignerMetadataCache; private readonly ConcurrentDictionary _contextualFeatureFilterCache; + private readonly ConcurrentDictionary _contextualFeatureVariantAssignerCache; private readonly FeatureManagementOptions _options; public FeatureManager( IFeatureDefinitionProvider featureDefinitionProvider, IEnumerable featureFilters, + IEnumerable variantAssigner, + IFeatureVariantOptionsResolver variantOptionsResolver, IEnumerable sessionManagers, ILoggerFactory loggerFactory, IOptions options) { - _featureDefinitionProvider = featureDefinitionProvider; _featureFilters = featureFilters ?? throw new ArgumentNullException(nameof(featureFilters)); + _variantAssigners = variantAssigner ?? throw new ArgumentNullException(nameof(variantAssigner)); + _variantOptionsResolver = variantOptionsResolver ?? throw new ArgumentNullException(nameof(variantOptionsResolver)); + _featureDefinitionProvider = featureDefinitionProvider ?? throw new ArgumentNullException(nameof(featureDefinitionProvider)); _sessionManagers = sessionManagers ?? throw new ArgumentNullException(nameof(sessionManagers)); _logger = loggerFactory.CreateLogger(); - _filterMetadataCache = new ConcurrentDictionary(); - _contextualFeatureFilterCache = new ConcurrentDictionary(); + _filterMetadataCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _contextualFeatureFilterCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _assignerMetadataCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _contextualFeatureVariantAssignerCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); } @@ -54,14 +65,133 @@ public Task IsEnabledAsync(string feature, TContext appContext, public async IAsyncEnumerable GetFeatureNamesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - await foreach (FeatureDefinition featureDefintion in _featureDefinitionProvider - .GetAllFeatureDefinitionsAsync(cancellationToken) - .ConfigureAwait(false)) + await foreach (FeatureDefinition featureDefintion in _featureDefinitionProvider.GetAllFeatureDefinitionsAsync(cancellationToken).ConfigureAwait(false)) { yield return featureDefintion.Name; } } + public ValueTask GetVariantAsync(string feature, TContext appContext, CancellationToken cancellationToken) + { + return GetVariantAsync(feature, appContext, true, cancellationToken); + } + + public ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken) + { + return GetVariantAsync(feature, null, false, cancellationToken); + } + + private async ValueTask GetVariantAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) + { + if (feature == null) + { + throw new ArgumentNullException(nameof(feature)); + } + + FeatureVariant variant = null; + + FeatureDefinition featureDefinition = await _featureDefinitionProvider + .GetFeatureDefinitionAsync(feature, cancellationToken) + .ConfigureAwait(false); + + if (featureDefinition == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeature, + $"The feature declaration for the dynamic feature '{feature}' was not found."); + } + + if (string.IsNullOrEmpty(featureDefinition.Assigner)) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariantAssigner, + $"Missing feature variant assigner name for the feature {feature}"); + } + + if (featureDefinition.Variants == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariant, + $"No variants are registered for the feature {feature}"); + } + + FeatureVariant defaultVariant = null; + + foreach (FeatureVariant v in featureDefinition.Variants) + { + if (v.Default) + { + if (defaultVariant != null) + { + throw new FeatureManagementException( + FeatureManagementError.AmbiguousDefaultFeatureVariant, + $"Multiple default variants are registered for the feature '{feature}'."); + } + + defaultVariant = v; + } + + if (v.ConfigurationReference == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingConfigurationReference, + $"The variant '{variant.Name}' for the feature '{feature}' does not have a configuration reference."); + } + } + + if (defaultVariant == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingDefaultFeatureVariant, + $"A default variant cannot be found for the feature '{feature}'."); + } + + IFeatureVariantAssignerMetadata assigner = GetFeatureVariantAssignerMetadata(featureDefinition.Assigner); + + if (assigner == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariantAssigner, + $"The feature variant assigner '{featureDefinition.Assigner}' specified for feature '{feature}' was not found."); + } + + var context = new FeatureVariantAssignmentContext() + { + FeatureDefinition = featureDefinition + }; + + // + // IFeatureVariantAssigner + if (assigner is IFeatureVariantAssigner featureVariantAssigner) + { + variant = await featureVariantAssigner.AssignVariantAsync(context, cancellationToken).ConfigureAwait(false); + } + // + // IContextualFeatureVariantAssigner + else if (useAppContext && + TryGetContextualFeatureVariantAssigner(featureDefinition.Assigner, typeof(TContext), out ContextualFeatureVariantAssignerEvaluator contextualAssigner)) + { + variant = await contextualAssigner.AssignVariantAsync(context, appContext, cancellationToken).ConfigureAwait(false); + } + // + // The assigner doesn't implement a feature variant assigner interface capable of performing the evaluation + else + { + throw new FeatureManagementException( + FeatureManagementError.InvalidFeatureVariantAssigner, + useAppContext ? + $"The feature variant assigner '{featureDefinition.Assigner}' specified for the feature '{feature}' is not capable of evaluating the requested feature with the provided context." : + $"The feature variant assigner '{featureDefinition.Assigner}' specified for the feature '{feature}' is not capable of evaluating the requested feature."); + } + + if (variant == null) + { + variant = defaultVariant; + } + + return await _variantOptionsResolver.GetOptionsAsync(featureDefinition, variant, cancellationToken).ConfigureAwait(false); + } + private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { foreach (ISessionManager sessionManager in _sessionManagers) @@ -76,9 +206,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo bool enabled = false; - FeatureDefinition featureDefinition = await _featureDefinitionProvider - .GetFeatureDefinitionAsync(feature, cancellationToken) - .ConfigureAwait(false); + FeatureDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureDefinitionAsync(feature, cancellationToken).ConfigureAwait(false); if (featureDefinition != null) { @@ -98,6 +226,13 @@ private async Task IsEnabledAsync(string feature, TContext appCo foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor) { + if (string.IsNullOrEmpty(featureFilterConfiguration.Name)) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureFilter, + $"Missing feature filter name for the feature {feature}"); + } + IFeatureFilterMetadata filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name); if (filter == null) @@ -118,35 +253,42 @@ private async Task IsEnabledAsync(string feature, TContext appCo var context = new FeatureFilterEvaluationContext() { - FeatureName = feature, - Parameters = featureFilterConfiguration.Parameters + FeatureName = featureDefinition.Name, + Parameters = featureFilterConfiguration.Parameters }; // - // IContextualFeatureFilter - if (useAppContext) + // IFeatureFilter + if (filter is IFeatureFilter featureFilter) { - ContextualFeatureFilterEvaluator contextualFilter = GetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext)); - - if (contextualFilter != null && await contextualFilter - .EvaluateAsync(context, appContext, cancellationToken) - .ConfigureAwait(false)) + if (await featureFilter.EvaluateAsync(context, cancellationToken).ConfigureAwait(false)) { enabled = true; break; } } - // - // IFeatureFilter - if (filter is IFeatureFilter featureFilter && await featureFilter - .EvaluateAsync(context, cancellationToken) - .ConfigureAwait(false)) + // IContextualFeatureFilter + else if (useAppContext && + TryGetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext), out ContextualFeatureFilterEvaluator contextualFilter)) { - enabled = true; + if (await contextualFilter.EvaluateAsync(context, appContext, cancellationToken).ConfigureAwait(false)) + { + enabled = true; - break; + break; + } + } + // + // The filter doesn't implement a feature filter interface capable of performing the evaluation + else + { + throw new FeatureManagementException( + FeatureManagementError.InvalidFeatureFilter, + useAppContext ? + $"The feature filter '{featureFilterConfiguration.Name}' specified for the feature '{feature}' is not capable of evaluating the requested feature with the provided context." : + $"The feature filter '{featureFilterConfiguration.Name}' specified for the feature '{feature}' is not capable of evaluating the requested feature."); } } } @@ -170,33 +312,16 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) IEnumerable matchingFilters = _featureFilters.Where(f => { - Type t = f.GetType(); + Type filterType = f.GetType(); - string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(t, typeof(FilterAliasAttribute)))?.Alias; + string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(filterType, typeof(FilterAliasAttribute)))?.Alias; if (name == null) { - name = t.Name.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase) ? t.Name.Substring(0, t.Name.Length - filterSuffix.Length) : t.Name; - } - - // - // Feature filters can have namespaces in their alias - // If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter' - // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' - if (filterName.Contains('.')) - { - // - // The configured filter name is namespaced. It must be an exact match. - return string.Equals(name, filterName, StringComparison.OrdinalIgnoreCase); + name = filterType.Name; } - else - { - // - // We take the simple name of a filter, E.g. 'MyFilter' for 'MyOrg.MyProduct.MyFilter' - string simpleName = name.Contains('.') ? name.Split('.').Last() : name; - return string.Equals(simpleName, filterName, StringComparison.OrdinalIgnoreCase); - } + return IsMatchingMetadataName(name, filterName, filterSuffix); }); if (matchingFilters.Count() > 1) @@ -211,14 +336,79 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) return filter; } - private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filterName, Type appContextType) + private IFeatureVariantAssignerMetadata GetFeatureVariantAssignerMetadata(string assignerName) + { + const string assignerSuffix = "assigner"; + + IFeatureVariantAssignerMetadata assigner = _assignerMetadataCache.GetOrAdd( + assignerName, + (_) => { + + IEnumerable matchingAssigners = _variantAssigners.Where(a => + { + Type assignerType = a.GetType(); + + string name = ((AssignerAliasAttribute)Attribute.GetCustomAttribute(assignerType, typeof(AssignerAliasAttribute)))?.Alias; + + if (name == null) + { + name = assignerType.Name; + } + + return IsMatchingMetadataName(name, assignerName, assignerSuffix); + }); + + if (matchingAssigners.Count() > 1) + { + throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureVariantAssigner, $"Multiple feature variant assigners match the configured assigner named '{assignerName}'."); + } + + return matchingAssigners.FirstOrDefault(); + } + ); + + return assigner; + } + + private static bool IsMatchingMetadataName(string metadataName, string desiredName, string suffix) + { + // + // Feature filters can be referenced with or without the 'filter' suffix + // E.g. A feature can reference a filter named 'CustomFilter' as 'Custom' or 'CustomFilter' + if (!desiredName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) && + metadataName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + metadataName = metadataName.Substring(0, metadataName.Length - suffix.Length); + } + + // + // Feature filters can have namespaces in their alias + // If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter' + // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' + if (desiredName.Contains('.')) + { + // + // The configured filter name is namespaced. It must be an exact match. + return string.Equals(metadataName, desiredName, StringComparison.OrdinalIgnoreCase); + } + else + { + // + // We take the simple name of a filter, E.g. 'MyFilter' for 'MyOrg.MyProduct.MyFilter' + string simpleName = metadataName.Contains('.') ? metadataName.Split('.').Last() : metadataName; + + return string.Equals(simpleName, desiredName, StringComparison.OrdinalIgnoreCase); + } + } + + private bool TryGetContextualFeatureFilter(string filterName, Type appContextType, out ContextualFeatureFilterEvaluator filter) { if (appContextType == null) { throw new ArgumentNullException(nameof(appContextType)); } - ContextualFeatureFilterEvaluator filter = _contextualFeatureFilterCache.GetOrAdd( + filter = _contextualFeatureFilterCache.GetOrAdd( $"{filterName}{Environment.NewLine}{appContextType.FullName}", (_) => { @@ -230,7 +420,29 @@ private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filte } ); - return filter; + return filter != null; + } + + private bool TryGetContextualFeatureVariantAssigner(string assignerName, Type appContextType, out ContextualFeatureVariantAssignerEvaluator assigner) + { + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + + assigner = _contextualFeatureVariantAssignerCache.GetOrAdd( + $"{assignerName}{Environment.NewLine}{appContextType.FullName}", + (_) => { + + IFeatureVariantAssignerMetadata metadata = GetFeatureVariantAssignerMetadata(assignerName); + + return ContextualFeatureVariantAssignerEvaluator.IsContextualVariantAssigner(metadata, appContextType) ? + new ContextualFeatureVariantAssignerEvaluator(metadata, appContextType) : + null; + } + ); + + return assigner != null; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureVariant.cs b/src/Microsoft.FeatureManagement/FeatureVariant.cs new file mode 100644 index 00000000..7701c5f0 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureVariant.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; + +namespace Microsoft.FeatureManagement +{ + /// + /// A variant of a feature. + /// + public class FeatureVariant + { + /// + /// The name of the variant. + /// + public string Name { get; set; } + + /// + /// Determines whether this variant should be chosen by default if no variant is chosen during the assignment process. + /// + public bool Default { get; set; } + + /// + /// The parameters to be used during assignment to test whether the variant should be used. + /// + public IConfiguration AssignmentParameters { get; set; } + + /// + /// A reference pointing to the configuration for this variant of the feature. + /// + public string ConfigurationReference { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs b/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs new file mode 100644 index 00000000..265a6ce1 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Contextual information needed during the process of feature variant assignment + /// + public class FeatureVariantAssignmentContext + { + /// + /// The definition of the feature in need of an assigned variant + /// + public FeatureDefinition FeatureDefinition { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs new file mode 100644 index 00000000..173def4f --- /dev/null +++ b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a method to assign a variant of a feature to be used based off of custom conditions. + /// + /// A custom type that the assigner requires to perform assignment + public interface IContextualFeatureVariantAssigner : IFeatureVariantAssignerMetadata + { + /// + /// Assign a variant of a feature to be used based off of customized criteria. + /// + /// A variant assignment context that contains information needed to assign a variant for a feature. + /// A context defined by the application that is passed in to the feature management system to provide contextual information for assigning a variant of a feature. + /// The cancellation token to cancel the operation. + /// The variant that should be assigned for a given feature. + ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, TContext appContext, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs b/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs index 6365c098..b2277139 100644 --- a/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs +++ b/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs @@ -30,5 +30,14 @@ public interface IFeatureManagementBuilder /// An implementation of /// The feature management builder. IFeatureManagementBuilder AddSessionManager() where T : ISessionManager; + + /// + /// Adds a given feature variant assigner to the list of feature variant assigners that will be available to assign feature variants during runtime. + /// Possible feature variant assigner metadata types include and + /// Only one feature variant assigner interface can be implemented by a single type. + /// + /// An implementation of + /// The feature management builder. + IFeatureManagementBuilder AddFeatureVariantAssigner() where T : IFeatureVariantAssignerMetadata; } } diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs new file mode 100644 index 00000000..ee6e1627 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a method to assign a variant of a feature to be used based off of custom conditions. + /// + public interface IFeatureVariantAssigner : IFeatureVariantAssignerMetadata + { + /// + /// Assign a variant of a feature to be used based off of customized criteria. + /// + /// A variant assignment context that contains information needed to assign a variant for a feature. + /// The cancellation token to cancel the operation. + /// The variant that should be assigned for a given feature. + ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs b/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs new file mode 100644 index 00000000..713cf852 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Marker interface for feature variant assigners used to assign which variant should be used for a feature. + /// + public interface IFeatureVariantAssignerMetadata + { + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs b/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs new file mode 100644 index 00000000..cc69ffc8 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to access the variants of a feature. + /// + public interface IFeatureVariantManager + { + /// + /// Retrieves a typed representation of the configuration variant that should be used for a given feature. + /// + /// The type that the variants configuration should be bound to. + /// The name of the feature. + /// The cancellation token to cancel the operation. + /// A typed representation of the configuration variant that should be used for a given feature. + ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken); + + /// + /// Retrieves a typed representation of the configuration variant that should be used for a given feature. + /// + /// The type that the variants configuration should be bound to. + /// The type of the context being provided to the feature variant manager for use during the process of choosing which variant to use. + /// The name of the feature. + /// A context providing information that can be used to evaluate whether a feature should be on or off. + /// The cancellation token to cancel the operation. + /// A typed representation of the configuration variant that should be used for a given feature. + ValueTask GetVariantAsync(string feature, TContext context, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs b/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs new file mode 100644 index 00000000..be0e2cd0 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Performs the resolution and binding necessary in the feature variant resolution process. + /// + public interface IFeatureVariantOptionsResolver + { + /// + /// Retrieves typed options for a given feature definition and chosen variant. + /// + /// The type of the options to return. + /// The definition of the feature that the resolution is being performed for. + /// The chosen variant of the feature. + /// The cancellation token to cancel the operation. + /// Typed options for a given feature definition and chosen variant. + ValueTask GetOptionsAsync(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index 507f1394..233413d1 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -26,11 +26,19 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // Add required services services.TryAddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); - services.AddSingleton(); + services.AddSingleton(); - services.AddScoped(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + + services.TryAddSingleton(sp => sp.GetRequiredService()); + + services.TryAddSingleton(); + + services.AddScoped(); + + services.TryAddScoped(sp => sp.GetRequiredService()); return new FeatureManagementBuilder(services); } diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs new file mode 100644 index 00000000..73807e9e --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.FeatureFilters; +using Microsoft.FeatureManagement.Targeting; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A feature variant assigner that can be used to assign a variant based on targeted audiences. + /// + [AssignerAlias(Alias)] + public class ContextualTargetingFeatureVariantAssigner : IContextualFeatureVariantAssigner + { + private const string Alias = "Microsoft.Targeting"; + private readonly TargetingEvaluationOptions _options; + + /// + /// Creates a targeting contextual feature filter. + /// + /// Options controlling the behavior of the targeting evaluation performed by the filter. + public ContextualTargetingFeatureVariantAssigner(IOptions options) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Assigns one of the variants configured for a feature based off the provided targeting context. + /// + /// Contextual information available for use during the assignment process. + /// The targeting context used to determine which variant should be assigned. + /// The cancellation token to cancel the operation. + /// + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, ITargetingContext targetingContext, CancellationToken cancellationToken) + { + if (variantAssignmentContext == null) + { + throw new ArgumentNullException(nameof(variantAssignmentContext)); + } + + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + FeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; + + if (featureDefinition == null) + { + throw new ArgumentException( + $"{nameof(variantAssignmentContext)}.{nameof(variantAssignmentContext.FeatureDefinition)} cannot be null.", + nameof(variantAssignmentContext)); + } + + if (featureDefinition.Variants == null) + { + throw new ArgumentException( + $"{nameof(variantAssignmentContext)}.{nameof(variantAssignmentContext.FeatureDefinition)}.{nameof(featureDefinition.Variants)} cannot be null.", + nameof(variantAssignmentContext)); + } + + FeatureVariant variant = null; + + double cumulativePercentage = 0; + + var cumulativeGroups = new Dictionary( + _options.IgnoreCase ? StringComparer.OrdinalIgnoreCase : + StringComparer.Ordinal); + + foreach (FeatureVariant v in featureDefinition.Variants) + { + TargetingFilterSettings targetingSettings = v.AssignmentParameters.Get(); + + if (targetingSettings == null) + { + if (v.Default) + { + // + // Valid to omit audience for default variant + continue; + } + } + + if (!TargetingEvaluator.TryValidateSettings(targetingSettings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); + } + + AccumulateAudience(targetingSettings.Audience, cumulativeGroups, ref cumulativePercentage); + + if (TargetingEvaluator.IsTargeted(targetingSettings, targetingContext, _options.IgnoreCase, featureDefinition.Name)) + { + variant = v; + + break; + } + } + + return new ValueTask(variant); + } + + /// + /// Accumulates percentages for groups and the default rollout for an audience. + /// + /// The audience that will have its percentages updated based on currently accumulated percentages + /// The current cumulative default rollout percentage + /// The current cumulative rollout percentage for each group + private static void AccumulateAudience(Audience audience, Dictionary cumulativeGroups, ref double cumulativeDefaultPercentage) + { + if (audience.Groups != null) + { + foreach (GroupRollout gr in audience.Groups) + { + double percentage = gr.RolloutPercentage; + + if (cumulativeGroups.TryGetValue(gr.Name, out double p)) + { + percentage += p; + } + + cumulativeGroups[gr.Name] = percentage; + + gr.RolloutPercentage = percentage; + } + } + + cumulativeDefaultPercentage = cumulativeDefaultPercentage + audience.DefaultRolloutPercentage; + + audience.DefaultRolloutPercentage = cumulativeDefaultPercentage; + } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index a43c1a80..a6e4ffd2 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -2,12 +2,9 @@ // Licensed under the MIT license. // using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.Targeting; using System; -using System.Linq; -using System.Security.Cryptography; -using System.Text; using System.Threading; using System.Threading.Tasks; @@ -21,21 +18,16 @@ public class ContextualTargetingFilter : IContextualFeatureFilter /// Creates a targeting contextual feature filter. /// /// Options controlling the behavior of the targeting evaluation performed by the filter. - /// A logger factory for creating loggers. - public ContextualTargetingFilter(IOptions options, ILoggerFactory loggerFactory) + public ContextualTargetingFilter(IOptions options) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); } - private StringComparison ComparisonType => _options.IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - /// /// Performs a targeting evaluation using the provided to determine if a feature should be enabled. /// @@ -58,131 +50,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargeti TargetingFilterSettings settings = context.Parameters.Get() ?? new TargetingFilterSettings(); - if (!TryValidateSettings(settings, out string paramName, out string message)) - { - throw new ArgumentException(message, paramName); - } - - // - // Check if the user is being targeted directly - if (targetingContext.UserId != null && - settings.Audience.Users != null && - settings.Audience.Users.Any(user => targetingContext.UserId.Equals(user, ComparisonType))) - { - return Task.FromResult(true); - } - - // - // Check if the user is in a group that is being targeted - if (targetingContext.Groups != null && - settings.Audience.Groups != null) - { - foreach (string group in targetingContext.Groups) - { - GroupRollout groupRollout = settings.Audience.Groups.FirstOrDefault(g => g.Name.Equals(group, ComparisonType)); - - if (groupRollout != null) - { - string audienceContextId = $"{targetingContext.UserId}\n{context.FeatureName}\n{group}"; - - if (IsTargeted(audienceContextId, groupRollout.RolloutPercentage)) - { - return Task.FromResult(true); - } - } - } - } - - // - // Check if the user is being targeted by a default rollout percentage - string defaultContextId = $"{targetingContext.UserId}\n{context.FeatureName}"; - - return Task.FromResult(IsTargeted(defaultContextId, settings.Audience.DefaultRolloutPercentage)); - } - - - /// - /// Determines if a given context id should be targeted based off the provided percentage - /// - /// A context identifier that determines what the percentage is applicable for - /// The total percentage of possible context identifiers that should be targeted - /// A boolean representing if the context identifier should be targeted - private bool IsTargeted(string contextId, double percentage) - { - byte[] hash; - - using (HashAlgorithm hashAlgorithm = SHA256.Create()) - { - hash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(contextId)); - } - - // - // Use first 4 bytes for percentage calculation - // Cryptographic hashing algorithms ensure adequate entropy across hash - uint contextMarker = BitConverter.ToUInt32(hash, 0); - - double contextPercentage = (contextMarker / (double)uint.MaxValue) * 100; - - return contextPercentage < percentage; - } - - /// - /// Performs validation of targeting settings. - /// - /// The settings to validate. - /// The name of the invalid setting, if any. - /// The reason that the setting is invalid. - /// True if the provided settings are valid. False if the provided settings are invalid. - private bool TryValidateSettings(TargetingFilterSettings settings, out string paramName, out string reason) - { - const string OutOfRange = "The value is out of the accepted range."; - - const string RequiredParameter = "Value cannot be null."; - - paramName = null; - - reason = null; - - if (settings.Audience == null) - { - paramName = nameof(settings.Audience); - - reason = RequiredParameter; - - return false; - } - - if (settings.Audience.DefaultRolloutPercentage < 0 || settings.Audience.DefaultRolloutPercentage > 100) - { - paramName = $"{settings.Audience}.{settings.Audience.DefaultRolloutPercentage}"; - - reason = OutOfRange; - - return false; - } - - if (settings.Audience.Groups != null) - { - int index = 0; - - foreach (GroupRollout groupRollout in settings.Audience.Groups) - { - index++; - - if (groupRollout.RolloutPercentage < 0 || groupRollout.RolloutPercentage > 100) - { - // - // Audience.Groups[1].RolloutPercentage - paramName = $"{settings.Audience}.{settings.Audience.Groups}[{index}].{groupRollout.RolloutPercentage}"; - - reason = OutOfRange; - - return false; - } - } - } - - return true; + return Task.FromResult(TargetingEvaluator.IsTargeted(settings, targetingContext, _options.IgnoreCase, context.FeatureName)); } } } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs new file mode 100644 index 00000000..3e33b38b --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.FeatureManagement.Targeting +{ + static class TargetingEvaluator + { + private static StringComparison GetComparisonType(bool ignoreCase) => + ignoreCase ? + StringComparison.OrdinalIgnoreCase : + StringComparison.Ordinal; + + public static bool IsTargeted(TargetingFilterSettings settings, ITargetingContext targetingContext, bool ignoreCase, string hint) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + if (!TryValidateSettings(settings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); + } + + // + // Check if the user is being targeted directly + if (targetingContext.UserId != null && + settings.Audience.Users != null && + settings.Audience.Users.Any(user => targetingContext.UserId.Equals(user, GetComparisonType(ignoreCase)))) + { + return true; + } + + string userId = ignoreCase ? + targetingContext.UserId.ToLower() : + targetingContext.UserId; + + // + // Check if the user is in a group that is being targeted + if (targetingContext.Groups != null && + settings.Audience.Groups != null) + { + IEnumerable groups = ignoreCase ? + targetingContext.Groups.Select(g => g.ToLower()) : + targetingContext.Groups; + + foreach (string group in groups) + { + GroupRollout groupRollout = settings.Audience.Groups.FirstOrDefault(g => g.Name.Equals(group, GetComparisonType(ignoreCase))); + + if (groupRollout != null) + { + string audienceContextId = $"{userId}\n{hint}\n{group}"; + + if (IsTargeted(audienceContextId, groupRollout.RolloutPercentage)) + { + return true; + } + } + } + } + + // + // Check if the user is being targeted by a default rollout percentage + string defaultContextId = $"{userId}\n{hint}"; + + return IsTargeted(defaultContextId, settings.Audience.DefaultRolloutPercentage); + } + + /// + /// Performs validation of targeting settings. + /// + /// The settings to validate. + /// The name of the invalid setting, if any. + /// The reason that the setting is invalid. + /// True if the provided settings are valid. False if the provided settings are invalid. + public static bool TryValidateSettings(TargetingFilterSettings targetingSettings, out string paramName, out string reason) + { + const string OutOfRange = "The value is out of the accepted range."; + + const string RequiredParameter = "Value cannot be null."; + + paramName = null; + + reason = null; + + if (targetingSettings == null) + { + paramName = nameof(targetingSettings); + + reason = RequiredParameter; + + return false; + } + + if (targetingSettings.Audience == null) + { + paramName = nameof(targetingSettings.Audience); + + reason = RequiredParameter; + + return false; + } + + if (targetingSettings.Audience.DefaultRolloutPercentage < 0 || targetingSettings.Audience.DefaultRolloutPercentage > 100) + { + paramName = $"{targetingSettings.Audience}.{targetingSettings.Audience.DefaultRolloutPercentage}"; + + reason = OutOfRange; + + return false; + } + + if (targetingSettings.Audience.Groups != null) + { + int index = 0; + + foreach (GroupRollout groupRollout in targetingSettings.Audience.Groups) + { + index++; + + if (groupRollout.RolloutPercentage < 0 || groupRollout.RolloutPercentage > 100) + { + // + // Audience.Groups[1].RolloutPercentage + paramName = $"{targetingSettings.Audience}.{targetingSettings.Audience.Groups}[{index}].{groupRollout.RolloutPercentage}"; + + reason = OutOfRange; + + return false; + } + } + } + + return true; + } + + + /// + /// Determines if a given context id should be targeted based off the provided percentage + /// + /// A context identifier that determines what the percentage is applicable for + /// The total percentage of possible context identifiers that should be targeted + /// A boolean representing if the context identifier should be targeted + private static bool IsTargeted(string contextId, double percentage) + { + byte[] hash; + + using (HashAlgorithm hashAlgorithm = SHA256.Create()) + { + hash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(contextId)); + } + + // + // Use first 4 bytes for percentage calculation + // Cryptographic hashing algorithms ensure adequate entropy across hash + uint contextMarker = BitConverter.ToUInt32(hash, 0); + + double contextPercentage = (contextMarker / (double)uint.MaxValue) * 100; + + return contextPercentage < percentage; + } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs new file mode 100644 index 00000000..f041c299 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A feature variant assigner that can be used to assign a variant based on targeted audiences. + /// + [AssignerAlias(Alias)] + public class TargetingFeatureVariantAssigner : IFeatureVariantAssigner + { + private const string Alias = "Microsoft.Targeting"; + private readonly ITargetingContextAccessor _contextAccessor; + private readonly IContextualFeatureVariantAssigner _contextualResolver; + private readonly ILogger _logger; + + /// + /// Creates a feature variant assigner that uses targeting to assign which of a feature's registered variants should be used. + /// + /// The options controlling how targeting is performed. + /// An accessor for the targeting context required to perform a targeting evaluation. + /// A logger factory for producing logs. + public TargetingFeatureVariantAssigner(IOptions options, + ITargetingContextAccessor contextAccessor, + ILoggerFactory loggerFactory) + { + _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); + _contextualResolver = new ContextualTargetingFeatureVariantAssigner(options); + _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); + } + + /// + /// Assigns one of the variants configured for a feature based off the provided targeting context. + /// + /// Contextual information available for use during the assignment process. + /// The cancellation token to cancel the operation. + /// + public async ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken) + { + if (variantAssignmentContext == null) + { + throw new ArgumentNullException(nameof(variantAssignmentContext)); + } + + // + // Acquire targeting context via accessor + TargetingContext targetingContext = await _contextAccessor.GetContextAsync(cancellationToken).ConfigureAwait(false); + + // + // Ensure targeting can be performed + if (targetingContext == null) + { + _logger.LogWarning("No targeting context available for targeting evaluation."); + + return null; + } + + return await _contextualResolver.AssignVariantAsync(variantAssignmentContext, targetingContext, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs index 93e753ef..716ee126 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs @@ -29,7 +29,7 @@ public class TargetingFilter : IFeatureFilter public TargetingFilter(IOptions options, ITargetingContextAccessor contextAccessor, ILoggerFactory loggerFactory) { _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); - _contextualFilter = new ContextualTargetingFilter(options, loggerFactory); + _contextualFilter = new ContextualTargetingFilter(options); _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); } diff --git a/tests/Tests.FeatureManagement/ContextualTestAssigner.cs b/tests/Tests.FeatureManagement/ContextualTestAssigner.cs new file mode 100644 index 00000000..8065f170 --- /dev/null +++ b/tests/Tests.FeatureManagement/ContextualTestAssigner.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tests.FeatureManagement +{ + class ContextualTestAssigner : IContextualFeatureVariantAssigner + { + public Func Callback { get; set; } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, IAccountContext appContext, CancellationToken cancellationToken) + { + return new ValueTask(Callback(variantAssignmentContext, appContext)); + } + } +} diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 0a123ec2..e35265d7 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -27,6 +27,8 @@ public class FeatureManagement private const string OffFeature = "OffFeature"; private const string ConditionalFeature = "ConditionalFeature"; private const string ContextualFeature = "ContextualFeature"; + private const string WithSuffixFeature = "WithSuffixFeature"; + private const string WithoutSuffixFeature = "WithoutSuffixFeature"; [Fact] public async Task ReadsConfiguration() @@ -38,7 +40,8 @@ public async Task ReadsConfiguration() services .AddSingleton(config) .AddFeatureManagement() - .AddFeatureFilter(); + .AddFeatureFilter() + .AddFeatureVariantAssigner(); ServiceProvider serviceProvider = services.BuildServiceProvider(); @@ -70,6 +73,94 @@ public async Task ReadsConfiguration() await featureManager.IsEnabledAsync(ConditionalFeature, CancellationToken.None); Assert.True(called); + + IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + + IEnumerable featureVariantAssigners = serviceProvider.GetRequiredService>(); + + TestAssigner testAssigner = (TestAssigner)featureVariantAssigners.First(f => f is TestAssigner); + + called = false; + + testAssigner.Callback = (evaluationContext) => + { + called = true; + + Assert.Equal(2, evaluationContext.FeatureDefinition.Variants.Count()); + + Assert.Equal(Features.VariantFeature, evaluationContext.FeatureDefinition.Name); + + FeatureVariant defaultVariant = evaluationContext.FeatureDefinition.Variants.First(v => v.Default); + + FeatureVariant otherVariant = evaluationContext.FeatureDefinition.Variants.First(v => !v.Default); + + // + // default variant + Assert.Equal("V1", defaultVariant.Name); + + Assert.Equal("Ref1", defaultVariant.ConfigurationReference); + + // other variant + Assert.Equal("V2", otherVariant.Name); + + Assert.Equal("Ref2", otherVariant.ConfigurationReference); + + Assert.Equal("V1", otherVariant.AssignmentParameters["P1"]); + + return otherVariant; + }; + + string val = await variantManager.GetVariantAsync(Features.VariantFeature, CancellationToken.None); + + Assert.True(called); + + Assert.Equal("def", val); + } + + [Fact] + public async Task AllowsSuffix() + { + /* + * Verifies a filter named ___Filter can be referenced with "___" or "___Filter" + */ + + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + + IEnumerable featureFilters = serviceProvider.GetRequiredService>(); + + TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter); + + bool called = false; + + testFeatureFilter.Callback = (evaluationContext) => + { + called = true; + + return true; + }; + + await featureManager.IsEnabledAsync(WithSuffixFeature, CancellationToken.None); + + Assert.True(called); + + called = false; + + await featureManager.IsEnabledAsync(WithoutSuffixFeature, CancellationToken.None); + + Assert.True(called); } [Fact] @@ -154,7 +245,7 @@ public async Task GatesFeatures() // // Enable 1/2 features - testFeatureFilter.Callback = ctx => ctx.FeatureName == Enum.GetName(typeof(Features), Features.ConditionalFeature); + testFeatureFilter.Callback = ctx => ctx.FeatureName == Features.ConditionalFeature; gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); @@ -266,7 +357,7 @@ public async Task Targeting() IFeatureManager featureManager = serviceProvider.GetRequiredService(); - string targetingTestFeature = Enum.GetName(typeof(Features), Features.TargetingTestFeature); + string targetingTestFeature = Features.TargetingTestFeature; // // Targeted by user id @@ -306,6 +397,151 @@ public async Task Targeting() }, CancellationToken.None)); } + [Fact] + public async Task VariantTargeting() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .Configure(options => + { + options.IgnoreMissingFeatureFilters = true; + }); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + + // + // Targeted + Assert.Equal("def", await variantManager.GetVariantAsync( + Features.ContextualVariantTargetingFeature, + new TargetingContext + { + UserId = "Jeff" + }, + CancellationToken.None)); + + // + // Not targeted + Assert.Equal("abc", await variantManager.GetVariantAsync( + Features.ContextualVariantTargetingFeature, + new TargetingContext + { + UserId = "Patty" + }, + CancellationToken.None)); + } + + [Fact] + public async Task AccumulatesAudience() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .Configure(options => + { + options.IgnoreMissingFeatureFilters = true; + }); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + + IFeatureDefinitionProvider featureProvider = serviceProvider.GetRequiredService(); + + var occurences = new Dictionary(); + + int totalAssignments = 3000; + + // + // Test default rollout percentage accumulation + for (int i = 0; i < totalAssignments; i++) + { + string result = await variantManager.GetVariantAsync( + "AccumulatedTargetingFeature", + new TargetingContext + { + UserId = RandomHelper.GetRandomString(32) + }, + CancellationToken.None); + + if (!occurences.ContainsKey(result)) + { + occurences.Add(result, 1); + } + else + { + occurences[result]++; + } + } + + foreach (KeyValuePair occurence in occurences) + { + double expectedPercentage = double.Parse(occurence.Key); + + double tolerance = expectedPercentage * .25; + + double percentage = 100 * (double)occurence.Value / totalAssignments; + + Assert.True(percentage > expectedPercentage - tolerance); + + Assert.True(percentage < expectedPercentage + tolerance); + } + + occurences.Clear(); + + // + // Test Group rollout accumulation + for (int i = 0; i < totalAssignments; i++) + { + string result = await variantManager.GetVariantAsync( + "AccumulatedGroupsTargetingFeature", + new TargetingContext + { + UserId = RandomHelper.GetRandomString(32), + Groups = new string[] { "r", } + }, + CancellationToken.None); + + if (!occurences.ContainsKey(result)) + { + occurences.Add(result, 1); + } + else + { + occurences[result]++; + } + } + + foreach (KeyValuePair occurence in occurences) + { + double expectedPercentage = double.Parse(occurence.Key); + + double tolerance = expectedPercentage * .25; + + double percentage = 100 * (double)occurence.Value / totalAssignments; + + Assert.True(percentage > expectedPercentage - tolerance); + + Assert.True(percentage < expectedPercentage + tolerance); + } + } + [Fact] public async Task TargetingAccessor() { @@ -332,7 +568,7 @@ public async Task TargetingAccessor() IFeatureManager featureManager = serviceProvider.GetRequiredService(); - string beta = Enum.GetName(typeof(Features), Features.TargetingTestFeature); + string beta = Features.TargetingTestFeature; // // Targeted by user id @@ -390,6 +626,58 @@ public async Task UsesContext() Assert.True(await featureManager.IsEnabledAsync(ContextualFeature, context, CancellationToken.None)); } + [Fact] + public async Task UsesContextVariants() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + ServiceProvider provider = serviceCollection.BuildServiceProvider(); + + ContextualTestAssigner contextualAssigner = (ContextualTestAssigner)provider + .GetRequiredService>().First(f => f is ContextualTestAssigner); + + contextualAssigner.Callback = (ctx, accountContext) => + { + foreach (FeatureVariant variant in ctx.FeatureDefinition.Variants) + { + var allowedAccounts = new List(); + + variant.AssignmentParameters.Bind("AllowedAccounts", allowedAccounts); + + if (allowedAccounts.Contains(accountContext.AccountId)) + { + return variant; + } + } + + return ctx.FeatureDefinition.Variants.FirstOrDefault(v => v.Default); + }; + + IFeatureVariantManager variantManager = provider.GetRequiredService(); + + AppContext context = new AppContext(); + + context.AccountId = "NotEnabledAccount"; + + Assert.Equal("abc", await variantManager.GetVariantAsync( + Features.ContextualVariantFeature, + context, + CancellationToken.None)); + + context.AccountId = "abc"; + + Assert.Equal("def", await variantManager.GetVariantAsync( + Features.ContextualVariantFeature, + context, + CancellationToken.None)); + } + [Fact] public void LimitsFeatureFilterImplementations() { @@ -412,6 +700,28 @@ public void LimitsFeatureFilterImplementations() }); } + [Fact] + public void LimitsFeatureVariantAssignerImplementations() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var serviceCollection = new ServiceCollection(); + + Assert.Throws(() => + { + new ServiceCollection().AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + }); + + Assert.Throws(() => + { + new ServiceCollection().AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + }); + } + [Fact] public async Task ListsFeatures() { @@ -455,8 +765,8 @@ public async Task ThrowsExceptionForMissingFeatureFilter() IFeatureManager featureManager = serviceProvider.GetRequiredService(); - FeatureManagementException e = await Assert.ThrowsAsync( - async () => await featureManager.IsEnabledAsync(ConditionalFeature, CancellationToken.None)); + FeatureManagementException e = await Assert.ThrowsAsync(async () => + await featureManager.IsEnabledAsync(ConditionalFeature, CancellationToken.None)); Assert.Equal(FeatureManagementError.MissingFeatureFilter, e.Error); } @@ -509,6 +819,7 @@ public async Task CustomFeatureDefinitionProvider() var services = new ServiceCollection(); services.AddSingleton(new InMemoryFeatureDefinitionProvider(new FeatureDefinition[] { testFeature })) + .AddSingleton(new ConfigurationBuilder().Build()) .AddFeatureManagement() .AddFeatureFilter(); diff --git a/tests/Tests.FeatureManagement/Features.cs b/tests/Tests.FeatureManagement/Features.cs index ade2d13d..3e8d791d 100644 --- a/tests/Tests.FeatureManagement/Features.cs +++ b/tests/Tests.FeatureManagement/Features.cs @@ -3,12 +3,15 @@ // namespace Tests.FeatureManagement { - enum Features + static class Features { - TargetingTestFeature, - OnTestFeature, - OffTestFeature, - ConditionalFeature, - ConditionalFeature2 + public const string TargetingTestFeature = "TargetingTestFeature"; + public const string OnTestFeature = "OnTestFeature"; + public const string OffTestFeature = "OffTestFeature"; + public const string ConditionalFeature = "ConditionalFeature"; + public const string ConditionalFeature2 = "ConditionalFeature2"; + public const string VariantFeature = "VariantFeature"; + public const string ContextualVariantFeature = "ContextualVariantFeature"; + public const string ContextualVariantTargetingFeature = "ContextualVariantTargetingFeature"; } } diff --git a/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs b/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs index b91dcad7..63a1032e 100644 --- a/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs +++ b/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs @@ -1,4 +1,7 @@ -using Microsoft.FeatureManagement; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; using System; using System.Collections.Generic; using System.Linq; diff --git a/tests/Tests.FeatureManagement/InvalidFeatureVariantAssigner.cs b/tests/Tests.FeatureManagement/InvalidFeatureVariantAssigner.cs new file mode 100644 index 00000000..d98f8028 --- /dev/null +++ b/tests/Tests.FeatureManagement/InvalidFeatureVariantAssigner.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; +using System.Threading; +using System.Threading.Tasks; + +namespace Tests.FeatureManagement +{ + // + // Cannot implement more than one IFeatureVariantAssigner interface + class InvalidFeatureVariantAssigner : IContextualFeatureVariantAssigner, IContextualFeatureVariantAssigner + { + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, IAccountContext appContext, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, object appContext, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + } + + // + // Cannot implement more than one IFeatureVariantAssigner interface + class InvalidFeatureVariantAssigner2 : IFeatureVariantAssigner, IContextualFeatureVariantAssigner + { + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, object appContext, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/tests/Tests.FeatureManagement/RandomHelper.cs b/tests/Tests.FeatureManagement/RandomHelper.cs new file mode 100644 index 00000000..e6bf13b2 --- /dev/null +++ b/tests/Tests.FeatureManagement/RandomHelper.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Text; + +namespace Tests.FeatureManagement +{ + class RandomHelper + { + private static Random s_random = new Random(); + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz"; + + public static string GetRandomString(int length) + { + var sb = new StringBuilder(); + + for (int i = 0; i < length; i++) + { + sb.Append(chars[s_random.Next(chars.Length) % chars.Length]); + } + + return sb.ToString(); + } + } +} diff --git a/tests/Tests.FeatureManagement/TestAssigner.cs b/tests/Tests.FeatureManagement/TestAssigner.cs new file mode 100644 index 00000000..260d8540 --- /dev/null +++ b/tests/Tests.FeatureManagement/TestAssigner.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tests.FeatureManagement +{ + class TestAssigner : IFeatureVariantAssigner + { + public Func Callback { get; set; } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken) + { + return new ValueTask(Callback(variantAssignmentContext)); + } + } +} diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index f033a400..0b7b8b04 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -52,6 +52,24 @@ } ] }, + "WithSuffixFeature": { + "EnabledFor": [ + { + "Name": "TestFilter", + "Parameters": { + } + } + ] + }, + "WithoutSuffixFeature": { + "EnabledFor": [ + { + "Name": "Test", + "Parameters": { + } + } + ] + }, "ContextualFeature": { "EnabledFor": [ { @@ -63,6 +81,136 @@ } } ] + }, + "VariantFeature": { + "Assigner": "Test", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "P1": "V1" + } + } + ] + }, + "ContextualVariantFeature": { + "Assigner": "ContextualTest", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "AllowedAccounts": [ + "abc" + ] + } + } + ] + }, + "ContextualVariantTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } + } + } + ] + }, + "AccumulatedTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Percentage15" + }, + { + "Name": "V2", + "ConfigurationReference": "Percentage35", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 35 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Percentage50", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 50 + } + } + } + ] + }, + "AccumulatedGroupsTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Percentage15" + }, + { + "Name": "V2", + "ConfigurationReference": "Percentage35", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "r", + "RolloutPercentage": 35 + } + ], + "DefaultRolloutPercentage": 0 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Percentage50", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "r", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 0 + } + } + } + ] } - } + }, + "Ref1": "abc", + "Ref2": "def", + "Percentage15": 15, + "Percentage35": 35, + "Percentage50": 50 }