From 99a3bdea379e72c0f997bac4f8a5d236f50cb121 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Tue, 27 Jul 2021 16:57:12 -0700 Subject: [PATCH 01/10] Add support for feature variants. --- Microsoft.FeatureManagement.sln | 7 + .../CustomAssignmentConsoleApp.csproj | 24 ++ .../DailyDiscountOptions.cs | 12 + .../DayOfWeekAssigner.cs | 39 +++ .../DayOfWeekAssignmentParameters.cs | 10 + .../CustomAssignmentConsoleApp/Program.cs | 49 ++++ .../appsettings.json | 31 +++ .../Controllers/HomeController.cs | 9 +- .../FeatureFlagDemo/DiscountBannerOptions.cs | 14 + examples/FeatureFlagDemo/MyFeatureFlags.cs | 14 +- examples/FeatureFlagDemo/Startup.cs | 1 + .../Views/Shared/_Layout.cshtml | 20 +- examples/FeatureFlagDemo/appsettings.json | 45 +++ examples/TargetingConsoleApp/CartOptions.cs | 10 + examples/TargetingConsoleApp/Program.cs | 18 +- examples/TargetingConsoleApp/appsettings.json | 41 +++ .../ConfigurationFeatureDefinitionProvider.cs | 29 +- ...figurationFeatureVariantOptionsResolver.cs | 35 +++ ...ntextualFeatureVariantAssignerEvaluator.cs | 112 ++++++++ .../FeatureDefinition.cs | 13 +- .../FeatureManagementBuilder.cs | 23 ++ .../FeatureManagementError.cs | 17 +- .../FeatureManagementOptions.cs | 6 + .../FeatureManager.cs | 259 +++++++++++++++--- .../FeatureManagerSnapshot.cs | 1 + .../FeatureVariant.cs | 33 +++ .../FeatureVariantAssignmentContext.cs | 16 ++ .../FilterAliasAttribute.cs | 2 +- .../IContextualFeatureVariantAssigner.cs | 24 ++ .../IFeatureManagementBuilder.cs | 9 + .../IFeatureVariantAssigner.cs | 22 ++ .../IFeatureVariantAssignerMetadata.cs | 12 + .../IFeatureVariantManager.cs | 34 +++ .../IFeatureVariantOptionsResolver.cs | 24 ++ .../ServiceCollectionExtensions.cs | 17 +- ...ntextualTargetingFeatureVariantAssigner.cs | 104 +++++++ .../Targeting/ContextualTargetingFilter.cs | 130 +-------- .../Targeting/TargetingEvaluator.cs | 164 +++++++++++ .../TargetingFeatureVariantAssigner.cs | 68 +++++ .../ContextualTestAssigner.cs | 20 ++ .../FeatureManagement.cs | 173 +++++++++++- tests/Tests.FeatureManagement/Features.cs | 15 +- .../InMemoryFeatureDefinitionProvider.cs | 5 +- .../InvalidFeatureVariantAssigner.cs | 39 +++ tests/Tests.FeatureManagement/TestAssigner.cs | 20 ++ .../Tests.FeatureManagement/appsettings.json | 61 ++++- 46 files changed, 1623 insertions(+), 208 deletions(-) create mode 100644 examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj create mode 100644 examples/CustomAssignmentConsoleApp/DailyDiscountOptions.cs create mode 100644 examples/CustomAssignmentConsoleApp/DayOfWeekAssigner.cs create mode 100644 examples/CustomAssignmentConsoleApp/DayOfWeekAssignmentParameters.cs create mode 100644 examples/CustomAssignmentConsoleApp/Program.cs create mode 100644 examples/CustomAssignmentConsoleApp/appsettings.json create mode 100644 examples/FeatureFlagDemo/DiscountBannerOptions.cs create mode 100644 examples/TargetingConsoleApp/CartOptions.cs create mode 100644 src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs create mode 100644 src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureVariant.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs create mode 100644 src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs create mode 100644 src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs create mode 100644 src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs create mode 100644 src/Microsoft.FeatureManagement/IFeatureVariantManager.cs create mode 100644 src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs create mode 100644 src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs create mode 100644 src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs create mode 100644 src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs create mode 100644 tests/Tests.FeatureManagement/ContextualTestAssigner.cs create mode 100644 tests/Tests.FeatureManagement/InvalidFeatureVariantAssigner.cs create mode 100644 tests/Tests.FeatureManagement/TestAssigner.cs 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..c1cb7e3b --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj @@ -0,0 +1,24 @@ + + + + Exe + netcoreapp3.1 + 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/DayOfWeekAssigner.cs b/examples/CustomAssignmentConsoleApp/DayOfWeekAssigner.cs new file mode 100644 index 00000000..76a64393 --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/DayOfWeekAssigner.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.FeatureManagement; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Consoto.Banking.AccountService +{ + class DayOfWeekAssigner : 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) + { + DayOfWeekAssignmentParameters p = variant.AssignmentParameters.Get() ?? + new DayOfWeekAssignmentParameters(); + + if (!string.IsNullOrEmpty(p.DayOfWeek) && + p.DayOfWeek.Equals(currentDay, StringComparison.OrdinalIgnoreCase)) + { + chosenVariant = variant; + + break; + } + } + + return new ValueTask(chosenVariant); + } + } +} diff --git a/examples/CustomAssignmentConsoleApp/DayOfWeekAssignmentParameters.cs b/examples/CustomAssignmentConsoleApp/DayOfWeekAssignmentParameters.cs new file mode 100644 index 00000000..04f63baf --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/DayOfWeekAssignmentParameters.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Consoto.Banking.AccountService +{ + class DayOfWeekAssignmentParameters + { + public string DayOfWeek { get; set; } + } +} diff --git a/examples/CustomAssignmentConsoleApp/Program.cs b/examples/CustomAssignmentConsoleApp/Program.cs new file mode 100644 index 00000000..db8084e4 --- /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(DayOfWeekAssigner)); + + 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/appsettings.json b/examples/CustomAssignmentConsoleApp/appsettings.json new file mode 100644 index 00000000..d15dbf68 --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/appsettings.json @@ -0,0 +1,31 @@ +{ + "FeatureManagement": { + "DailyDiscount": { + "Assigner": "DayOfWeek", + "Variants": [ + { + "Default": true, + "Name": "Default", + "ConfigurationReference": "DailyDiscount:Default" + }, + { + "Name": "Default", + "ConfigurationReference": "DailyDiscount:Tuesday", + "AssignmentParameters": { + "DayOfWeek": "Tuesday" + } + } + ] + } + }, + "DailyDiscount": { + "Default": { + "Discount": 20, + "ProductName": "Bananas" + }, + "Tuesday": { + "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..61858ef7 100644 --- a/examples/FeatureFlagDemo/Controllers/HomeController.cs +++ b/examples/FeatureFlagDemo/Controllers/HomeController.cs @@ -15,10 +15,13 @@ namespace FeatureFlagDemo.Controllers public class HomeController : Controller { private readonly IFeatureManager _featureManager; + private readonly IFeatureVariantManager _variantManager; - public HomeController(IFeatureManagerSnapshot featureSnapshot) + public HomeController(IFeatureManagerSnapshot featureSnapshot, + IFeatureVariantManager variantManager) { _featureManager = featureSnapshot; + _variantManager = variantManager; } [FeatureGate(MyFeatureFlags.Home)] @@ -31,9 +34,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..79fbd44c --- /dev/null +++ b/examples/TargetingConsoleApp/CartOptions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Consoto.Banking.HelpDesk +{ + class CartOptions + { + public int Size { get; set; } + } +} diff --git a/examples/TargetingConsoleApp/Program.cs b/examples/TargetingConsoleApp/Program.cs index 10e64223..91d27b7f 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 @@ -63,11 +65,23 @@ 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("Beta", targetingContext, CancellationToken.None); + + // + // Retrieve feature variant using targeting + CartOptions cartOptions = await variantManager + .GetVariantAsync( + "ShoppingCart", + targetingContext, + CancellationToken.None); // // Output results Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); + + Console.WriteLine($"User {user.Id}'s cart size: {cartOptions.Size}."); } } } 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/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..acdbe826 --- /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 GetOptions(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/ContextualFeatureVariantAssignerEvaluator.cs b/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs new file mode 100644 index 00000000..b70d6e02 --- /dev/null +++ b/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs @@ -0,0 +1,112 @@ +// 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 IContextualFeatureVariantAssigner<T> without knowing what the generic type parameter is. + /// + sealed class ContextualFeatureVariantAssignerEvaluator : IContextualFeatureVariantAssigner + { + private IFeatureVariantAssignerMetadata _filter; + 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 IContextualFeatureFilter.EvaluateAsync method. + if (targetInterface != null) + { + MethodInfo evaluateMethod = targetInterface.GetMethod(nameof(IContextualFeatureVariantAssigner.AssignVariantAsync), BindingFlags.Public | BindingFlags.Instance); + + _evaluateFunc = TypeAgnosticEvaluate(assigner.GetType(), evaluateMethod); + } + + _filter = assigner; + } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext assignmentContext, object context, CancellationToken cancellationToken) + { + if (_evaluateFunc == null) + { + return new ValueTask((FeatureVariant)null); + } + + return _evaluateFunc(_filter, assignmentContext, context, cancellationToken); + } + + public static bool IsContextualFilter(IFeatureVariantAssignerMetadata assigner, Type 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 filterType, 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 + (filterType, + 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..f86ff9a9 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementError.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementError.cs @@ -16,6 +16,21 @@ 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 assigner that was listed for variant assignment was not found. + /// + MissingFeatureAssigner, + + /// + /// An assigned feature variant does not have a valid configuration reference. + /// + MissingConfigurationReference, + + /// + /// An invalid configuration was encountered when performing a feature managment operation. + /// + InvalidConfiguration } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs index 9322934e..bd411174 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs @@ -13,5 +13,11 @@ public class FeatureManagementOptions /// If missing feature filters are not ignored an exception will be thrown when attempting to evaluate a feature that depends on a missing feature filter. /// public bool IgnoreMissingFeatureFilters { get; set; } + + /// + /// Controls the behavior of feature assignment when dependent feature assigners are missing. + /// If missing feature assigners are not ignored an exception will be thrown when attempting to assign a variant of a feature that uses the missing assigner. + /// + public bool IgnoreMissingFeatureAssigners { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index c57f03c5..e4de995c 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(); + _assignerMetadataCache = new ConcurrentDictionary(); + _contextualFeatureVariantAssignerCache = new ConcurrentDictionary(); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); } @@ -54,14 +65,106 @@ 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) + { + if (string.IsNullOrEmpty(featureDefinition.Assigner)) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfiguration, + $"Invalid feature variant assigner name for the feature {feature}"); + } + + IFeatureVariantAssignerMetadata assigner = GetFeatureAssignerMetadata(featureDefinition.Assigner); + + if (assigner == null) + { + string errorMessage = $"The feature assigner '{featureDefinition.Assigner}' specified for feature '{feature}' was not found."; + + if (!_options.IgnoreMissingFeatureAssigners) + { + throw new FeatureManagementException(FeatureManagementError.MissingFeatureAssigner, errorMessage); + } + else + { + _logger.LogWarning(errorMessage); + + return default(T); + } + } + + var context = new FeatureVariantAssignmentContext() + { + FeatureDefinition = featureDefinition + }; + + // + // IContextualFeatureVariantAssigner + if (useAppContext) + { + ContextualFeatureVariantAssignerEvaluator contextualAssigner = GetContextualFeatureVariantAssigner(featureDefinition.Assigner, typeof(TContext)); + + if (contextualAssigner != null) + { + variant = await contextualAssigner.AssignVariantAsync(context, appContext, cancellationToken).ConfigureAwait(false); + } + } + + // + // IFeatureVariantAssigner + if (assigner is IFeatureVariantAssigner featureVariantAssigner) + { + variant = await featureVariantAssigner.AssignVariantAsync(context, cancellationToken).ConfigureAwait(false); + } + + if (variant == null && + featureDefinition.Variants != null) + { + variant = featureDefinition.Variants.FirstOrDefault(v => v.Default); + } + } + + if (variant == null) + { + return default(T); + } + + if (variant.ConfigurationReference == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingConfigurationReference, + $"The variant '{variant.Name}' for the feature '{feature}' does not have a configuration reference."); + } + + return await _variantOptionsResolver.GetOptions(featureDefinition, variant, cancellationToken).ConfigureAwait(false); + } + private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { foreach (ISessionManager sessionManager in _sessionManagers) @@ -76,9 +179,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 +199,13 @@ private async Task IsEnabledAsync(string feature, TContext appCo foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor) { + if (string.IsNullOrEmpty(featureFilterConfiguration.Name)) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfiguration, + $"Invalid feature filter name for the feature {feature}"); + } + IFeatureFilterMetadata filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name); if (filter == null) @@ -119,7 +227,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo var context = new FeatureFilterEvaluationContext() { FeatureName = feature, - Parameters = featureFilterConfiguration.Parameters + Parameters = featureFilterConfiguration.Parameters }; // @@ -128,9 +236,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo { ContextualFeatureFilterEvaluator contextualFilter = GetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext)); - if (contextualFilter != null && await contextualFilter - .EvaluateAsync(context, appContext, cancellationToken) - .ConfigureAwait(false)) + if (contextualFilter != null && await contextualFilter.EvaluateAsync(context, appContext, cancellationToken).ConfigureAwait(false)) { enabled = true; @@ -140,9 +246,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo // // IFeatureFilter - if (filter is IFeatureFilter featureFilter && await featureFilter - .EvaluateAsync(context, cancellationToken) - .ConfigureAwait(false)) + if (filter is IFeatureFilter featureFilter && await featureFilter.EvaluateAsync(context, cancellationToken).ConfigureAwait(false)) { enabled = true; @@ -162,41 +266,15 @@ private async Task IsEnabledAsync(string feature, TContext appCo private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) { - const string filterSuffix = "filter"; - IFeatureFilterMetadata filter = _filterMetadataCache.GetOrAdd( filterName, (_) => { IEnumerable matchingFilters = _featureFilters.Where(f => { - Type t = f.GetType(); + string name = GetMetadataName(f.GetType()); - string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(t, 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); - } - 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); }); if (matchingFilters.Count() > 1) @@ -211,6 +289,79 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) return filter; } + private IFeatureVariantAssignerMetadata GetFeatureAssignerMetadata(string assignerName) + { + IFeatureVariantAssignerMetadata assigner = _assignerMetadataCache.GetOrAdd( + assignerName, + (_) => { + + IEnumerable matchingAssigners = _variantAssigners.Where(f => + { + string name = GetMetadataName(f.GetType()); + + return IsMatchingMetadataName(name, assignerName); + }); + + if (matchingAssigners.Count() > 1) + { + throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureFilter, $"Multiple feature filters match the configured filter named '{assignerName}'."); + } + + return matchingAssigners.FirstOrDefault(); + } + ); + + return assigner; + } + + private static string GetMetadataName(Type type) + { + const string filterSuffix = "filter"; + const string assignerSuffix = "assigner"; + + Debug.Assert(type != null); + + string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(type, typeof(FilterAliasAttribute)))?.Alias; + + if (name == null) + { + name = type.Name; + + if (name.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - filterSuffix.Length); + } + else if (name.EndsWith(assignerSuffix, StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - assignerSuffix.Length); + } + } + + return name; + } + + private static bool IsMatchingMetadataName(string metadataName, string desiredName) + { + // + // 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 ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filterName, Type appContextType) { if (appContextType == null) @@ -232,5 +383,27 @@ private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filte return filter; } + + private ContextualFeatureVariantAssignerEvaluator GetContextualFeatureVariantAssigner(string assignerName, Type appContextType) + { + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + + ContextualFeatureVariantAssignerEvaluator assigner = _contextualFeatureVariantAssignerCache.GetOrAdd( + $"{assignerName}{Environment.NewLine}{appContextType.FullName}", + (_) => { + + IFeatureVariantAssignerMetadata metadata = GetFeatureAssignerMetadata(assignerName); + + return ContextualFeatureVariantAssignerEvaluator.IsContextualFilter(metadata, appContextType) ? + new ContextualFeatureVariantAssignerEvaluator(metadata, appContextType) : + null; + } + ); + + return assigner; + } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs index 46549f4d..c028d1da 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs @@ -16,6 +16,7 @@ class FeatureManagerSnapshot : IFeatureManagerSnapshot { private readonly IFeatureManager _featureManager; private readonly IDictionary _flagCache = new Dictionary(); + private readonly IDictionary _variantCache = new Dictionary(); private IEnumerable _featureNames; public FeatureManagerSnapshot(IFeatureManager featureManager) 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/FilterAliasAttribute.cs b/src/Microsoft.FeatureManagement/FilterAliasAttribute.cs index 42f5571a..ca14477a 100644 --- a/src/Microsoft.FeatureManagement/FilterAliasAttribute.cs +++ b/src/Microsoft.FeatureManagement/FilterAliasAttribute.cs @@ -6,7 +6,7 @@ namespace Microsoft.FeatureManagement { /// - /// Allows the name of an to be customized to relate to the name specified in configuration. + /// Allows the name of an or to be customized to relate to the name specified in configuration. /// public class FilterAliasAttribute : Attribute { diff --git a/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs new file mode 100644 index 00000000..bc0fb2f0 --- /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. + /// + /// Information provided by the system to be used during the assignment process. + /// 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..da056e13 --- /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. + /// + /// Information provided by the system to be used during the assignment process. + /// 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..4b3645cd --- /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 manger 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..a48c2fbe --- /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 GetOptions(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index 507f1394..60fc37f5 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.FeatureManagement.FeatureFilters; using System; namespace Microsoft.FeatureManagement @@ -26,11 +27,21 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // Add required services services.TryAddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); - services.AddSingleton(); + services.TryAddSingleton, ContextualTargetingFeatureVariantAssigner>(); - services.AddScoped(); + services.AddSingleton(); + + 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..f778b64e --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.FeatureManagement.FeatureFilters; +using Microsoft.FeatureManagement.Targeting; +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. + /// + [FilterAlias(Alias)] + public class ContextualTargetingFeatureVariantAssigner : IContextualFeatureVariantAssigner + { + private const string Alias = "Microsoft.Targeting"; + + /// + /// 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) + { + FeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; + + if (featureDefinition == null) + { + return new ValueTask((FeatureVariant)null); + } + + FeatureVariant variant = null; + + FeatureVariant defaultVariant = null; + + double cumulativePercentage = 0; + + var cumulativeGroups = new Dictionary(); + + if (featureDefinition.Variants != null) + { + foreach (FeatureVariant v in featureDefinition.Variants) + { + if (defaultVariant == null && v.Default) + { + defaultVariant = v; + } + + TargetingFilterSettings targetingSettings = v.AssignmentParameters.Get(); + + if (targetingSettings == null) + { + // + // Valid to omit audience for default variant + continue; + } + + AccumulateAudience(targetingSettings.Audience, ref cumulativePercentage, ref cumulativeGroups); + + if (TargetingEvaluator.IsTargeted(targetingSettings, targetingContext, true, featureDefinition.Name)) + { + variant = v; + + break; + } + } + } + + if (variant == null) + { + variant = defaultVariant; + } + + return new ValueTask(variant); + } + + private static void AccumulateAudience(Audience audience, ref double cumulativePercentage, ref Dictionary cumulativeGroups) + { + 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; + } + } + + cumulativePercentage = cumulativePercentage + audience.DefaultRolloutPercentage; + } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index a43c1a80..4d879cc2 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -4,10 +4,8 @@ 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; @@ -58,131 +56,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..4ff38cd2 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.FeatureManagement.Targeting +{ + static class TargetingEvaluator + { + private static StringComparison ComparisonType(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, ComparisonType(ignoreCase)))) + { + return 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(ignoreCase))); + + if (groupRollout != null) + { + string audienceContextId = $"{targetingContext.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 = $"{targetingContext.UserId}\n{hint}"; + + return 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 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; + } + + /// + /// 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 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; + } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs new file mode 100644 index 00000000..640482df --- /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. + /// + [FilterAlias(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(); + _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/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..29bab408 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -38,7 +38,8 @@ public async Task ReadsConfiguration() services .AddSingleton(config) .AddFeatureManagement() - .AddFeatureFilter(); + .AddFeatureFilter() + .AddFeatureVariantAssigner(); ServiceProvider serviceProvider = services.BuildServiceProvider(); @@ -70,6 +71,48 @@ 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] @@ -154,7 +197,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 +309,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 +349,49 @@ 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 TargetingAccessor() { @@ -332,7 +418,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 +476,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 +550,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 +615,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 +669,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/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..a07e0df4 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -63,6 +63,65 @@ } } ] + }, + "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" + ] + } + } + } + ] } - } + }, + "Ref1": "abc", + "Ref2": "def" } From 57b06b369801551108b11f956f7e9b7c1c5400ff Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Mon, 30 Aug 2021 14:45:59 -0700 Subject: [PATCH 02/10] Renaming and assorted fixes. --- .../CustomAssignmentConsoleApp.csproj | 6 +- .../CustomAssignmentConsoleApp/Program.cs | 4 +- ...OfWeekAssigner.cs => RecurringAssigner.cs} | 11 ++-- ...rs.cs => RecurringAssignmentParameters.cs} | 6 +- .../appsettings.json | 8 ++- examples/TargetingConsoleApp/CartOptions.cs | 2 + examples/TargetingConsoleApp/Program.cs | 4 +- .../ContextualFeatureFilterEvaluator.cs | 15 +++++ ...ntextualFeatureVariantAssignerEvaluator.cs | 31 ++++++--- .../FeatureManagementError.cs | 9 ++- .../FeatureManager.cs | 4 +- ...ntextualTargetingFeatureVariantAssigner.cs | 32 ++++++++-- .../Targeting/ContextualTargetingFilter.cs | 8 +-- .../Targeting/TargetingEvaluator.cs | 63 ++++++++----------- .../TargetingFeatureVariantAssigner.cs | 2 +- .../Targeting/TargetingFilter.cs | 2 +- 16 files changed, 129 insertions(+), 78 deletions(-) rename examples/CustomAssignmentConsoleApp/{DayOfWeekAssigner.cs => RecurringAssigner.cs} (69%) rename examples/CustomAssignmentConsoleApp/{DayOfWeekAssignmentParameters.cs => RecurringAssignmentParameters.cs} (53%) diff --git a/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj index c1cb7e3b..68f2c659 100644 --- a/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj +++ b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj @@ -2,13 +2,13 @@ Exe - netcoreapp3.1 + net5.0 Consoto.Banking.AccountService - - + + diff --git a/examples/CustomAssignmentConsoleApp/Program.cs b/examples/CustomAssignmentConsoleApp/Program.cs index db8084e4..c1f91629 100644 --- a/examples/CustomAssignmentConsoleApp/Program.cs +++ b/examples/CustomAssignmentConsoleApp/Program.cs @@ -25,11 +25,11 @@ public static async Task Main(string[] args) // Setup application services + feature management IServiceCollection services = new ServiceCollection(); - services.AddSingleton(typeof(IFeatureVariantAssignerMetadata), typeof(DayOfWeekAssigner)); + services.AddSingleton(typeof(IFeatureVariantAssignerMetadata), typeof(RecurringAssigner)); services.AddSingleton(configuration) .AddFeatureManagement() - .AddFeatureVariantAssigner(); + .AddFeatureVariantAssigner(); // // Get the feature manager from application services diff --git a/examples/CustomAssignmentConsoleApp/DayOfWeekAssigner.cs b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs similarity index 69% rename from examples/CustomAssignmentConsoleApp/DayOfWeekAssigner.cs rename to examples/CustomAssignmentConsoleApp/RecurringAssigner.cs index 76a64393..3918c752 100644 --- a/examples/CustomAssignmentConsoleApp/DayOfWeekAssigner.cs +++ b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs @@ -4,12 +4,13 @@ using Microsoft.Extensions.Configuration; using Microsoft.FeatureManagement; using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; namespace Consoto.Banking.AccountService { - class DayOfWeekAssigner : IFeatureVariantAssigner + class RecurringAssigner : IFeatureVariantAssigner { public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken _) { @@ -21,11 +22,11 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont foreach (var variant in featureDefinition.Variants) { - DayOfWeekAssignmentParameters p = variant.AssignmentParameters.Get() ?? - new DayOfWeekAssignmentParameters(); + RecurringAssignmentParameters p = variant.AssignmentParameters.Get() ?? + new RecurringAssignmentParameters(); - if (!string.IsNullOrEmpty(p.DayOfWeek) && - p.DayOfWeek.Equals(currentDay, StringComparison.OrdinalIgnoreCase)) + if (p.Days != null && + p.Days.Any(d => d.Equals(currentDay, StringComparison.OrdinalIgnoreCase))) { chosenVariant = variant; diff --git a/examples/CustomAssignmentConsoleApp/DayOfWeekAssignmentParameters.cs b/examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs similarity index 53% rename from examples/CustomAssignmentConsoleApp/DayOfWeekAssignmentParameters.cs rename to examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs index 04f63baf..34518abe 100644 --- a/examples/CustomAssignmentConsoleApp/DayOfWeekAssignmentParameters.cs +++ b/examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs @@ -1,10 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System.Collections.Generic; + namespace Consoto.Banking.AccountService { - class DayOfWeekAssignmentParameters + class RecurringAssignmentParameters { - public string DayOfWeek { get; set; } + public List Days { get; set; } } } diff --git a/examples/CustomAssignmentConsoleApp/appsettings.json b/examples/CustomAssignmentConsoleApp/appsettings.json index d15dbf68..c9c2d31a 100644 --- a/examples/CustomAssignmentConsoleApp/appsettings.json +++ b/examples/CustomAssignmentConsoleApp/appsettings.json @@ -1,7 +1,7 @@ { "FeatureManagement": { "DailyDiscount": { - "Assigner": "DayOfWeek", + "Assigner": "Recurring", "Variants": [ { "Default": true, @@ -9,10 +9,12 @@ "ConfigurationReference": "DailyDiscount:Default" }, { - "Name": "Default", + "Name": "Tuesday", "ConfigurationReference": "DailyDiscount:Tuesday", "AssignmentParameters": { - "DayOfWeek": "Tuesday" + "Days": [ + "Tuesday" + ] } } ] diff --git a/examples/TargetingConsoleApp/CartOptions.cs b/examples/TargetingConsoleApp/CartOptions.cs index 79fbd44c..eb202dbd 100644 --- a/examples/TargetingConsoleApp/CartOptions.cs +++ b/examples/TargetingConsoleApp/CartOptions.cs @@ -6,5 +6,7 @@ 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 91d27b7f..8c76b224 100644 --- a/examples/TargetingConsoleApp/Program.cs +++ b/examples/TargetingConsoleApp/Program.cs @@ -67,7 +67,7 @@ public static async Task Main(string[] args) // // Evaluate feature flag using targeting - bool enabled = await featureManager.IsEnabledAsync("Beta", targetingContext, CancellationToken.None); + bool enabled = await featureManager.IsEnabledAsync(FeatureName, targetingContext, CancellationToken.None); // // Retrieve feature variant using targeting @@ -81,7 +81,7 @@ public static async Task Main(string[] args) // Output results Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); - Console.WriteLine($"User {user.Id}'s cart size: {cartOptions.Size}."); + Console.WriteLine($"User {user.Id} has a {cartOptions.Color} cart with a size of {cartOptions.Size} pixels."); } } } 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 index b70d6e02..edfbd8de 100644 --- a/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs +++ b/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs @@ -11,11 +11,11 @@ namespace Microsoft.FeatureManagement { /// - /// Provides a performance efficient method of evaluating IContextualFeatureVariantAssigner<T> without knowing what the generic type parameter is. + /// Provides a performance efficient method of evaluating without knowing what the generic type parameter is. /// sealed class ContextualFeatureVariantAssignerEvaluator : IContextualFeatureVariantAssigner { - private IFeatureVariantAssignerMetadata _filter; + private IFeatureVariantAssignerMetadata _assigner; private Func> _evaluateFunc; public ContextualFeatureVariantAssignerEvaluator(IFeatureVariantAssignerMetadata assigner, Type appContextType) @@ -33,7 +33,7 @@ public ContextualFeatureVariantAssignerEvaluator(IFeatureVariantAssignerMetadata Type targetInterface = GetContextualAssignerInterface(assigner, appContextType); // - // Extract IContextualFeatureFilter.EvaluateAsync method. + // Extract IContextualFeatureVariantAssigner.AssignVariantAsync method. if (targetInterface != null) { MethodInfo evaluateMethod = targetInterface.GetMethod(nameof(IContextualFeatureVariantAssigner.AssignVariantAsync), BindingFlags.Public | BindingFlags.Instance); @@ -41,21 +41,36 @@ public ContextualFeatureVariantAssignerEvaluator(IFeatureVariantAssignerMetadata _evaluateFunc = TypeAgnosticEvaluate(assigner.GetType(), evaluateMethod); } - _filter = assigner; + _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(_filter, assignmentContext, context, cancellationToken); + return _evaluateFunc(_assigner, assignmentContext, context, cancellationToken); } - public static bool IsContextualFilter(IFeatureVariantAssignerMetadata assigner, Type appContextType) + 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; } @@ -73,7 +88,7 @@ private static Type GetContextualAssignerInterface(IFeatureVariantAssignerMetada return targetInterface; } - private static Func> TypeAgnosticEvaluate(Type filterType, MethodInfo method) + private static Func> TypeAgnosticEvaluate(Type assignerType, MethodInfo method) { // // Get the generic version of the evaluation helper method @@ -83,7 +98,7 @@ private static Func - /// A feature assigner that was listed for variant assignment was not found. + /// A feature variant assigner that was listed for variant assignment was not found. /// MissingFeatureAssigner, + /// + /// The feature variant assigner configured for the feature being evaluated is an ambiguous reference to multiple registered feature assigners. + /// + AmbiguousFeatureAssigner, + /// /// An assigned feature variant does not have a valid configuration reference. /// MissingConfigurationReference, /// - /// An invalid configuration was encountered when performing a feature managment operation. + /// An invalid configuration was encountered when performing a feature management operation. /// InvalidConfiguration } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index e4de995c..39009ac3 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -304,7 +304,7 @@ private IFeatureVariantAssignerMetadata GetFeatureAssignerMetadata(string assign if (matchingAssigners.Count() > 1) { - throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureFilter, $"Multiple feature filters match the configured filter named '{assignerName}'."); + throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureFilter, $"Multiple feature assigners match the configured assigner named '{assignerName}'."); } return matchingAssigners.FirstOrDefault(); @@ -397,7 +397,7 @@ private ContextualFeatureVariantAssignerEvaluator GetContextualFeatureVariantAss IFeatureVariantAssignerMetadata metadata = GetFeatureAssignerMetadata(assignerName); - return ContextualFeatureVariantAssignerEvaluator.IsContextualFilter(metadata, appContextType) ? + return ContextualFeatureVariantAssignerEvaluator.IsContextualVariantAssigner(metadata, appContextType) ? new ContextualFeatureVariantAssignerEvaluator(metadata, appContextType) : null; } diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs index f778b64e..c0fd9ac5 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -2,8 +2,10 @@ // 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; @@ -17,6 +19,16 @@ namespace Microsoft.FeatureManagement 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. @@ -55,14 +67,26 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont if (targetingSettings == null) { - // - // Valid to omit audience for default variant - continue; + if (v.Default) + { + // + // Valid to omit audience for default variant + continue; + } + else + { + targetingSettings = new TargetingFilterSettings(); + } + } + + if (!TargetingEvaluator.TryValidateSettings(targetingSettings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); } AccumulateAudience(targetingSettings.Audience, ref cumulativePercentage, ref cumulativeGroups); - if (TargetingEvaluator.IsTargeted(targetingSettings, targetingContext, true, featureDefinition.Name)) + if (TargetingEvaluator.IsTargeted(targetingSettings, targetingContext, _options.IgnoreCase, featureDefinition.Name)) { variant = v; diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index 4d879cc2..a6e4ffd2 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. // using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.FeatureManagement.Targeting; using System; @@ -19,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. /// diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs index 4ff38cd2..2bfbdb2b 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -67,32 +67,6 @@ public static bool IsTargeted(TargetingFilterSettings settings, ITargetingContex return 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 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; - } - /// /// Performs validation of targeting settings. /// @@ -100,7 +74,7 @@ private static bool IsTargeted(string contextId, double percentage) /// 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 static bool TryValidateSettings(TargetingFilterSettings targetingSettings, out string paramName, out string reason) + public static bool TryValidateSettings(TargetingFilterSettings targetingSettings, out string paramName, out string reason) { const string OutOfRange = "The value is out of the accepted range."; @@ -110,15 +84,6 @@ private static bool TryValidateSettings(TargetingFilterSettings targetingSetting reason = null; - if (targetingSettings == null) - { - paramName = nameof(targetingSettings); - - reason = RequiredParameter; - - return false; - } - if (targetingSettings.Audience == null) { paramName = nameof(targetingSettings.Audience); @@ -160,5 +125,31 @@ private static bool TryValidateSettings(TargetingFilterSettings targetingSetting 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 index 640482df..4918b11f 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs @@ -32,7 +32,7 @@ public TargetingFeatureVariantAssigner(IOptions opti ILoggerFactory loggerFactory) { _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); - _contextualResolver = new ContextualTargetingFeatureVariantAssigner(); + _contextualResolver = new ContextualTargetingFeatureVariantAssigner(options); _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); } 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)); } From b8d1353f34fcc260e0ba4c134830ca5f51f91cce Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Mon, 30 Aug 2021 16:46:21 -0700 Subject: [PATCH 03/10] Remove unnecessary field. --- src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs index c028d1da..46549f4d 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs @@ -16,7 +16,6 @@ class FeatureManagerSnapshot : IFeatureManagerSnapshot { private readonly IFeatureManager _featureManager; private readonly IDictionary _flagCache = new Dictionary(); - private readonly IDictionary _variantCache = new Dictionary(); private IEnumerable _featureNames; public FeatureManagerSnapshot(IFeatureManager featureManager) From 6ea29fd4593a2ce7391b12d2db192196b10bb9c8 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Fri, 17 Sep 2021 11:18:03 -0700 Subject: [PATCH 04/10] * Added additional argument validation. * Added additional dynamic feature configuration validation. * Fixed bug in audience accumulation for variant targeting * Added test for variant targeting audience accumulation. --- .../RecurringAssigner.cs | 1 + .../appsettings.json | 6 +- .../Controllers/HomeController.cs | 5 +- examples/TargetingConsoleApp/Program.cs | 13 +- .../AssignerAliasAttribute.cs | 32 +++ ...figurationFeatureVariantOptionsResolver.cs | 2 +- .../FeatureManagementError.cs | 18 +- .../FeatureManagementOptions.cs | 6 - .../FeatureManager.cs | 185 +++++++++++------- .../FilterAliasAttribute.cs | 2 +- .../IContextualFeatureVariantAssigner.cs | 2 +- .../IFeatureVariantAssigner.cs | 2 +- .../IFeatureVariantManager.cs | 2 +- .../IFeatureVariantOptionsResolver.cs | 2 +- .../ServiceCollectionExtensions.cs | 3 - ...ntextualTargetingFeatureVariantAssigner.cs | 85 ++++---- .../Targeting/TargetingEvaluator.cs | 24 ++- .../TargetingFeatureVariantAssigner.cs | 2 +- .../FeatureManagement.cs | 92 +++++++++ tests/Tests.FeatureManagement/RandomHelper.cs | 26 +++ .../Tests.FeatureManagement/appsettings.json | 72 ++++++- 21 files changed, 440 insertions(+), 142 deletions(-) create mode 100644 src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs create mode 100644 tests/Tests.FeatureManagement/RandomHelper.cs diff --git a/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs index 3918c752..47ded1fc 100644 --- a/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs +++ b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs @@ -10,6 +10,7 @@ namespace Consoto.Banking.AccountService { + [AssignerAlias("Recurring")] class RecurringAssigner : IFeatureVariantAssigner { public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken _) diff --git a/examples/CustomAssignmentConsoleApp/appsettings.json b/examples/CustomAssignmentConsoleApp/appsettings.json index c9c2d31a..8cbd7ee8 100644 --- a/examples/CustomAssignmentConsoleApp/appsettings.json +++ b/examples/CustomAssignmentConsoleApp/appsettings.json @@ -9,8 +9,8 @@ "ConfigurationReference": "DailyDiscount:Default" }, { - "Name": "Tuesday", - "ConfigurationReference": "DailyDiscount:Tuesday", + "Name": "Special", + "ConfigurationReference": "DailyDiscount:Special", "AssignmentParameters": { "Days": [ "Tuesday" @@ -25,7 +25,7 @@ "Discount": 20, "ProductName": "Bananas" }, - "Tuesday": { + "Special": { "Discount": 30, "ProductName": "Fish" } diff --git a/examples/FeatureFlagDemo/Controllers/HomeController.cs b/examples/FeatureFlagDemo/Controllers/HomeController.cs index 61858ef7..e6f47169 100644 --- a/examples/FeatureFlagDemo/Controllers/HomeController.cs +++ b/examples/FeatureFlagDemo/Controllers/HomeController.cs @@ -15,13 +15,10 @@ namespace FeatureFlagDemo.Controllers public class HomeController : Controller { private readonly IFeatureManager _featureManager; - private readonly IFeatureVariantManager _variantManager; - public HomeController(IFeatureManagerSnapshot featureSnapshot, - IFeatureVariantManager variantManager) + public HomeController(IFeatureManagerSnapshot featureSnapshot) { _featureManager = featureSnapshot; - _variantManager = variantManager; } [FeatureGate(MyFeatureFlags.Home)] diff --git a/examples/TargetingConsoleApp/Program.cs b/examples/TargetingConsoleApp/Program.cs index 8c76b224..464cb417 100644 --- a/examples/TargetingConsoleApp/Program.cs +++ b/examples/TargetingConsoleApp/Program.cs @@ -51,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 @@ -67,19 +68,23 @@ public static async Task Main(string[] args) // // Evaluate feature flag using targeting - bool enabled = await featureManager.IsEnabledAsync(FeatureName, targetingContext, CancellationToken.None); + bool enabled = await featureManager + .IsEnabledAsync( + FeatureFlagName, + targetingContext, + CancellationToken.None); // // Retrieve feature variant using targeting CartOptions cartOptions = await variantManager .GetVariantAsync( - "ShoppingCart", + 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/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs b/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs new file mode 100644 index 00000000..a4ec0a71 --- /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(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/ConfigurationFeatureVariantOptionsResolver.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs index acdbe826..dcb32a0e 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs @@ -20,7 +20,7 @@ public ConfigurationFeatureVariantOptionsResolver(IConfiguration configuration) _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); } - public ValueTask GetOptions(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken) + public ValueTask GetOptionsAsync(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken) { if (variant == null) { diff --git a/src/Microsoft.FeatureManagement/FeatureManagementError.cs b/src/Microsoft.FeatureManagement/FeatureManagementError.cs index 08939cad..682f3352 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementError.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementError.cs @@ -21,12 +21,12 @@ public enum FeatureManagementError /// /// A feature variant assigner that was listed for variant assignment was not found. /// - MissingFeatureAssigner, + MissingFeatureVariantAssigner, /// - /// The feature variant assigner configured for the feature being evaluated is an ambiguous reference to multiple registered feature assigners. + /// The feature variant assigner configured for the feature being evaluated is an ambiguous reference to multiple registered feature variant assigners. /// - AmbiguousFeatureAssigner, + AmbiguousFeatureVariantAssigner, /// /// An assigned feature variant does not have a valid configuration reference. @@ -36,6 +36,16 @@ public enum FeatureManagementError /// /// An invalid configuration was encountered when performing a feature management operation. /// - InvalidConfiguration + InvalidConfiguration, + + /// + /// A feature variant assigner being used in feature evaluation is invalid. + /// + InvalidFeatureVariantAssigner, + + /// + /// A feature that was requested for evaluation was not found. + /// + MissingFeature } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs index bd411174..9322934e 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs @@ -13,11 +13,5 @@ public class FeatureManagementOptions /// If missing feature filters are not ignored an exception will be thrown when attempting to evaluate a feature that depends on a missing feature filter. /// public bool IgnoreMissingFeatureFilters { get; set; } - - /// - /// Controls the behavior of feature assignment when dependent feature assigners are missing. - /// If missing feature assigners are not ignored an exception will be thrown when attempting to assign a variant of a feature that uses the missing assigner. - /// - public bool IgnoreMissingFeatureAssigners { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 39009ac3..88113434 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -90,79 +90,113 @@ private async ValueTask GetVariantAsync(string feature, TContext FeatureVariant variant = null; - FeatureDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureDefinitionAsync(feature, cancellationToken).ConfigureAwait(false); + FeatureDefinition featureDefinition = await _featureDefinitionProvider + .GetFeatureDefinitionAsync(feature, cancellationToken) + .ConfigureAwait(false); - if (featureDefinition != null) + if (featureDefinition == null) { - if (string.IsNullOrEmpty(featureDefinition.Assigner)) - { - throw new FeatureManagementException( - FeatureManagementError.InvalidConfiguration, - $"Invalid feature variant assigner name for the feature {feature}"); - } + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureFilter, + $"The feature declaration for the dynamic feature '{feature}' was not found."); + } + + if (string.IsNullOrEmpty(featureDefinition.Assigner)) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfiguration, + $"Missing feature variant assigner name for the feature {feature}"); + } - IFeatureVariantAssignerMetadata assigner = GetFeatureAssignerMetadata(featureDefinition.Assigner); + if (featureDefinition.Variants == null) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfiguration, + $"No variants are registered for the feature {feature}"); + } - if (assigner == null) - { - string errorMessage = $"The feature assigner '{featureDefinition.Assigner}' specified for feature '{feature}' was not found."; + FeatureVariant defaultVariant = null; - if (!_options.IgnoreMissingFeatureAssigners) + foreach (FeatureVariant v in featureDefinition.Variants) + { + if (v.Default) + { + if (defaultVariant != null) { - throw new FeatureManagementException(FeatureManagementError.MissingFeatureAssigner, errorMessage); + throw new FeatureManagementException( + FeatureManagementError.InvalidConfiguration, + $"Multiple default variants are registered for the feature '{feature}'."); } - else - { - _logger.LogWarning(errorMessage); - return default(T); - } + defaultVariant = v; } - var context = new FeatureVariantAssignmentContext() + if (v.ConfigurationReference == null) { - FeatureDefinition = featureDefinition - }; + throw new FeatureManagementException( + FeatureManagementError.MissingConfigurationReference, + $"The variant '{variant.Name}' for the feature '{feature}' does not have a configuration reference."); + } + } - // - // IContextualFeatureVariantAssigner - if (useAppContext) - { - ContextualFeatureVariantAssignerEvaluator contextualAssigner = GetContextualFeatureVariantAssigner(featureDefinition.Assigner, typeof(TContext)); + if (defaultVariant == null) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfiguration, + $"A default variant cannot be found for the feature '{feature}'."); + } - if (contextualAssigner != null) - { - variant = await contextualAssigner.AssignVariantAsync(context, appContext, cancellationToken).ConfigureAwait(false); - } - } + IFeatureVariantAssignerMetadata assigner = GetFeatureAssignerMetadata(featureDefinition.Assigner); - // - // IFeatureVariantAssigner + 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 + }; + + if (!useAppContext) + { if (assigner is IFeatureVariantAssigner featureVariantAssigner) { variant = await featureVariantAssigner.AssignVariantAsync(context, cancellationToken).ConfigureAwait(false); } - - if (variant == null && - featureDefinition.Variants != null) + else { - variant = featureDefinition.Variants.FirstOrDefault(v => v.Default); + throw new FeatureManagementException( + FeatureManagementError.InvalidFeatureVariantAssigner, + $"The feature variant assigner '{featureDefinition.Assigner}' specified for feature '{feature}' is not capable of evaluating the requested feature without a provided context."); } } - - if (variant == null) + else { - return default(T); + ContextualFeatureVariantAssignerEvaluator contextualAssigner = GetContextualFeatureVariantAssigner( + featureDefinition.Assigner, + typeof(TContext)); + + if (contextualAssigner != null) + { + variant = await contextualAssigner.AssignVariantAsync(context, appContext, cancellationToken).ConfigureAwait(false); + } + else + { + throw new FeatureManagementException( + FeatureManagementError.InvalidFeatureVariantAssigner, + $"The feature variant assigner '{featureDefinition.Assigner}' specified for feature '{feature}' is not capable of evaluating the requested feature with the provided context."); + } } - if (variant.ConfigurationReference == null) + if (variant == null) { - throw new FeatureManagementException( - FeatureManagementError.MissingConfigurationReference, - $"The variant '{variant.Name}' for the feature '{feature}' does not have a configuration reference."); + variant = defaultVariant; } - return await _variantOptionsResolver.GetOptions(featureDefinition, variant, cancellationToken).ConfigureAwait(false); + return await _variantOptionsResolver.GetOptionsAsync(featureDefinition, variant, cancellationToken).ConfigureAwait(false); } private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) @@ -203,7 +237,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo { throw new FeatureManagementException( FeatureManagementError.InvalidConfiguration, - $"Invalid feature filter name for the feature {feature}"); + $"Missing feature filter name for the feature {feature}"); } IFeatureFilterMetadata filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name); @@ -226,7 +260,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo var context = new FeatureFilterEvaluationContext() { - FeatureName = feature, + FeatureName = featureDefinition.Name, Parameters = featureFilterConfiguration.Parameters }; @@ -266,13 +300,22 @@ private async Task IsEnabledAsync(string feature, TContext appCo private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) { + const string filterSuffix = "filter"; + IFeatureFilterMetadata filter = _filterMetadataCache.GetOrAdd( filterName, (_) => { IEnumerable matchingFilters = _featureFilters.Where(f => { - string name = GetMetadataName(f.GetType()); + Type filterType = f.GetType(); + + string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(filterType, typeof(FilterAliasAttribute)))?.Alias; + + if (name == null) + { + name = GetTrimmedName(f.GetType(), filterSuffix); + } return IsMatchingMetadataName(name, filterName); }); @@ -291,20 +334,29 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) private IFeatureVariantAssignerMetadata GetFeatureAssignerMetadata(string assignerName) { + const string assignerSuffix = "assigner"; + IFeatureVariantAssignerMetadata assigner = _assignerMetadataCache.GetOrAdd( assignerName, (_) => { - IEnumerable matchingAssigners = _variantAssigners.Where(f => + IEnumerable matchingAssigners = _variantAssigners.Where(a => { - string name = GetMetadataName(f.GetType()); + Type filterType = a.GetType(); + + string name = ((AssignerAliasAttribute)Attribute.GetCustomAttribute(filterType, typeof(AssignerAliasAttribute)))?.Alias; + + if (name == null) + { + name = GetTrimmedName(a.GetType(), assignerSuffix); + } return IsMatchingMetadataName(name, assignerName); }); if (matchingAssigners.Count() > 1) { - throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureFilter, $"Multiple feature assigners match the configured assigner named '{assignerName}'."); + throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureVariantAssigner, $"Multiple feature variant assigners match the configured assigner named '{assignerName}'."); } return matchingAssigners.FirstOrDefault(); @@ -314,27 +366,22 @@ private IFeatureVariantAssignerMetadata GetFeatureAssignerMetadata(string assign return assigner; } - private static string GetMetadataName(Type type) + /// + /// Get's a trimmed name for the provided type + /// + /// The type who's name to use. + /// The possible suffix that may need trimming. + /// + private static string GetTrimmedName(Type type, string suffix) { - const string filterSuffix = "filter"; - const string assignerSuffix = "assigner"; - Debug.Assert(type != null); + Debug.Assert(suffix != null); - string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(type, typeof(FilterAliasAttribute)))?.Alias; + string name = type.Name; - if (name == null) + if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { - name = type.Name; - - if (name.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase)) - { - name = name.Substring(0, name.Length - filterSuffix.Length); - } - else if (name.EndsWith(assignerSuffix, StringComparison.OrdinalIgnoreCase)) - { - name = name.Substring(0, name.Length - assignerSuffix.Length); - } + name = name.Substring(0, name.Length - suffix.Length); } return name; @@ -384,7 +431,7 @@ private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filte return filter; } - private ContextualFeatureVariantAssignerEvaluator GetContextualFeatureVariantAssigner(string assignerName, Type appContextType) + private ContextualFeatureVariantAssignerEvaluator GetContextualFeatureVariantAssigner(string assignerName, Type appContextType) { if (appContextType == null) { diff --git a/src/Microsoft.FeatureManagement/FilterAliasAttribute.cs b/src/Microsoft.FeatureManagement/FilterAliasAttribute.cs index ca14477a..42f5571a 100644 --- a/src/Microsoft.FeatureManagement/FilterAliasAttribute.cs +++ b/src/Microsoft.FeatureManagement/FilterAliasAttribute.cs @@ -6,7 +6,7 @@ namespace Microsoft.FeatureManagement { /// - /// Allows the name of an or to be customized to relate to the name specified in configuration. + /// Allows the name of an to be customized to relate to the name specified in configuration. /// public class FilterAliasAttribute : Attribute { diff --git a/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs index bc0fb2f0..173def4f 100644 --- a/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs @@ -15,7 +15,7 @@ public interface IContextualFeatureVariantAssigner : IFeatureVariantAs /// /// Assign a variant of a feature to be used based off of customized criteria. /// - /// Information provided by the system to be used during the assignment process. + /// 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. diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs index da056e13..ee6e1627 100644 --- a/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs @@ -14,7 +14,7 @@ public interface IFeatureVariantAssigner : IFeatureVariantAssignerMetadata /// /// Assign a variant of a feature to be used based off of customized criteria. /// - /// Information provided by the system to be used during the assignment process. + /// 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/IFeatureVariantManager.cs b/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs index 4b3645cd..cc69ffc8 100644 --- a/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs +++ b/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs @@ -24,7 +24,7 @@ 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 type of the context being provided to the feature variant manger for use during the process of choosing which variant to use. + /// 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. diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs b/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs index a48c2fbe..be0e2cd0 100644 --- a/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs +++ b/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs @@ -19,6 +19,6 @@ public interface IFeatureVariantOptionsResolver /// The chosen variant of the feature. /// The cancellation token to cancel the operation. /// Typed options for a given feature definition and chosen variant. - ValueTask GetOptions(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken); + ValueTask GetOptionsAsync(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken); } } diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index 60fc37f5..233413d1 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.FeatureManagement.FeatureFilters; using System; namespace Microsoft.FeatureManagement @@ -29,8 +28,6 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec services.TryAddSingleton(); - services.TryAddSingleton, ContextualTargetingFeatureVariantAssigner>(); - services.AddSingleton(); services.TryAddSingleton(sp => sp.GetRequiredService()); diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs index c0fd9ac5..5cafe2e7 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -15,7 +15,7 @@ namespace Microsoft.FeatureManagement /// /// A feature variant assigner that can be used to assign a variant based on targeted audiences. /// - [FilterAlias(Alias)] + [AssignerAlias(Alias)] public class ContextualTargetingFeatureVariantAssigner : IContextualFeatureVariantAssigner { private const string Alias = "Microsoft.Targeting"; @@ -39,11 +39,26 @@ public ContextualTargetingFeatureVariantAssigner(IOptions 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) { - return new ValueTask((FeatureVariant)null); + throw new ArgumentNullException(nameof(variantAssignmentContext.FeatureDefinition)); + } + + if (featureDefinition.Variants == null) + { + throw new ArgumentNullException(nameof(featureDefinition.Variants)); } FeatureVariant variant = null; @@ -54,56 +69,54 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont var cumulativeGroups = new Dictionary(); - if (featureDefinition.Variants != null) + foreach (FeatureVariant v in featureDefinition.Variants) { - foreach (FeatureVariant v in featureDefinition.Variants) + if (defaultVariant == null && v.Default) { - if (defaultVariant == null && v.Default) - { - defaultVariant = v; - } + defaultVariant = v; + } - TargetingFilterSettings targetingSettings = v.AssignmentParameters.Get(); + TargetingFilterSettings targetingSettings = v.AssignmentParameters.Get(); - if (targetingSettings == null) + if (targetingSettings == null) + { + if (v.Default) { - if (v.Default) - { - // - // Valid to omit audience for default variant - continue; - } - else - { - targetingSettings = new TargetingFilterSettings(); - } + // + // Valid to omit audience for default variant + continue; } - - if (!TargetingEvaluator.TryValidateSettings(targetingSettings, out string paramName, out string reason)) + else { - throw new ArgumentException(reason, paramName); + targetingSettings = new TargetingFilterSettings(); } + } - AccumulateAudience(targetingSettings.Audience, ref cumulativePercentage, ref cumulativeGroups); + if (!TargetingEvaluator.TryValidateSettings(targetingSettings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); + } - if (TargetingEvaluator.IsTargeted(targetingSettings, targetingContext, _options.IgnoreCase, featureDefinition.Name)) - { - variant = v; + AccumulateAudience(targetingSettings.Audience, cumulativeGroups, ref cumulativePercentage); - break; - } - } - } + if (TargetingEvaluator.IsTargeted(targetingSettings, targetingContext, _options.IgnoreCase, featureDefinition.Name)) + { + variant = v; - if (variant == null) - { - variant = defaultVariant; + break; + } } return new ValueTask(variant); } - private static void AccumulateAudience(Audience audience, ref double cumulativePercentage, ref Dictionary cumulativeGroups) + /// + /// 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 cumulativePercentage) { if (audience.Groups != null) { @@ -123,6 +136,8 @@ private static void AccumulateAudience(Audience audience, ref double cumulativeP } cumulativePercentage = cumulativePercentage + audience.DefaultRolloutPercentage; + + audience.DefaultRolloutPercentage = cumulativePercentage; } } } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs index 2bfbdb2b..7952b969 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -3,6 +3,7 @@ // using Microsoft.FeatureManagement.FeatureFilters; using System; +using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -11,7 +12,10 @@ namespace Microsoft.FeatureManagement.Targeting { static class TargetingEvaluator { - private static StringComparison ComparisonType(bool ignoreCase) => ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private static StringComparison GetComparisonType(bool ignoreCase) => + ignoreCase ? + StringComparison.OrdinalIgnoreCase : + StringComparison.Ordinal; public static bool IsTargeted(TargetingFilterSettings settings, ITargetingContext targetingContext, bool ignoreCase, string hint) { @@ -34,23 +38,31 @@ public static bool IsTargeted(TargetingFilterSettings settings, ITargetingContex // 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(ignoreCase)))) + 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) { - foreach (string group in targetingContext.Groups) + 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, ComparisonType(ignoreCase))); + GroupRollout groupRollout = settings.Audience.Groups.FirstOrDefault(g => g.Name.Equals(group, GetComparisonType(ignoreCase))); if (groupRollout != null) { - string audienceContextId = $"{targetingContext.UserId}\n{hint}\n{group}"; + string audienceContextId = $"{userId}\n{hint}\n{group}"; if (IsTargeted(audienceContextId, groupRollout.RolloutPercentage)) { @@ -62,7 +74,7 @@ public static bool IsTargeted(TargetingFilterSettings settings, ITargetingContex // // Check if the user is being targeted by a default rollout percentage - string defaultContextId = $"{targetingContext.UserId}\n{hint}"; + string defaultContextId = $"{userId}\n{hint}"; return IsTargeted(defaultContextId, settings.Audience.DefaultRolloutPercentage); } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs index 4918b11f..f041c299 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs @@ -13,7 +13,7 @@ namespace Microsoft.FeatureManagement /// /// A feature variant assigner that can be used to assign a variant based on targeted audiences. /// - [FilterAlias(Alias)] + [AssignerAlias(Alias)] public class TargetingFeatureVariantAssigner : IFeatureVariantAssigner { private const string Alias = "Microsoft.Targeting"; diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 29bab408..17ea8e62 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -392,6 +392,98 @@ public async Task VariantTargeting() 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(); + + var occurences = new Dictionary(); + + // + // Test default rollout percentage accumulation + for (int i = 0; i < 3000; 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]++; + } + } + + int total = occurences.Values.Aggregate((cur, t) => cur + t); + + foreach (int count in occurences.Values) + { + double percentage = (double)count / total; + + Assert.True(percentage > .25); + Assert.True(percentage < .4); + } + occurences.Clear(); + + + // + // Test Group rollout accumulation + for (int i = 0; i < 3000; 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]++; + } + } + + total = occurences.Values.Aggregate((cur, t) => cur + t); + + foreach (int count in occurences.Values) + { + double percentage = (double)count / total; + + Assert.True(percentage > .25); + Assert.True(percentage < .4); + } + } + [Fact] public async Task TargetingAccessor() { 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/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index a07e0df4..ed155d99 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -120,8 +120,78 @@ } } ] + }, + "AccumulatedTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 33 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Ref3", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 33 + } + } + } + ] + }, + "AccumulatedGroupsTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "r", + "RolloutPercentage": 33 + } + ], + "DefaultRolloutPercentage": 0 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Ref3", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "r", + "RolloutPercentage": 33 + } + ], + "DefaultRolloutPercentage": 0 + } + } + } + ] } }, "Ref1": "abc", - "Ref2": "def" + "Ref2": "def", + "Ref3": "ghi", + "Ref4": "jkl" } From 2f524033c4ade323800bd05032ac4aabccfb4859 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Fri, 17 Sep 2021 15:14:23 -0700 Subject: [PATCH 05/10] Since filter aliases are case insensitive, update filter cache to reflect this. --- src/Microsoft.FeatureManagement/FeatureManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 88113434..3b5f9818 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -46,10 +46,10 @@ public FeatureManager( _featureDefinitionProvider = featureDefinitionProvider ?? throw new ArgumentNullException(nameof(featureDefinitionProvider)); _sessionManagers = sessionManagers ?? throw new ArgumentNullException(nameof(sessionManagers)); _logger = loggerFactory.CreateLogger(); - _filterMetadataCache = new ConcurrentDictionary(); - _contextualFeatureFilterCache = new ConcurrentDictionary(); - _assignerMetadataCache = new ConcurrentDictionary(); - _contextualFeatureVariantAssignerCache = 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)); } From 356077db354ceebb0d63e8efa247053b44baba3b Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 20 Oct 2021 10:37:42 -0700 Subject: [PATCH 06/10] * Fixed issue with filter + assigner suffix references. * Clarified error types. * Updated/added tests --- .../AssignerAliasAttribute.cs | 2 +- .../FeatureManagementError.cs | 22 +++-- .../FeatureManager.cs | 40 +++++---- ...ntextualTargetingFeatureVariantAssigner.cs | 23 ++--- .../Targeting/TargetingEvaluator.cs | 9 ++ .../FeatureManagement.cs | 88 +++++++++++++++---- .../Tests.FeatureManagement/appsettings.json | 43 ++++++--- 7 files changed, 158 insertions(+), 69 deletions(-) diff --git a/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs b/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs index a4ec0a71..f7d8f52b 100644 --- a/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs +++ b/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs @@ -18,7 +18,7 @@ public AssignerAliasAttribute(string alias) { if (string.IsNullOrEmpty(alias)) { - throw new ArgumentNullException(alias); + throw new ArgumentNullException(nameof(alias)); } Alias = alias; diff --git a/src/Microsoft.FeatureManagement/FeatureManagementError.cs b/src/Microsoft.FeatureManagement/FeatureManagementError.cs index 682f3352..08542dde 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementError.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementError.cs @@ -33,11 +33,6 @@ public enum FeatureManagementError /// MissingConfigurationReference, - /// - /// An invalid configuration was encountered when performing a feature management operation. - /// - InvalidConfiguration, - /// /// A feature variant assigner being used in feature evaluation is invalid. /// @@ -46,6 +41,21 @@ public enum FeatureManagementError /// /// A feature that was requested for evaluation was not found. /// - MissingFeature + MissingFeature, + + /// + /// A dynamic feature does not have any feature variants registered. + /// + MissingVariants, + + /// + /// A dynamic feature has multiple default feature variants configured. + /// + AmbiguousDefaultVariant, + + /// + /// A dynamic feature does not have a default feature variant configured. + /// + MissingDefaultVariant } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 3b5f9818..e7fc054e 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -97,21 +97,21 @@ private async ValueTask GetVariantAsync(string feature, TContext if (featureDefinition == null) { throw new FeatureManagementException( - FeatureManagementError.MissingFeatureFilter, + FeatureManagementError.MissingFeature, $"The feature declaration for the dynamic feature '{feature}' was not found."); } if (string.IsNullOrEmpty(featureDefinition.Assigner)) { throw new FeatureManagementException( - FeatureManagementError.InvalidConfiguration, + FeatureManagementError.MissingFeatureVariantAssigner, $"Missing feature variant assigner name for the feature {feature}"); } if (featureDefinition.Variants == null) { throw new FeatureManagementException( - FeatureManagementError.InvalidConfiguration, + FeatureManagementError.MissingVariants, $"No variants are registered for the feature {feature}"); } @@ -124,7 +124,7 @@ private async ValueTask GetVariantAsync(string feature, TContext if (defaultVariant != null) { throw new FeatureManagementException( - FeatureManagementError.InvalidConfiguration, + FeatureManagementError.AmbiguousDefaultVariant, $"Multiple default variants are registered for the feature '{feature}'."); } @@ -142,11 +142,11 @@ private async ValueTask GetVariantAsync(string feature, TContext if (defaultVariant == null) { throw new FeatureManagementException( - FeatureManagementError.InvalidConfiguration, + FeatureManagementError.MissingDefaultVariant, $"A default variant cannot be found for the feature '{feature}'."); } - IFeatureVariantAssignerMetadata assigner = GetFeatureAssignerMetadata(featureDefinition.Assigner); + IFeatureVariantAssignerMetadata assigner = GetFeatureVariantAssignerMetadata(featureDefinition.Assigner); if (assigner == null) { @@ -236,7 +236,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo if (string.IsNullOrEmpty(featureFilterConfiguration.Name)) { throw new FeatureManagementException( - FeatureManagementError.InvalidConfiguration, + FeatureManagementError.MissingFeatureFilter, $"Missing feature filter name for the feature {feature}"); } @@ -314,10 +314,12 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) if (name == null) { - name = GetTrimmedName(f.GetType(), filterSuffix); + name = filterType.Name; } - return IsMatchingMetadataName(name, filterName); + return filterName.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase) ? + IsMatchingMetadataName(name, filterName) : + IsMatchingMetadataName(GetTrimmedName(name, filterSuffix), filterName); }); if (matchingFilters.Count() > 1) @@ -332,7 +334,7 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) return filter; } - private IFeatureVariantAssignerMetadata GetFeatureAssignerMetadata(string assignerName) + private IFeatureVariantAssignerMetadata GetFeatureVariantAssignerMetadata(string assignerName) { const string assignerSuffix = "assigner"; @@ -348,10 +350,12 @@ private IFeatureVariantAssignerMetadata GetFeatureAssignerMetadata(string assign if (name == null) { - name = GetTrimmedName(a.GetType(), assignerSuffix); + name = filterType.Name; } - return IsMatchingMetadataName(name, assignerName); + return assignerName.EndsWith(assignerSuffix, StringComparison.OrdinalIgnoreCase) ? + IsMatchingMetadataName(name, assignerName) : + IsMatchingMetadataName(GetTrimmedName(name, assignerSuffix), assignerName); }); if (matchingAssigners.Count() > 1) @@ -367,18 +371,16 @@ private IFeatureVariantAssignerMetadata GetFeatureAssignerMetadata(string assign } /// - /// Get's a trimmed name for the provided type + /// Trims a suffix from a name if necessary /// - /// The type who's name to use. + /// The name to trim. /// The possible suffix that may need trimming. /// - private static string GetTrimmedName(Type type, string suffix) + private static string GetTrimmedName(string name, string suffix) { - Debug.Assert(type != null); + Debug.Assert(name != null); Debug.Assert(suffix != null); - string name = type.Name; - if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { name = name.Substring(0, name.Length - suffix.Length); @@ -442,7 +444,7 @@ private ContextualFeatureVariantAssignerEvaluator GetContextualFeatureVariantAss $"{assignerName}{Environment.NewLine}{appContextType.FullName}", (_) => { - IFeatureVariantAssignerMetadata metadata = GetFeatureAssignerMetadata(assignerName); + IFeatureVariantAssignerMetadata metadata = GetFeatureVariantAssignerMetadata(assignerName); return ContextualFeatureVariantAssignerEvaluator.IsContextualVariantAssigner(metadata, appContextType) ? new ContextualFeatureVariantAssignerEvaluator(metadata, appContextType) : diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs index 5cafe2e7..066a53db 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -63,19 +63,14 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont FeatureVariant variant = null; - FeatureVariant defaultVariant = null; - double cumulativePercentage = 0; - var cumulativeGroups = new Dictionary(); + var cumulativeGroups = new Dictionary( + _options.IgnoreCase ? StringComparer.OrdinalIgnoreCase : + StringComparer.Ordinal); foreach (FeatureVariant v in featureDefinition.Variants) { - if (defaultVariant == null && v.Default) - { - defaultVariant = v; - } - TargetingFilterSettings targetingSettings = v.AssignmentParameters.Get(); if (targetingSettings == null) @@ -86,10 +81,6 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont // Valid to omit audience for default variant continue; } - else - { - targetingSettings = new TargetingFilterSettings(); - } } if (!TargetingEvaluator.TryValidateSettings(targetingSettings, out string paramName, out string reason)) @@ -114,9 +105,9 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont /// 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 default rollout percentage /// The current cumulative rollout percentage for each group - private static void AccumulateAudience(Audience audience, Dictionary cumulativeGroups, ref double cumulativePercentage) + private static void AccumulateAudience(Audience audience, Dictionary cumulativeGroups, ref double cumulativeDefaultPercentage) { if (audience.Groups != null) { @@ -135,9 +126,9 @@ private static void AccumulateAudience(Audience audience, Dictionary() + .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] public async Task Integrates() { @@ -414,11 +462,15 @@ public async Task AccumulatesAudience() 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 < 3000; i++) + for (int i = 0; i < totalAssignments; i++) { string result = await variantManager.GetVariantAsync( "AccumulatedTargetingFeature", @@ -438,21 +490,24 @@ public async Task AccumulatesAudience() } } - int total = occurences.Values.Aggregate((cur, t) => cur + t); - - foreach (int count in occurences.Values) + foreach (KeyValuePair occurence in occurences) { - double percentage = (double)count / total; + 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 > .25); - Assert.True(percentage < .4); + Assert.True(percentage < expectedPercentage + tolerance); } - occurences.Clear(); + occurences.Clear(); // // Test Group rollout accumulation - for (int i = 0; i < 3000; i++) + for (int i = 0; i < totalAssignments; i++) { string result = await variantManager.GetVariantAsync( "AccumulatedGroupsTargetingFeature", @@ -473,14 +528,17 @@ public async Task AccumulatesAudience() } } - total = occurences.Values.Aggregate((cur, t) => cur + t); - - foreach (int count in occurences.Values) + foreach (KeyValuePair occurence in occurences) { - double percentage = (double)count / total; + 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 > .25); - Assert.True(percentage < .4); + Assert.True(percentage < expectedPercentage + tolerance); } } diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index ed155d99..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": [ { @@ -127,23 +145,23 @@ { "Default": true, "Name": "V1", - "ConfigurationReference": "Ref1" + "ConfigurationReference": "Percentage15" }, { "Name": "V2", - "ConfigurationReference": "Ref2", + "ConfigurationReference": "Percentage35", "AssignmentParameters": { "Audience": { - "DefaultRolloutPercentage": 33 + "DefaultRolloutPercentage": 35 } } }, { "Name": "V3", - "ConfigurationReference": "Ref3", + "ConfigurationReference": "Percentage50", "AssignmentParameters": { "Audience": { - "DefaultRolloutPercentage": 33 + "DefaultRolloutPercentage": 50 } } } @@ -155,17 +173,17 @@ { "Default": true, "Name": "V1", - "ConfigurationReference": "Ref1" + "ConfigurationReference": "Percentage15" }, { "Name": "V2", - "ConfigurationReference": "Ref2", + "ConfigurationReference": "Percentage35", "AssignmentParameters": { "Audience": { "Groups": [ { "Name": "r", - "RolloutPercentage": 33 + "RolloutPercentage": 35 } ], "DefaultRolloutPercentage": 0 @@ -174,13 +192,13 @@ }, { "Name": "V3", - "ConfigurationReference": "Ref3", + "ConfigurationReference": "Percentage50", "AssignmentParameters": { "Audience": { "Groups": [ { "Name": "r", - "RolloutPercentage": 33 + "RolloutPercentage": 50 } ], "DefaultRolloutPercentage": 0 @@ -192,6 +210,7 @@ }, "Ref1": "abc", "Ref2": "def", - "Ref3": "ghi", - "Ref4": "jkl" + "Percentage15": 15, + "Percentage35": 35, + "Percentage50": 50 } From 5ed4021ec6b385f0cea2286bcee5dc2b3ce412a9 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Tue, 2 Nov 2021 15:31:12 -0700 Subject: [PATCH 07/10] Share suffix matching logic. Corrected variable name. --- .../FeatureManager.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index e7fc054e..046852ee 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -317,9 +317,7 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) name = filterType.Name; } - return filterName.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase) ? - IsMatchingMetadataName(name, filterName) : - IsMatchingMetadataName(GetTrimmedName(name, filterSuffix), filterName); + return IsMatchingMetadataName(name, filterName, filterSuffix); }); if (matchingFilters.Count() > 1) @@ -344,18 +342,16 @@ private IFeatureVariantAssignerMetadata GetFeatureVariantAssignerMetadata(string IEnumerable matchingAssigners = _variantAssigners.Where(a => { - Type filterType = a.GetType(); + Type assignerType = a.GetType(); - string name = ((AssignerAliasAttribute)Attribute.GetCustomAttribute(filterType, typeof(AssignerAliasAttribute)))?.Alias; + string name = ((AssignerAliasAttribute)Attribute.GetCustomAttribute(assignerType, typeof(AssignerAliasAttribute)))?.Alias; if (name == null) { - name = filterType.Name; + name = assignerType.Name; } - return assignerName.EndsWith(assignerSuffix, StringComparison.OrdinalIgnoreCase) ? - IsMatchingMetadataName(name, assignerName) : - IsMatchingMetadataName(GetTrimmedName(name, assignerSuffix), assignerName); + return IsMatchingMetadataName(name, assignerName, assignerSuffix); }); if (matchingAssigners.Count() > 1) @@ -389,6 +385,16 @@ private static string GetTrimmedName(string name, string suffix) return name; } + private static bool IsMatchingMetadataName(string metadataName, string desiredName, string suffix) + { + // + // Support matching with suffix, i.e. "CustomFilter" + // Or without, i.e. "Custom" + return desiredName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ? + IsMatchingMetadataName(metadataName, desiredName) : + IsMatchingMetadataName(GetTrimmedName(metadataName, suffix), desiredName); + } + private static bool IsMatchingMetadataName(string metadataName, string desiredName) { // From 0044eebd7bd76bbf9b1f817cca49c17891473f86 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 3 Nov 2021 11:10:25 -0700 Subject: [PATCH 08/10] Removed unnecessary helper methods. Updated error codes for consistency. --- .../FeatureManagementError.cs | 6 +-- .../FeatureManager.cs | 40 +++++-------------- 2 files changed, 13 insertions(+), 33 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManagementError.cs b/src/Microsoft.FeatureManagement/FeatureManagementError.cs index 08542dde..8098d639 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementError.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementError.cs @@ -46,16 +46,16 @@ public enum FeatureManagementError /// /// A dynamic feature does not have any feature variants registered. /// - MissingVariants, + MissingFeatureVariant, /// /// A dynamic feature has multiple default feature variants configured. /// - AmbiguousDefaultVariant, + AmbiguousDefaultFeatureVariant, /// /// A dynamic feature does not have a default feature variant configured. /// - MissingDefaultVariant + MissingDefaultFeatureVariant } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 046852ee..dd749481 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -111,7 +111,7 @@ private async ValueTask GetVariantAsync(string feature, TContext if (featureDefinition.Variants == null) { throw new FeatureManagementException( - FeatureManagementError.MissingVariants, + FeatureManagementError.MissingFeatureVariant, $"No variants are registered for the feature {feature}"); } @@ -124,7 +124,7 @@ private async ValueTask GetVariantAsync(string feature, TContext if (defaultVariant != null) { throw new FeatureManagementException( - FeatureManagementError.AmbiguousDefaultVariant, + FeatureManagementError.AmbiguousDefaultFeatureVariant, $"Multiple default variants are registered for the feature '{feature}'."); } @@ -142,7 +142,7 @@ private async ValueTask GetVariantAsync(string feature, TContext if (defaultVariant == null) { throw new FeatureManagementException( - FeatureManagementError.MissingDefaultVariant, + FeatureManagementError.MissingDefaultFeatureVariant, $"A default variant cannot be found for the feature '{feature}'."); } @@ -366,37 +366,17 @@ private IFeatureVariantAssignerMetadata GetFeatureVariantAssignerMetadata(string return assigner; } - /// - /// Trims a suffix from a name if necessary - /// - /// The name to trim. - /// The possible suffix that may need trimming. - /// - private static string GetTrimmedName(string name, string suffix) - { - Debug.Assert(name != null); - Debug.Assert(suffix != null); - - if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) - { - name = name.Substring(0, name.Length - suffix.Length); - } - - return name; - } - private static bool IsMatchingMetadataName(string metadataName, string desiredName, string suffix) { // - // Support matching with suffix, i.e. "CustomFilter" - // Or without, i.e. "Custom" - return desiredName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ? - IsMatchingMetadataName(metadataName, desiredName) : - IsMatchingMetadataName(GetTrimmedName(metadataName, suffix), desiredName); - } + // 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); + } - private static bool IsMatchingMetadataName(string metadataName, string desiredName) - { // // 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' From d064c971439d0b8522d78f026ae10e88d5cf0f77 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 10 Nov 2021 16:42:32 -0800 Subject: [PATCH 09/10] Clarify filter selection logic in feature evaluation and add checks for impossible contextual evaluations. --- .../FeatureManagementError.cs | 5 ++ .../FeatureManager.cs | 86 ++++++++++--------- 2 files changed, 50 insertions(+), 41 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManagementError.cs b/src/Microsoft.FeatureManagement/FeatureManagementError.cs index 8098d639..60f8a81d 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementError.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementError.cs @@ -18,6 +18,11 @@ public enum FeatureManagementError /// 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. /// diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index dd749481..60c9855a 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -160,35 +160,28 @@ private async ValueTask GetVariantAsync(string feature, TContext FeatureDefinition = featureDefinition }; - if (!useAppContext) + // + // IFeatureVariantAssigner + if (assigner is IFeatureVariantAssigner featureVariantAssigner) { - if (assigner is IFeatureVariantAssigner featureVariantAssigner) - { - variant = await featureVariantAssigner.AssignVariantAsync(context, cancellationToken).ConfigureAwait(false); - } - else - { - throw new FeatureManagementException( - FeatureManagementError.InvalidFeatureVariantAssigner, - $"The feature variant assigner '{featureDefinition.Assigner}' specified for feature '{feature}' is not capable of evaluating the requested feature without a provided context."); - } + 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 { - ContextualFeatureVariantAssignerEvaluator contextualAssigner = GetContextualFeatureVariantAssigner( - featureDefinition.Assigner, - typeof(TContext)); - - if (contextualAssigner != null) - { - variant = await contextualAssigner.AssignVariantAsync(context, appContext, cancellationToken).ConfigureAwait(false); - } - else - { - throw new FeatureManagementException( - FeatureManagementError.InvalidFeatureVariantAssigner, - $"The feature variant assigner '{featureDefinition.Assigner}' specified for feature '{feature}' is not capable of evaluating the requested feature with the provided context."); - } + 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) @@ -265,26 +258,37 @@ private async Task IsEnabledAsync(string feature, TContext appCo }; // - // 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."); } } } @@ -397,14 +401,14 @@ private static bool IsMatchingMetadataName(string metadataName, string desiredNa } } - private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filterName, Type appContextType) + 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}", (_) => { @@ -416,17 +420,17 @@ private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filte } ); - return filter; + return filter != null; } - private ContextualFeatureVariantAssignerEvaluator GetContextualFeatureVariantAssigner(string assignerName, Type appContextType) + private bool TryGetContextualFeatureVariantAssigner(string assignerName, Type appContextType, out ContextualFeatureVariantAssignerEvaluator assigner) { if (appContextType == null) { throw new ArgumentNullException(nameof(appContextType)); } - ContextualFeatureVariantAssignerEvaluator assigner = _contextualFeatureVariantAssignerCache.GetOrAdd( + assigner = _contextualFeatureVariantAssignerCache.GetOrAdd( $"{assignerName}{Environment.NewLine}{appContextType.FullName}", (_) => { @@ -438,7 +442,7 @@ private ContextualFeatureVariantAssignerEvaluator GetContextualFeatureVariantAss } ); - return assigner; + return assigner != null; } } } From 10cd77115895aa1a17e9fd40738b2e09d45fb00c Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Fri, 12 Nov 2021 15:06:42 -0800 Subject: [PATCH 10/10] Updated argument validation messages. Updated exception used for null child property. --- .../ContextualTargetingFeatureVariantAssigner.cs | 8 ++++++-- .../Targeting/TargetingEvaluator.cs | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs index 066a53db..73807e9e 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -53,12 +53,16 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont if (featureDefinition == null) { - throw new ArgumentNullException(nameof(variantAssignmentContext.FeatureDefinition)); + throw new ArgumentException( + $"{nameof(variantAssignmentContext)}.{nameof(variantAssignmentContext.FeatureDefinition)} cannot be null.", + nameof(variantAssignmentContext)); } if (featureDefinition.Variants == null) { - throw new ArgumentNullException(nameof(featureDefinition.Variants)); + throw new ArgumentException( + $"{nameof(variantAssignmentContext)}.{nameof(variantAssignmentContext.FeatureDefinition)}.{nameof(featureDefinition.Variants)} cannot be null.", + nameof(variantAssignmentContext)); } FeatureVariant variant = null; diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs index 94edbf90..3e33b38b 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -98,7 +98,7 @@ public static bool TryValidateSettings(TargetingFilterSettings targetingSettings if (targetingSettings == null) { - paramName = nameof(FeatureFilterConfiguration.Parameters); + paramName = nameof(targetingSettings); reason = RequiredParameter;