From 99a3bdea379e72c0f997bac4f8a5d236f50cb121 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Tue, 27 Jul 2021 16:57:12 -0700 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] * 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/24] 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/24] * 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/24] 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/24] 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/24] 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/24] 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; From fd51d970ff79586b7638a9d4356c9b3c5d7bff1f Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Mon, 10 Jan 2022 11:35:20 -0800 Subject: [PATCH 11/24] Update to dynamic features. (#157) * Update to dynamic features. * Update types to respect dynamic feature name. E.g. IFeatureManager -> IDynamicFeatureManager. * Updated property/method summaries to distinguish dynamic features and feature flags. * Added IDynamicFeatureManagerSnapshot * Separate feature flags and dynamic features in configuration schema * Backward compatability support for old configuration schema. * Breaking: Update custom feature definition provider to return feature flags and dynamic features. * Added DynamicFeatureDefinition class * renamed FeatureDefinition to FeatureFlagDefinition. * Breaking: IFeatureManager.GetFeatureNamesAsync renamed to IFeatureManager.GetFeatureFlagNamesAsync * Corrected namespace for built-in feature variant assigners. * Microsoft.FeatureManagement -> Microsoft.FeatureManagement.Assigners * Added test for custom feature definition provider. * Added test for backcompat support for v1 configuration schema. * * Separated feature flag implementation from dynamic feature impelementation in FeatureManager, ConfigurationFeatureDefinitionProvider, and FeatureManagerSnapshot.. * Separated IConfigurationDefinitionProvider into IFeatureFlagDefinitionProvider and IDynamicFeatureDefinitionProvider * Added missing default cancellation tokens. * Use shared helper for filter/assigner reference matching. * Add copyright header. --- .../CustomAssignmentConsoleApp/Program.cs | 4 +- .../RecurringAssigner.cs | 2 +- examples/FeatureFlagDemo/Startup.cs | 1 + .../Views/Shared/_Layout.cshtml | 4 +- examples/FeatureFlagDemo/appsettings.json | 170 ++++---- examples/TargetingConsoleApp/Program.cs | 5 +- ...urationDynamicFeatureDefinitionProvider.cs | 144 +++++++ ...igurationFeatureFlagDefinitionProvider.cs} | 103 +++-- ...figurationFeatureVariantOptionsResolver.cs | 2 +- ...inition.cs => DynamicFeatureDefinition.cs} | 13 +- .../DynamicFeatureManager.cs | 225 +++++++++++ .../DynamicFeatureManagerSnapshot.cs | 87 ++++ .../FeatureFlagDefinition.cs | 24 ++ .../FeatureManager.cs | 236 +---------- .../FeatureManagerSnapshot.cs | 12 +- .../FeatureVariantAssignmentContext.cs | 4 +- .../IContextualFeatureVariantAssigner.cs | 12 +- .../IDynamicFeatureDefinitionProvider.cs | 30 ++ .../IDynamicFeatureManager.cs | 42 ++ .../IDynamicFeatureManagerSnapshot.cs | 12 + .../IFeatureDefinitionProvider.cs | 30 -- .../IFeatureFlagDefinitionProvider.cs | 30 ++ .../IFeatureManager.cs | 26 +- .../IFeatureVariantAssigner.cs | 10 +- .../IFeatureVariantAssignerMetadata.cs | 2 +- .../IFeatureVariantManager.cs | 34 -- .../IFeatureVariantOptionsResolver.cs | 10 +- src/Microsoft.FeatureManagement/NameHelper.cs | 59 +++ .../ServiceCollectionExtensions.cs | 18 +- ...ntextualTargetingFeatureVariantAssigner.cs | 4 +- .../TargetingFeatureVariantAssigner.cs | 2 +- .../FeatureManagement.cs | 170 +++++++- .../InMemoryFeatureDefinitionProvider.cs | 35 +- .../Tests.FeatureManagement.csproj | 5 + .../Tests.FeatureManagement/appsettings.json | 371 +++++++++--------- .../appsettings.v1.json | 16 + 36 files changed, 1248 insertions(+), 706 deletions(-) create mode 100644 src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs rename src/Microsoft.FeatureManagement/{ConfigurationFeatureDefinitionProvider.cs => ConfigurationFeatureFlagDefinitionProvider.cs} (59%) rename src/Microsoft.FeatureManagement/{FeatureDefinition.cs => DynamicFeatureDefinition.cs} (61%) create mode 100644 src/Microsoft.FeatureManagement/DynamicFeatureManager.cs create mode 100644 src/Microsoft.FeatureManagement/DynamicFeatureManagerSnapshot.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureFlagDefinition.cs create mode 100644 src/Microsoft.FeatureManagement/IDynamicFeatureDefinitionProvider.cs create mode 100644 src/Microsoft.FeatureManagement/IDynamicFeatureManager.cs create mode 100644 src/Microsoft.FeatureManagement/IDynamicFeatureManagerSnapshot.cs delete mode 100644 src/Microsoft.FeatureManagement/IFeatureDefinitionProvider.cs create mode 100644 src/Microsoft.FeatureManagement/IFeatureFlagDefinitionProvider.cs delete mode 100644 src/Microsoft.FeatureManagement/IFeatureVariantManager.cs create mode 100644 src/Microsoft.FeatureManagement/NameHelper.cs create mode 100644 tests/Tests.FeatureManagement/appsettings.v1.json diff --git a/examples/CustomAssignmentConsoleApp/Program.cs b/examples/CustomAssignmentConsoleApp/Program.cs index c1f91629..a9ec26d4 100644 --- a/examples/CustomAssignmentConsoleApp/Program.cs +++ b/examples/CustomAssignmentConsoleApp/Program.cs @@ -35,9 +35,9 @@ public static async Task Main(string[] args) // Get the feature manager from application services using (ServiceProvider serviceProvider = services.BuildServiceProvider()) { - IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + IDynamicFeatureManager dynamicFeatureManager = serviceProvider.GetRequiredService(); - DailyDiscountOptions discountOptions = await variantManager + DailyDiscountOptions discountOptions = await dynamicFeatureManager .GetVariantAsync("DailyDiscount", CancellationToken.None); // diff --git a/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs index 47ded1fc..3a6845df 100644 --- a/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs +++ b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs @@ -15,7 +15,7 @@ class RecurringAssigner : IFeatureVariantAssigner { public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken _) { - FeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; + DynamicFeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; FeatureVariant chosenVariant = null; diff --git a/examples/FeatureFlagDemo/Startup.cs b/examples/FeatureFlagDemo/Startup.cs index 573d1fe3..11adfb93 100644 --- a/examples/FeatureFlagDemo/Startup.cs +++ b/examples/FeatureFlagDemo/Startup.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Assigners; using Microsoft.FeatureManagement.FeatureFilters; namespace FeatureFlagDemo diff --git a/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml b/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml index 516eb3ba..f8a5d76b 100644 --- a/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml +++ b/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml @@ -1,7 +1,7 @@ @using Microsoft.FeatureManagement -@inject IFeatureVariantManager variantManager; +@inject IDynamicFeatureManager dynamicFeatureManager; @{ - DiscountBannerOptions opts = await variantManager.GetVariantAsync("DiscountBanner", Context.RequestAborted); + DiscountBannerOptions opts = await dynamicFeatureManager.GetVariantAsync("DiscountBanner", Context.RequestAborted); } diff --git a/examples/FeatureFlagDemo/appsettings.json b/examples/FeatureFlagDemo/appsettings.json index 919af4bf..7ae75e61 100644 --- a/examples/FeatureFlagDemo/appsettings.json +++ b/examples/FeatureFlagDemo/appsettings.json @@ -5,100 +5,102 @@ } }, "AllowedHosts": "*", - // Define feature flags in config file "FeatureManagement": { - - "Home": true, - "Beta": { - "EnabledFor": [ - { - "Name": "Targeting", - "Parameters": { // This json object maps to a strongly typed configuration class - "Audience": { - "Users": [ - "Jeff", - "Alicia" - ], - "Groups": [ - { - "Name": "Ring0", - "RolloutPercentage": 80 - }, - { - "Name": "Ring1", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 20 + "FeatureFlags": { + "Home": true, + "Beta": { + "EnabledFor": [ + { + "Name": "Targeting", + "Parameters": { // This json object maps to a strongly typed configuration class + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 80 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } } } - } - ] - }, - "CustomViewData": { - "EnabledFor": [ - { - "Name": "Browser", - "Parameters": { - "AllowedBrowsers": [ "Chrome", "Edge" ] + ] + }, + "CustomViewData": { + "EnabledFor": [ + { + "Name": "Browser", + "Parameters": { + "AllowedBrowsers": [ "Chrome", "Edge" ] + } } - } - ] - }, - "ContentEnhancement": { - "EnabledFor": [ - { - "Name": "TimeWindow", - "Parameters": { - "Start": "Wed, 01 May 2019 13:59:59 GMT", - "End": "Mon, 01 July 2019 00:00:00 GMT" + ] + }, + "ContentEnhancement": { + "EnabledFor": [ + { + "Name": "TimeWindow", + "Parameters": { + "Start": "Wed, 01 May 2019 13:59:59 GMT", + "End": "Mon, 01 July 2019 00:00:00 GMT" + } } - } - ] - }, - "EnhancedPipeline": { - "EnabledFor": [ - { - "Name": "Microsoft.Percentage", - "Parameters": { - "Value": 50 + ] + }, + "EnhancedPipeline": { + "EnabledFor": [ + { + "Name": "Microsoft.Percentage", + "Parameters": { + "Value": 50 + } } - } - ] + ] + } }, - "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 + "DynamicFeatures": { + "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": { diff --git a/examples/TargetingConsoleApp/Program.cs b/examples/TargetingConsoleApp/Program.cs index 464cb417..ca4efd46 100644 --- a/examples/TargetingConsoleApp/Program.cs +++ b/examples/TargetingConsoleApp/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Assigners; using Microsoft.FeatureManagement.FeatureFilters; using System; using System.Collections.Generic; @@ -40,7 +41,7 @@ public static async Task Main(string[] args) using (ServiceProvider serviceProvider = services.BuildServiceProvider()) { IFeatureManager featureManager = serviceProvider.GetRequiredService(); - IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + IDynamicFeatureManager dynamicFeatureManager = serviceProvider.GetRequiredService(); // // We'll simulate a task to run on behalf of each known user @@ -76,7 +77,7 @@ public static async Task Main(string[] args) // // Retrieve feature variant using targeting - CartOptions cartOptions = await variantManager + CartOptions cartOptions = await dynamicFeatureManager .GetVariantAsync( DynamicFeatureName, targetingContext, diff --git a/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs new file mode 100644 index 00000000..82794ef6 --- /dev/null +++ b/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A feature definition provider that pulls dynamic feature definitions from the .NET Core system. + /// + sealed class ConfigurationDynamicFeatureDefinitionProvider : IDynamicFeatureDefinitionProvider, IDisposable + { + private const string FeatureManagementSectionName = "FeatureManagement"; + private const string DynamicFeatureDefinitionsSectionName= "DynamicFeatures"; + private const string FeatureVariantsSectionName = "Variants"; + private readonly IConfiguration _configuration; + private readonly ConcurrentDictionary _dynamicFeatureDefinitions; + private IDisposable _changeSubscription; + private int _stale = 0; + + public ConfigurationDynamicFeatureDefinitionProvider(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _dynamicFeatureDefinitions = new ConcurrentDictionary(); + + _changeSubscription = ChangeToken.OnChange( + () => _configuration.GetReloadToken(), + () => _stale = 1); + } + + public void Dispose() + { + _changeSubscription?.Dispose(); + + _changeSubscription = null; + } + + public Task GetDynamicFeatureDefinitionAsync(string featureName, CancellationToken cancellationToken = default) + { + if (featureName == null) + { + throw new ArgumentNullException(nameof(featureName)); + } + + EnsureFresh(); + + // + // Query by feature name + DynamicFeatureDefinition definition = _dynamicFeatureDefinitions.GetOrAdd(featureName, (name) => ReadDynamicFeatureDefinition(name)); + + return Task.FromResult(definition); + } + + // + // The async key word is necessary for creating IAsyncEnumerable. + // The need to disable this warning occurs when implementaing async stream synchronously. +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async IAsyncEnumerable GetAllDynamicFeatureDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + EnsureFresh(); + + // + // Iterate over all features registered in the system at initial invocation time + foreach (IConfigurationSection featureSection in GetDynamicFeatureDefinitionSections()) + { + // + // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned + yield return _dynamicFeatureDefinitions.GetOrAdd(featureSection.Key, (_) => ReadDynamicFeatureDefinition(featureSection)); + } + } + + private DynamicFeatureDefinition ReadDynamicFeatureDefinition(string featureName) + { + IConfigurationSection configuration = GetDynamicFeatureDefinitionSections() + .FirstOrDefault(section => section.Key.Equals(featureName, StringComparison.OrdinalIgnoreCase)); + + if (configuration == null) + { + return null; + } + + return ReadDynamicFeatureDefinition(configuration); + } + + private DynamicFeatureDefinition ReadDynamicFeatureDefinition(IConfigurationSection configurationSection) + { + Debug.Assert(configurationSection != null); + + var variants = new List(); + + foreach (IConfigurationSection section in configurationSection.GetSection(FeatureVariantsSectionName).GetChildren()) + { + if (int.TryParse(section.Key, out int _) && !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)) + }); + } + } + + return new DynamicFeatureDefinition() + { + Name = configurationSection.Key, + Variants = variants, + Assigner = configurationSection.GetValue(nameof(DynamicFeatureDefinition.Assigner)) + }; + } + + private IEnumerable GetDynamicFeatureDefinitionSections() + { + // + // Look for feature definitions under the "FeatureManagement" section + IConfiguration featureManagementSection = _configuration.GetChildren().Any(s => s.Key.Equals(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase)) ? + _configuration.GetSection(FeatureManagementSectionName) : + _configuration; + + return featureManagementSection + .GetSection(DynamicFeatureDefinitionsSectionName) + .GetChildren(); + } + + private void EnsureFresh() + { + if (Interlocked.Exchange(ref _stale, 0) != 0) + { + _dynamicFeatureDefinitions.Clear(); + } + } + } +} diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs similarity index 59% rename from src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs rename to src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs index 2c8fe03d..d302cff2 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.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; @@ -14,21 +15,22 @@ namespace Microsoft.FeatureManagement { /// - /// A feature definition provider that pulls feature definitions from the .NET Core system. + /// A feature definition provider that pulls feature flag definitions from the .NET Core system. /// - sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionProvider, IDisposable + sealed class ConfigurationFeatureFlagDefinitionProvider : IFeatureFlagDefinitionProvider, IDisposable { + private const string FeatureManagementSectionName = "FeatureManagement"; + private const string FeatureFlagDefinitionsSectionName = "FeatureFlags"; private const string FeatureFiltersSectionName = "EnabledFor"; - private const string FeatureVariantsSectionName = "Variants"; private readonly IConfiguration _configuration; - private readonly ConcurrentDictionary _definitions; + private readonly ConcurrentDictionary _featureFlagDefinitions; private IDisposable _changeSubscription; private int _stale = 0; - public ConfigurationFeatureDefinitionProvider(IConfiguration configuration) + public ConfigurationFeatureFlagDefinitionProvider(IConfiguration configuration) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _definitions = new ConcurrentDictionary(); + _featureFlagDefinitions = new ConcurrentDictionary(); _changeSubscription = ChangeToken.OnChange( () => _configuration.GetReloadToken(), @@ -42,21 +44,18 @@ public void Dispose() _changeSubscription = null; } - public Task GetFeatureDefinitionAsync(string featureName, CancellationToken cancellationToken) + public Task GetFeatureFlagDefinitionAsync(string featureName, CancellationToken cancellationToken) { if (featureName == null) { throw new ArgumentNullException(nameof(featureName)); } - if (Interlocked.Exchange(ref _stale, 0) != 0) - { - _definitions.Clear(); - } + EnsureFresh(); // // Query by feature name - FeatureDefinition definition = _definitions.GetOrAdd(featureName, (name) => ReadFeatureDefinition(name)); + FeatureFlagDefinition definition = _featureFlagDefinitions.GetOrAdd(featureName, (name) => ReadFeatureFlagDefinition(name)); return Task.FromResult(definition); } @@ -65,27 +64,24 @@ public Task GetFeatureDefinitionAsync(string featureName, Can // The async key word is necessary for creating IAsyncEnumerable. // The need to disable this warning occurs when implementaing async stream synchronously. #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - public async IAsyncEnumerable GetAllFeatureDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken) #pragma warning restore CS1998 { - if (Interlocked.Exchange(ref _stale, 0) != 0) - { - _definitions.Clear(); - } + EnsureFresh(); // // Iterate over all features registered in the system at initial invocation time - foreach (IConfigurationSection featureSection in GetFeatureDefinitionSections()) + foreach (IConfigurationSection featureSection in GetFeatureFlagDefinitionSections()) { // // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - yield return _definitions.GetOrAdd(featureSection.Key, (_) => ReadFeatureDefinition(featureSection)); + yield return _featureFlagDefinitions.GetOrAdd(featureSection.Key, (_) => ReadFeatureFlagDefinition(featureSection)); } } - private FeatureDefinition ReadFeatureDefinition(string featureName) + private FeatureFlagDefinition ReadFeatureFlagDefinition(string featureName) { - IConfigurationSection configuration = GetFeatureDefinitionSections() + IConfigurationSection configuration = GetFeatureFlagDefinitionSections() .FirstOrDefault(section => section.Key.Equals(featureName, StringComparison.OrdinalIgnoreCase)); if (configuration == null) @@ -93,10 +89,10 @@ private FeatureDefinition ReadFeatureDefinition(string featureName) return null; } - return ReadFeatureDefinition(configuration); + return ReadFeatureFlagDefinition(configuration); } - private FeatureDefinition ReadFeatureDefinition(IConfigurationSection configurationSection) + private FeatureFlagDefinition ReadFeatureFlagDefinition(IConfigurationSection configurationSection) { /* @@ -127,11 +123,9 @@ We support */ - var enabledFor = new List(); - - var variants = new List(); + Debug.Assert(configurationSection != null); - string assigner = null; + var enabledFor = new List(); string val = configurationSection.Value; // configuration[$"{featureName}"]; @@ -162,7 +156,7 @@ We support // // Arrays in json such as "myKey": [ "some", "values" ] // 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)])) + if (int.TryParse(section.Key, out int _) && !string.IsNullOrEmpty(section[nameof(FeatureFilterConfiguration.Name)])) { enabledFor.Add(new FeatureFilterConfiguration { @@ -171,48 +165,39 @@ We support }); } } - - 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() + return new FeatureFlagDefinition() { Name = configurationSection.Key, EnabledFor = enabledFor, - Variants = variants, - Assigner = assigner }; } - private IEnumerable GetFeatureDefinitionSections() + private IEnumerable GetFeatureFlagDefinitionSections() { - const string FeatureManagementSectionName = "FeatureManagement"; + // + // Look for feature definitions under the "FeatureManagement" section + IConfiguration featureManagementSection = _configuration.GetChildren().Any(s => s.Key.Equals(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase)) ? + _configuration.GetSection(FeatureManagementSectionName) : + _configuration; - if (_configuration.GetChildren().Any(s => s.Key.Equals(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase))) - { - // - // Look for feature definitions under the "FeatureManagement" section - return _configuration.GetSection(FeatureManagementSectionName).GetChildren(); - } - else + IEnumerable featureManagementChildren = featureManagementSection.GetChildren(); + + IConfigurationSection featureFlagsSection = featureManagementChildren.FirstOrDefault(s => s.Key == FeatureFlagDefinitionsSectionName); + + // + // Support backward compatability where feature flag definitions were directly under the feature management section + return featureFlagsSection == null ? + featureManagementChildren : + featureFlagsSection.GetChildren(); + } + + private void EnsureFresh() + { + if (Interlocked.Exchange(ref _stale, 0) != 0) { - return _configuration.GetChildren(); + _featureFlagDefinitions.Clear(); } } } diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs index dcb32a0e..c5868056 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 GetOptionsAsync(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken) + public ValueTask GetOptionsAsync(DynamicFeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken) { if (variant == null) { diff --git a/src/Microsoft.FeatureManagement/FeatureDefinition.cs b/src/Microsoft.FeatureManagement/DynamicFeatureDefinition.cs similarity index 61% rename from src/Microsoft.FeatureManagement/FeatureDefinition.cs rename to src/Microsoft.FeatureManagement/DynamicFeatureDefinition.cs index 19287df6..8dfb95cc 100644 --- a/src/Microsoft.FeatureManagement/FeatureDefinition.cs +++ b/src/Microsoft.FeatureManagement/DynamicFeatureDefinition.cs @@ -7,27 +7,22 @@ namespace Microsoft.FeatureManagement { /// - /// The definition of a feature. + /// The definition of a dynamic feature. /// - public class FeatureDefinition + public class DynamicFeatureDefinition { /// - /// The name of the feature. + /// The name of the dynamic feature. /// public string Name { get; set; } - /// - /// The feature filters that the feature can be enabled for. - /// - 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. + /// The feature variants listed for this dynamic feature. /// public IEnumerable Variants { get; set; } = Enumerable.Empty(); } diff --git a/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs b/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs new file mode 100644 index 00000000..4ac7dcd1 --- /dev/null +++ b/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to retrieve variants for dynamic features. + /// + class DynamicFeatureManager : IDynamicFeatureManager + { + private readonly IDynamicFeatureDefinitionProvider _featureDefinitionProvider; + private readonly IEnumerable _variantAssigners; + private readonly IFeatureVariantOptionsResolver _variantOptionsResolver; + private readonly ConcurrentDictionary _assignerMetadataCache; + private readonly ConcurrentDictionary _contextualFeatureVariantAssignerCache; + + public DynamicFeatureManager( + IDynamicFeatureDefinitionProvider featureDefinitionProvider, + IEnumerable variantAssigner, + IFeatureVariantOptionsResolver variantOptionsResolver) + { + _variantAssigners = variantAssigner ?? throw new ArgumentNullException(nameof(variantAssigner)); + _variantOptionsResolver = variantOptionsResolver ?? throw new ArgumentNullException(nameof(variantOptionsResolver)); + _featureDefinitionProvider = featureDefinitionProvider ?? throw new ArgumentNullException(nameof(featureDefinitionProvider)); + _assignerMetadataCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _contextualFeatureVariantAssignerCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + + public async IAsyncEnumerable GetDynamicFeatureNamesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (DynamicFeatureDefinition featureDefintion in _featureDefinitionProvider.GetAllDynamicFeatureDefinitionsAsync(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; + + DynamicFeatureDefinition featureDefinition = await _featureDefinitionProvider + .GetDynamicFeatureDefinitionAsync(feature, cancellationToken) + .ConfigureAwait(false); + + if (featureDefinition == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeature, + $"The feature declaration for the dynamic feature '{feature}' was not found."); + } + + if (string.IsNullOrEmpty(featureDefinition.Assigner)) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariantAssigner, + $"Missing feature variant assigner name for the feature {feature}"); + } + + if (featureDefinition.Variants == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariant, + $"No variants are registered for the feature {feature}"); + } + + FeatureVariant defaultVariant = null; + + foreach (FeatureVariant v in featureDefinition.Variants) + { + if (v.Default) + { + if (defaultVariant != null) + { + throw new FeatureManagementException( + FeatureManagementError.AmbiguousDefaultFeatureVariant, + $"Multiple default variants are registered for the feature '{feature}'."); + } + + defaultVariant = v; + } + + if (v.ConfigurationReference == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingConfigurationReference, + $"The variant '{variant.Name}' for the feature '{feature}' does not have a configuration reference."); + } + } + + if (defaultVariant == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingDefaultFeatureVariant, + $"A default variant cannot be found for the feature '{feature}'."); + } + + IFeatureVariantAssignerMetadata assigner = GetFeatureVariantAssignerMetadata(featureDefinition.Assigner); + + if (assigner == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariantAssigner, + $"The feature variant assigner '{featureDefinition.Assigner}' specified for feature '{feature}' was not found."); + } + + var context = new FeatureVariantAssignmentContext() + { + FeatureDefinition = featureDefinition + }; + + // + // IFeatureVariantAssigner + if (assigner is IFeatureVariantAssigner featureVariantAssigner) + { + variant = await featureVariantAssigner.AssignVariantAsync(context, cancellationToken).ConfigureAwait(false); + } + // + // IContextualFeatureVariantAssigner + else if (useAppContext && + TryGetContextualFeatureVariantAssigner(featureDefinition.Assigner, typeof(TContext), out ContextualFeatureVariantAssignerEvaluator contextualAssigner)) + { + variant = await contextualAssigner.AssignVariantAsync(context, appContext, cancellationToken).ConfigureAwait(false); + } + // + // The assigner doesn't implement a feature variant assigner interface capable of performing the evaluation + else + { + throw new FeatureManagementException( + FeatureManagementError.InvalidFeatureVariantAssigner, + useAppContext ? + $"The feature variant assigner '{featureDefinition.Assigner}' specified for the feature '{feature}' is not capable of evaluating the requested feature with the provided context." : + $"The feature variant assigner '{featureDefinition.Assigner}' specified for the feature '{feature}' is not capable of evaluating the requested feature."); + } + + if (variant == null) + { + variant = defaultVariant; + } + + return await _variantOptionsResolver.GetOptionsAsync(featureDefinition, variant, cancellationToken).ConfigureAwait(false); + } + + private IFeatureVariantAssignerMetadata GetFeatureVariantAssignerMetadata(string assignerName) + { + const string assignerSuffix = "assigner"; + + IFeatureVariantAssignerMetadata assigner = _assignerMetadataCache.GetOrAdd( + assignerName, + (_) => { + + IEnumerable matchingAssigners = _variantAssigners.Where(a => + { + Type assignerType = a.GetType(); + + string name = ((AssignerAliasAttribute)Attribute.GetCustomAttribute(assignerType, typeof(AssignerAliasAttribute)))?.Alias; + + if (name == null) + { + name = assignerType.Name; + } + + return NameHelper.IsMatchingReference( + reference: assignerName, + metadataName: name, + suffix: assignerSuffix); + }); + + if (matchingAssigners.Count() > 1) + { + throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureVariantAssigner, $"Multiple feature variant assigners match the configured assigner named '{assignerName}'."); + } + + return matchingAssigners.FirstOrDefault(); + } + ); + + return assigner; + } + + private bool TryGetContextualFeatureVariantAssigner(string assignerName, Type appContextType, out ContextualFeatureVariantAssignerEvaluator assigner) + { + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + + assigner = _contextualFeatureVariantAssignerCache.GetOrAdd( + $"{assignerName}{Environment.NewLine}{appContextType.FullName}", + (_) => { + + IFeatureVariantAssignerMetadata metadata = GetFeatureVariantAssignerMetadata(assignerName); + + return ContextualFeatureVariantAssignerEvaluator.IsContextualVariantAssigner(metadata, appContextType) ? + new ContextualFeatureVariantAssignerEvaluator(metadata, appContextType) : + null; + } + ); + + return assigner != null; + } + } +} diff --git a/src/Microsoft.FeatureManagement/DynamicFeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/DynamicFeatureManagerSnapshot.cs new file mode 100644 index 00000000..517c496f --- /dev/null +++ b/src/Microsoft.FeatureManagement/DynamicFeatureManagerSnapshot.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a snapshot of feature state to ensure consistency across a given request. + /// + class DynamicFeatureManagerSnapshot : IDynamicFeatureManagerSnapshot + { + private readonly IDynamicFeatureManager _dynamicFeatureManager; + private readonly IDictionary _variantCache = new Dictionary(); + private IEnumerable _dynamicFeatureNames; + + public DynamicFeatureManagerSnapshot(IDynamicFeatureManager dynamicFeatureManager) + { + _dynamicFeatureManager = dynamicFeatureManager ?? throw new ArgumentNullException(nameof(dynamicFeatureManager)); + } + + public async IAsyncEnumerable GetDynamicFeatureNamesAsync([EnumeratorCancellation]CancellationToken cancellationToken = default) + { + if (_dynamicFeatureNames == null) + { + var dynamicFeatureNames = new List(); + + await foreach (string featureName in _dynamicFeatureManager.GetDynamicFeatureNamesAsync(cancellationToken).ConfigureAwait(false)) + { + dynamicFeatureNames.Add(featureName); + } + + _dynamicFeatureNames = dynamicFeatureNames; + } + + foreach (string featureName in _dynamicFeatureNames) + { + yield return featureName; + } + } + + public async ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken) + { + string cacheKey = GetVariantCacheKey(feature); + + // + // First, check local cache + if (_variantCache.ContainsKey(feature)) + { + return (T)_variantCache[cacheKey]; + } + + T variant = await _dynamicFeatureManager.GetVariantAsync(feature, cancellationToken).ConfigureAwait(false); + + _variantCache[cacheKey] = variant; + + return variant; + } + + public async ValueTask GetVariantAsync(string feature, TContext context, CancellationToken cancellationToken) + { + string cacheKey = GetVariantCacheKey(feature); + + // + // First, check local cache + if (_variantCache.ContainsKey(feature)) + { + return (T)_variantCache[cacheKey]; + } + + T variant = await _dynamicFeatureManager.GetVariantAsync(feature, context, cancellationToken).ConfigureAwait(false); + + _variantCache[cacheKey] = variant; + + return variant; + } + + private string GetVariantCacheKey(string feature) + { + return $"{typeof(T).FullName}\n{feature}"; + } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFlagDefinition.cs b/src/Microsoft.FeatureManagement/FeatureFlagDefinition.cs new file mode 100644 index 00000000..4cce2194 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFlagDefinition.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.FeatureManagement +{ + /// + /// The definition of a feature flag. + /// + public class FeatureFlagDefinition + { + /// + /// The name of the feature flag. + /// + public string Name { get; set; } + + /// + /// The feature filters that the feature flag can be enabled for. + /// + public IEnumerable EnabledFor { get; set; } = Enumerable.Empty(); + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 60c9855a..b119d4f8 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; @@ -17,39 +16,29 @@ namespace Microsoft.FeatureManagement /// /// Used to evaluate whether a feature is enabled or disabled. /// - class FeatureManager : IFeatureManager, IFeatureVariantManager + class FeatureManager : IFeatureManager { - private readonly IFeatureDefinitionProvider _featureDefinitionProvider; + private readonly IFeatureFlagDefinitionProvider _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, + IFeatureFlagDefinitionProvider featureDefinitionProvider, IEnumerable featureFilters, - IEnumerable variantAssigner, - IFeatureVariantOptionsResolver variantOptionsResolver, IEnumerable sessionManagers, ILoggerFactory loggerFactory, IOptions options) { _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(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)); } @@ -63,135 +52,14 @@ public Task IsEnabledAsync(string feature, TContext appContext, return IsEnabledAsync(feature, appContext, true, cancellationToken); } - public async IAsyncEnumerable GetFeatureNamesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable GetFeatureFlagNamesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - await foreach (FeatureDefinition featureDefintion in _featureDefinitionProvider.GetAllFeatureDefinitionsAsync(cancellationToken).ConfigureAwait(false)) + await foreach (FeatureFlagDefinition featureDefintion in _featureDefinitionProvider.GetAllFeatureFlagDefinitionsAsync(cancellationToken).ConfigureAwait(false)) { yield return featureDefintion.Name; } } - public ValueTask GetVariantAsync(string feature, TContext appContext, CancellationToken cancellationToken) - { - return GetVariantAsync(feature, appContext, true, cancellationToken); - } - - public ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken) - { - return GetVariantAsync(feature, null, false, cancellationToken); - } - - private async ValueTask GetVariantAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) - { - if (feature == null) - { - throw new ArgumentNullException(nameof(feature)); - } - - FeatureVariant variant = null; - - FeatureDefinition featureDefinition = await _featureDefinitionProvider - .GetFeatureDefinitionAsync(feature, cancellationToken) - .ConfigureAwait(false); - - if (featureDefinition == null) - { - throw new FeatureManagementException( - FeatureManagementError.MissingFeature, - $"The feature declaration for the dynamic feature '{feature}' was not found."); - } - - if (string.IsNullOrEmpty(featureDefinition.Assigner)) - { - throw new FeatureManagementException( - FeatureManagementError.MissingFeatureVariantAssigner, - $"Missing feature variant assigner name for the feature {feature}"); - } - - if (featureDefinition.Variants == null) - { - throw new FeatureManagementException( - FeatureManagementError.MissingFeatureVariant, - $"No variants are registered for the feature {feature}"); - } - - FeatureVariant defaultVariant = null; - - foreach (FeatureVariant v in featureDefinition.Variants) - { - if (v.Default) - { - if (defaultVariant != null) - { - throw new FeatureManagementException( - FeatureManagementError.AmbiguousDefaultFeatureVariant, - $"Multiple default variants are registered for the feature '{feature}'."); - } - - defaultVariant = v; - } - - if (v.ConfigurationReference == null) - { - throw new FeatureManagementException( - FeatureManagementError.MissingConfigurationReference, - $"The variant '{variant.Name}' for the feature '{feature}' does not have a configuration reference."); - } - } - - if (defaultVariant == null) - { - throw new FeatureManagementException( - FeatureManagementError.MissingDefaultFeatureVariant, - $"A default variant cannot be found for the feature '{feature}'."); - } - - IFeatureVariantAssignerMetadata assigner = GetFeatureVariantAssignerMetadata(featureDefinition.Assigner); - - if (assigner == null) - { - throw new FeatureManagementException( - FeatureManagementError.MissingFeatureVariantAssigner, - $"The feature variant assigner '{featureDefinition.Assigner}' specified for feature '{feature}' was not found."); - } - - var context = new FeatureVariantAssignmentContext() - { - FeatureDefinition = featureDefinition - }; - - // - // IFeatureVariantAssigner - if (assigner is IFeatureVariantAssigner featureVariantAssigner) - { - variant = await featureVariantAssigner.AssignVariantAsync(context, cancellationToken).ConfigureAwait(false); - } - // - // IContextualFeatureVariantAssigner - else if (useAppContext && - TryGetContextualFeatureVariantAssigner(featureDefinition.Assigner, typeof(TContext), out ContextualFeatureVariantAssignerEvaluator contextualAssigner)) - { - variant = await contextualAssigner.AssignVariantAsync(context, appContext, cancellationToken).ConfigureAwait(false); - } - // - // The assigner doesn't implement a feature variant assigner interface capable of performing the evaluation - else - { - throw new FeatureManagementException( - FeatureManagementError.InvalidFeatureVariantAssigner, - useAppContext ? - $"The feature variant assigner '{featureDefinition.Assigner}' specified for the feature '{feature}' is not capable of evaluating the requested feature with the provided context." : - $"The feature variant assigner '{featureDefinition.Assigner}' specified for the feature '{feature}' is not capable of evaluating the requested feature."); - } - - if (variant == null) - { - variant = defaultVariant; - } - - return await _variantOptionsResolver.GetOptionsAsync(featureDefinition, variant, cancellationToken).ConfigureAwait(false); - } - private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { foreach (ISessionManager sessionManager in _sessionManagers) @@ -206,7 +74,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo bool enabled = false; - FeatureDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureDefinitionAsync(feature, cancellationToken).ConfigureAwait(false); + FeatureFlagDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureFlagDefinitionAsync(feature, cancellationToken).ConfigureAwait(false); if (featureDefinition != null) { @@ -321,7 +189,10 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) name = filterType.Name; } - return IsMatchingMetadataName(name, filterName, filterSuffix); + return NameHelper.IsMatchingReference( + reference: filterName, + metadataName: name, + suffix: filterSuffix); }); if (matchingFilters.Count() > 1) @@ -336,71 +207,6 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) return filter; } - private IFeatureVariantAssignerMetadata GetFeatureVariantAssignerMetadata(string assignerName) - { - const string assignerSuffix = "assigner"; - - IFeatureVariantAssignerMetadata assigner = _assignerMetadataCache.GetOrAdd( - assignerName, - (_) => { - - IEnumerable matchingAssigners = _variantAssigners.Where(a => - { - Type assignerType = a.GetType(); - - string name = ((AssignerAliasAttribute)Attribute.GetCustomAttribute(assignerType, typeof(AssignerAliasAttribute)))?.Alias; - - if (name == null) - { - name = assignerType.Name; - } - - return IsMatchingMetadataName(name, assignerName, assignerSuffix); - }); - - if (matchingAssigners.Count() > 1) - { - throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureVariantAssigner, $"Multiple feature variant assigners match the configured assigner named '{assignerName}'."); - } - - return matchingAssigners.FirstOrDefault(); - } - ); - - return assigner; - } - - private static bool IsMatchingMetadataName(string metadataName, string desiredName, string suffix) - { - // - // Feature filters can be referenced with or without the 'filter' suffix - // E.g. A feature can reference a filter named 'CustomFilter' as 'Custom' or 'CustomFilter' - if (!desiredName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) && - metadataName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) - { - metadataName = metadataName.Substring(0, metadataName.Length - suffix.Length); - } - - // - // Feature filters can have namespaces in their alias - // If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter' - // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' - if (desiredName.Contains('.')) - { - // - // The configured filter name is namespaced. It must be an exact match. - return string.Equals(metadataName, desiredName, StringComparison.OrdinalIgnoreCase); - } - else - { - // - // We take the simple name of a filter, E.g. 'MyFilter' for 'MyOrg.MyProduct.MyFilter' - string simpleName = metadataName.Contains('.') ? metadataName.Split('.').Last() : metadataName; - - return string.Equals(simpleName, desiredName, StringComparison.OrdinalIgnoreCase); - } - } - private bool TryGetContextualFeatureFilter(string filterName, Type appContextType, out ContextualFeatureFilterEvaluator filter) { if (appContextType == null) @@ -422,27 +228,5 @@ private bool TryGetContextualFeatureFilter(string filterName, Type appContextTyp return filter != null; } - - private bool TryGetContextualFeatureVariantAssigner(string assignerName, Type appContextType, out ContextualFeatureVariantAssignerEvaluator assigner) - { - if (appContextType == null) - { - throw new ArgumentNullException(nameof(appContextType)); - } - - assigner = _contextualFeatureVariantAssignerCache.GetOrAdd( - $"{assignerName}{Environment.NewLine}{appContextType.FullName}", - (_) => { - - IFeatureVariantAssignerMetadata metadata = GetFeatureVariantAssignerMetadata(assignerName); - - return ContextualFeatureVariantAssignerEvaluator.IsContextualVariantAssigner(metadata, appContextType) ? - new ContextualFeatureVariantAssignerEvaluator(metadata, appContextType) : - null; - } - ); - - return assigner != null; - } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs index 46549f4d..2abea404 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs @@ -16,28 +16,28 @@ class FeatureManagerSnapshot : IFeatureManagerSnapshot { private readonly IFeatureManager _featureManager; private readonly IDictionary _flagCache = new Dictionary(); - private IEnumerable _featureNames; + private IEnumerable _featureFlagNames; public FeatureManagerSnapshot(IFeatureManager featureManager) { _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); } - public async IAsyncEnumerable GetFeatureNamesAsync([EnumeratorCancellation]CancellationToken cancellationToken) + public async IAsyncEnumerable GetFeatureFlagNamesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { - if (_featureNames == null) + if (_featureFlagNames == null) { var featureNames = new List(); - await foreach (string featureName in _featureManager.GetFeatureNamesAsync(cancellationToken).ConfigureAwait(false)) + await foreach (string featureName in _featureManager.GetFeatureFlagNamesAsync(cancellationToken).ConfigureAwait(false)) { featureNames.Add(featureName); } - _featureNames = featureNames; + _featureFlagNames = featureNames; } - foreach (string featureName in _featureNames) + foreach (string featureName in _featureFlagNames) { yield return featureName; } diff --git a/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs b/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs index 265a6ce1..0620288b 100644 --- a/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs +++ b/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs @@ -9,8 +9,8 @@ namespace Microsoft.FeatureManagement public class FeatureVariantAssignmentContext { /// - /// The definition of the feature in need of an assigned variant + /// The definition of the dynamic feature in need of an assigned variant /// - public FeatureDefinition FeatureDefinition { get; set; } + public DynamicFeatureDefinition FeatureDefinition { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs index 173def4f..9dab47de 100644 --- a/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs @@ -7,18 +7,18 @@ namespace Microsoft.FeatureManagement { /// - /// Provides a method to assign a variant of a feature to be used based off of custom conditions. + /// Provides a method to assign a variant of a dynamic 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. + /// Assign a variant of a dynamic feature to be used based off of customized criteria. /// - /// A variant assignment context that contains information needed to assign a variant for a feature. - /// A context defined by the application that is passed in to the feature management system to provide contextual information for assigning a variant of a feature. + /// A variant assignment context that contains information needed to assign a variant for a dynamic 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 dynamic 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); + /// The variant that should be assigned for a given dynamic feature. + ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, TContext appContext, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/IDynamicFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/IDynamicFeatureDefinitionProvider.cs new file mode 100644 index 00000000..9f805ce1 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IDynamicFeatureDefinitionProvider.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A provider of dynamic feature definitions. + /// + public interface IDynamicFeatureDefinitionProvider + { + /// + /// Retrieves the definition for a given dynamic feature. + /// + /// The name of the dynamic feature to retrieve the definition for. + /// The cancellation token to cancel the operation. + /// The dynamic feature's definition. + Task GetDynamicFeatureDefinitionAsync(string dynamicFeatureName, CancellationToken cancellationToken = default); + + /// + /// Retrieves definitions for all dynamic features. + /// + /// The cancellation token to cancel the operation. + /// An enumerator which provides asynchronous iteration over dynamic feature definitions. + IAsyncEnumerable GetAllDynamicFeatureDefinitionsAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/IDynamicFeatureManager.cs b/src/Microsoft.FeatureManagement/IDynamicFeatureManager.cs new file mode 100644 index 00000000..e1785c08 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IDynamicFeatureManager.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to access the variants of a dynamic feature. + /// + public interface IDynamicFeatureManager + { + /// + /// Retrieves a list of dynamic feature names registered in the feature manager. + /// + /// The cancellation token to cancel the operation. + /// An enumerator which provides asynchronous iteration over the dynamic feature names registered in the feature manager. + IAsyncEnumerable GetDynamicFeatureNamesAsync(CancellationToken cancellationToken = default); + + /// + /// Retrieves a typed representation of the feature variant that should be used for a given dynamic feature. + /// + /// The type that the feature variant's configuration should be bound to. + /// The name of the dynamic feature. + /// The cancellation token to cancel the operation. + /// A typed representation of the feature variant that should be used for a given dynamic feature. + ValueTask GetVariantAsync(string dynamicFeature, CancellationToken cancellationToken = default); + + /// + /// Retrieves a typed representation of the feature variant that should be used for a given dynamic feature. + /// + /// The type that the feature variant's configuration should be bound to. + /// The type of the context being provided to the dynamic feature manager for use during the process of choosing which variant to use. + /// The name of the dynamic feature. + /// A context providing information that can be used to evaluate which variant should be used for the dynamic feature. + /// The cancellation token to cancel the operation. + /// A typed representation of the feature variant's configuration that should be used for a given feature. + ValueTask GetVariantAsync(string dynamicFeature, TContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/IDynamicFeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/IDynamicFeatureManagerSnapshot.cs new file mode 100644 index 00000000..1a5b34c1 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IDynamicFeatureManagerSnapshot.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a snapshot of feature state to ensure consistency across a given request. + /// + public interface IDynamicFeatureManagerSnapshot : IDynamicFeatureManager + { + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/IFeatureDefinitionProvider.cs deleted file mode 100644 index c8c37494..00000000 --- a/src/Microsoft.FeatureManagement/IFeatureDefinitionProvider.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.FeatureManagement -{ - /// - /// A provider of feature definitions. - /// - public interface IFeatureDefinitionProvider - { - /// - /// Retrieves the definition for a given feature. - /// - /// The name of the feature to retrieve the definition for. - /// The cancellation token to cancel the operation. - /// The feature's definition. - Task GetFeatureDefinitionAsync(string featureName, CancellationToken cancellationToken = default); - - /// - /// Retrieves definitions for all features. - /// - /// The cancellation token to cancel the operation. - /// An enumerator which provides asynchronous iteration over feature definitions. - IAsyncEnumerable GetAllFeatureDefinitionsAsync(CancellationToken cancellationToken = default); - } -} diff --git a/src/Microsoft.FeatureManagement/IFeatureFlagDefinitionProvider.cs b/src/Microsoft.FeatureManagement/IFeatureFlagDefinitionProvider.cs new file mode 100644 index 00000000..024e28ad --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureFlagDefinitionProvider.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A provider of feature flag definitions. + /// + public interface IFeatureFlagDefinitionProvider + { + /// + /// Retrieves the definition for a given feature flag. + /// + /// The name of the feature flag to retrieve the definition for. + /// The cancellation token to cancel the operation. + /// The feature flag's definition. + Task GetFeatureFlagDefinitionAsync(string featureFlagName, CancellationToken cancellationToken = default); + + /// + /// Retrieves definitions for all feature flags. + /// + /// The cancellation token to cancel the operation. + /// An enumerator which provides asynchronous iteration over feature flag definitions. + IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureManager.cs b/src/Microsoft.FeatureManagement/IFeatureManager.cs index 53231f89..ff69fb4e 100644 --- a/src/Microsoft.FeatureManagement/IFeatureManager.cs +++ b/src/Microsoft.FeatureManagement/IFeatureManager.cs @@ -8,32 +8,32 @@ namespace Microsoft.FeatureManagement { /// - /// Used to evaluate whether a feature is enabled or disabled. + /// Used to evaluate whether a feature flag is enabled or disabled. /// public interface IFeatureManager { /// - /// Retrieves a list of feature names registered in the feature manager. + /// Retrieves a list of feature flag names registered in the feature manager. /// /// The cancellation token to cancel the operation. - /// An enumerator which provides asynchronous iteration over the feature names registered in the feature manager. - IAsyncEnumerable GetFeatureNamesAsync(CancellationToken cancellationToken = default); + /// An enumerator which provides asynchronous iteration over the feature flag names registered in the feature manager. + IAsyncEnumerable GetFeatureFlagNamesAsync(CancellationToken cancellationToken = default); /// - /// Checks whether a given feature is enabled. + /// Checks whether a given feature flag is enabled. /// - /// The name of the feature to check. + /// The name of the feature flag to check. /// The cancellation token to cancel the operation. - /// True if the feature is enabled, otherwise false. - Task IsEnabledAsync(string feature, CancellationToken cancellationToken = default); + /// True if the feature flag is enabled, otherwise false. + Task IsEnabledAsync(string featureFlag, CancellationToken cancellationToken = default); /// - /// Checks whether a given feature is enabled. + /// Checks whether a given feature flag is enabled. /// - /// The name of the feature to check. - /// A context providing information that can be used to evaluate whether a feature should be on or off. + /// The name of the feature flag to check. + /// A context providing information that can be used to evaluate whether a feature flag should be on or off. /// The cancellation token to cancel the operation. - /// True if the feature is enabled, otherwise false. - Task IsEnabledAsync(string feature, TContext context, CancellationToken cancellationToken = default); + /// True if the feature flag is enabled, otherwise false. + Task IsEnabledAsync(string featureFlag, TContext context, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs index ee6e1627..cae12197 100644 --- a/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs @@ -7,16 +7,16 @@ namespace Microsoft.FeatureManagement { /// - /// Provides a method to assign a variant of a feature to be used based off of custom conditions. + /// Provides a method to assign a variant of a dynamic 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. + /// Assign a variant of a dynamic feature to be used based off of customized criteria. /// - /// A variant assignment context that contains information needed to assign a variant for a feature. + /// A variant assignment context that contains information needed to assign a variant for a dynamic feature. /// The cancellation token to cancel the operation. - /// The variant that should be assigned for a given feature. - ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken); + /// The variant that should be assigned for a given dynamic feature. + ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs b/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs index 713cf852..2b09c689 100644 --- a/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs +++ b/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs @@ -4,7 +4,7 @@ namespace Microsoft.FeatureManagement { /// - /// Marker interface for feature variant assigners used to assign which variant should be used for a feature. + /// Marker interface for feature variant assigners used to assign which variant should be used for a dynamic feature. /// public interface IFeatureVariantAssignerMetadata { diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs b/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs deleted file mode 100644 index cc69ffc8..00000000 --- a/src/Microsoft.FeatureManagement/IFeatureVariantManager.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.FeatureManagement -{ - /// - /// Used to access the variants of a feature. - /// - public interface IFeatureVariantManager - { - /// - /// Retrieves a typed representation of the configuration variant that should be used for a given feature. - /// - /// The type that the variants configuration should be bound to. - /// The name of the feature. - /// The cancellation token to cancel the operation. - /// A typed representation of the configuration variant that should be used for a given feature. - ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken); - - /// - /// Retrieves a typed representation of the configuration variant that should be used for a given feature. - /// - /// The type that the variants configuration should be bound to. - /// The type of the context being provided to the feature variant manager for use during the process of choosing which variant to use. - /// The name of the feature. - /// A context providing information that can be used to evaluate whether a feature should be on or off. - /// The cancellation token to cancel the operation. - /// A typed representation of the configuration variant that should be used for a given feature. - ValueTask GetVariantAsync(string feature, TContext context, CancellationToken cancellationToken); - } -} diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs b/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs index be0e2cd0..760c9358 100644 --- a/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs +++ b/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs @@ -12,13 +12,13 @@ namespace Microsoft.FeatureManagement public interface IFeatureVariantOptionsResolver { /// - /// Retrieves typed options for a given feature definition and chosen variant. + /// Retrieves typed options for a given dynamic 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 definition of the dynamic feature that the resolution is being performed for. + /// The chosen variant of the dynamic feature. /// The cancellation token to cancel the operation. - /// Typed options for a given feature definition and chosen variant. - ValueTask GetOptionsAsync(FeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken); + /// Typed options for a given dynamic feature definition and chosen variant. + ValueTask GetOptionsAsync(DynamicFeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/NameHelper.cs b/src/Microsoft.FeatureManagement/NameHelper.cs new file mode 100644 index 00000000..135deabe --- /dev/null +++ b/src/Microsoft.FeatureManagement/NameHelper.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Linq; + +namespace Microsoft.FeatureManagement +{ + static class NameHelper + { + /// + /// Evaluates whether a feature filter or feature variant assigner reference matches a given feature filter/assigner name. + /// + /// A reference to some feature metadata that should be checked for a match with the provided metadata name + /// The name used by the feature filter/feature variant assigner + /// An optional suffix that may be included when referencing the metadata type. E.g. "filter" or "assigner". + /// True if the reference is a match for the metadata name. False otherwise. + public static bool IsMatchingReference(string reference, string metadataName, string suffix) + { + if (string.IsNullOrEmpty(reference)) + { + throw new ArgumentNullException(nameof(reference)); + } + + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentNullException(nameof(metadataName)); + } + + // + // Feature filters/assigner can be referenced with or without their associated suffix ('filter' or 'assigner') + // E.g. A feature can reference a filter named 'CustomFilter' as 'Custom' or 'CustomFilter' + if (!reference.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) && + metadataName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + metadataName = metadataName.Substring(0, metadataName.Length - suffix.Length); + } + + // + // Feature filters/assigners 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 (reference.Contains('.')) + { + // + // The configured metadata name is namespaced. It must be an exact match. + return string.Equals(metadataName, reference, StringComparison.OrdinalIgnoreCase); + } + else + { + // + // We take the simple name of the metadata, E.g. 'MyFilter' for a feature filter named 'MyOrg.MyProduct.MyFilter' + string simpleName = metadataName.Contains('.') ? metadataName.Split('.').Last() : metadataName; + + return string.Equals(simpleName, reference, StringComparison.OrdinalIgnoreCase); + } + } + } +} diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index 233413d1..fdb94aaa 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -24,21 +24,21 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // // Add required services - services.TryAddSingleton(); + services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); services.TryAddSingleton(); - services.AddScoped(); + services.TryAddScoped(); - services.TryAddScoped(sp => sp.GetRequiredService()); + services.TryAddScoped(); return new FeatureManagementBuilder(services); } @@ -56,7 +56,9 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec throw new ArgumentNullException(nameof(configuration)); } - services.AddSingleton(new ConfigurationFeatureDefinitionProvider(configuration)); + services.AddSingleton(new ConfigurationFeatureFlagDefinitionProvider(configuration)); + + services.AddSingleton(new ConfigurationDynamicFeatureDefinitionProvider(configuration)); return services.AddFeatureManagement(); } diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs index 73807e9e..e703da76 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -10,7 +10,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Microsoft.FeatureManagement +namespace Microsoft.FeatureManagement.Assigners { /// /// A feature variant assigner that can be used to assign a variant based on targeted audiences. @@ -49,7 +49,7 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont throw new ArgumentNullException(nameof(targetingContext)); } - FeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; + DynamicFeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; if (featureDefinition == null) { diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs index f041c299..7068f0b3 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs @@ -8,7 +8,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Microsoft.FeatureManagement +namespace Microsoft.FeatureManagement.Assigners { /// /// A feature variant assigner that can be used to assign a variant based on targeted audiences. diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index e35265d7..4f14944f 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Assigners; using Microsoft.FeatureManagement.FeatureFilters; using System; using System.Collections.Generic; @@ -74,7 +75,7 @@ public async Task ReadsConfiguration() Assert.True(called); - IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + IDynamicFeatureManager variantManager = serviceProvider.GetRequiredService(); IEnumerable featureVariantAssigners = serviceProvider.GetRequiredService>(); @@ -117,6 +118,51 @@ public async Task ReadsConfiguration() Assert.Equal("def", val); } + [Fact] + public async Task ReadsV1Configuration() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.v1.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + Assert.True(await featureManager.IsEnabledAsync(OnFeature, CancellationToken.None)); + + Assert.False(await featureManager.IsEnabledAsync(OffFeature, CancellationToken.None)); + + IEnumerable featureFilters = serviceProvider.GetRequiredService>(); + + // + // Sync filter + TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter); + + bool called = false; + + testFeatureFilter.Callback = (evaluationContext) => + { + called = true; + + Assert.Equal("V1", evaluationContext.Parameters["P1"]); + + Assert.Equal(ConditionalFeature, evaluationContext.FeatureName); + + return true; + }; + + await featureManager.IsEnabledAsync(ConditionalFeature, CancellationToken.None); + + Assert.True(called); + } + [Fact] public async Task AllowsSuffix() { @@ -417,7 +463,7 @@ public async Task VariantTargeting() ServiceProvider serviceProvider = services.BuildServiceProvider(); - IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + IDynamicFeatureManager variantManager = serviceProvider.GetRequiredService(); // // Targeted @@ -460,9 +506,9 @@ public async Task AccumulatesAudience() ServiceProvider serviceProvider = services.BuildServiceProvider(); - IFeatureVariantManager variantManager = serviceProvider.GetRequiredService(); + IDynamicFeatureManager variantManager = serviceProvider.GetRequiredService(); - IFeatureDefinitionProvider featureProvider = serviceProvider.GetRequiredService(); + IFeatureFlagDefinitionProvider featureProvider = serviceProvider.GetRequiredService(); var occurences = new Dictionary(); @@ -659,7 +705,7 @@ public async Task UsesContextVariants() return ctx.FeatureDefinition.Variants.FirstOrDefault(v => v.Default); }; - IFeatureVariantManager variantManager = provider.GetRequiredService(); + IDynamicFeatureManager variantManager = provider.GetRequiredService(); AppContext context = new AppContext(); @@ -737,16 +783,29 @@ public async Task ListsFeatures() { IFeatureManager featureManager = provider.GetRequiredService(); - bool hasItems = false; + bool hasFeatureFlags = false; - await foreach (string feature in featureManager.GetFeatureNamesAsync(CancellationToken.None)) + await foreach (string feature in featureManager.GetFeatureFlagNamesAsync(CancellationToken.None)) { - hasItems = true; + hasFeatureFlags = true; break; } - Assert.True(hasItems); + Assert.True(hasFeatureFlags); + + IDynamicFeatureManager dynamicFeatureManager = provider.GetRequiredService(); + + bool hasDynamicFeatures = false; + + await foreach (string feature in dynamicFeatureManager.GetDynamicFeatureNamesAsync(CancellationToken.None)) + { + hasDynamicFeatures = true; + + break; + } + + Assert.True(hasDynamicFeatures); } } @@ -800,7 +859,11 @@ public async Task SwallowsExceptionForMissingFeatureFilter() [Fact] public async Task CustomFeatureDefinitionProvider() { - FeatureDefinition testFeature = new FeatureDefinition + const string DynamicFeature = "DynamicFeature"; + + // + // Feature flag + FeatureFlagDefinition testFeature = new FeatureFlagDefinition { Name = ConditionalFeature, EnabledFor = new List() @@ -816,12 +879,58 @@ public async Task CustomFeatureDefinitionProvider() } }; + // + // Dynamic feature + DynamicFeatureDefinition dynamicFeature = new DynamicFeatureDefinition + { + Name = DynamicFeature, + Assigner = "Test", + Variants = new List() + { + new FeatureVariant + { + Name = "V1", + AssignmentParameters = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary() + { + { "P1", "V1" } + }) + .Build(), + ConfigurationReference = "Ref1", + Default = true + }, + new FeatureVariant + { + Name = "V2", + AssignmentParameters = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary() + { + { "P2", "V2" } + }) + .Build(), + ConfigurationReference = "Ref2" + } + } + }; + var services = new ServiceCollection(); - services.AddSingleton(new InMemoryFeatureDefinitionProvider(new FeatureDefinition[] { testFeature })) + var definitionProvider = new InMemoryFeatureDefinitionProvider( + new FeatureFlagDefinition[] + { + testFeature + }, + new DynamicFeatureDefinition[] + { + dynamicFeature + }); + + services.AddSingleton(definitionProvider) + .AddSingleton(definitionProvider) .AddSingleton(new ConfigurationBuilder().Build()) .AddFeatureManagement() - .AddFeatureFilter(); + .AddFeatureFilter() + .AddFeatureVariantAssigner(); ServiceProvider serviceProvider = services.BuildServiceProvider(); @@ -849,6 +958,43 @@ public async Task CustomFeatureDefinitionProvider() await featureManager.IsEnabledAsync(ConditionalFeature, CancellationToken.None); Assert.True(called); + + IDynamicFeatureManager dynamicFeatureManager = serviceProvider.GetRequiredService(); + + IEnumerable featureAssigners = serviceProvider.GetRequiredService>(); + + // + // Sync filter + TestAssigner testFeatureVariantAssigner = (TestAssigner)featureAssigners.First(f => f is TestAssigner); + + called = false; + + testFeatureVariantAssigner.Callback = (assignmentContext) => + { + called = true; + + Assert.True(assignmentContext.FeatureDefinition.Variants.Count() == 2); + + FeatureVariant v1 = assignmentContext.FeatureDefinition.Variants.First(v => v.Name == "V1"); + + Assert.True(v1.Default); + + Assert.Equal("V1", v1.AssignmentParameters["P1"]); + + Assert.Equal("Ref1", v1.ConfigurationReference); + + FeatureVariant v2 = assignmentContext.FeatureDefinition.Variants.First(v => v.Name == "V2"); + + Assert.False(v2.Default); + + Assert.Equal("Ref2", v2.ConfigurationReference); + + return v1; + }; + + await dynamicFeatureManager.GetVariantAsync(DynamicFeature, CancellationToken.None); + + Assert.True(called); } private static void DisableEndpointRouting(MvcOptions options) diff --git a/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs b/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs index 63a1032e..01e347ab 100644 --- a/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs +++ b/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs @@ -11,28 +11,47 @@ namespace Tests.FeatureManagement { - class InMemoryFeatureDefinitionProvider : IFeatureDefinitionProvider + class InMemoryFeatureDefinitionProvider : IFeatureFlagDefinitionProvider, IDynamicFeatureDefinitionProvider { - private IEnumerable _definitions; + private IEnumerable _featureFlagDefinitions; + private IEnumerable _dynamicFeatureDefinitions; - public InMemoryFeatureDefinitionProvider(IEnumerable featureDefinitions) + public InMemoryFeatureDefinitionProvider( + IEnumerable featureFlagDefinitions, + IEnumerable dynamicFeatureDefinitions) { - _definitions = featureDefinitions ?? throw new ArgumentNullException(nameof(featureDefinitions)); + _featureFlagDefinitions = featureFlagDefinitions ?? throw new ArgumentNullException(nameof(featureFlagDefinitions)); + _dynamicFeatureDefinitions = dynamicFeatureDefinitions ?? throw new ArgumentNullException(nameof(dynamicFeatureDefinitions)); } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - public async IAsyncEnumerable GetAllFeatureDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken) #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { - foreach (FeatureDefinition definition in _definitions) + foreach (FeatureFlagDefinition definition in _featureFlagDefinitions) { yield return definition; } } - public Task GetFeatureDefinitionAsync(string featureName, CancellationToken cancellationToken) + public Task GetFeatureFlagDefinitionAsync(string featureName, CancellationToken cancellationToken) { - return Task.FromResult(_definitions.FirstOrDefault(definitions => definitions.Name.Equals(featureName, StringComparison.OrdinalIgnoreCase))); + return Task.FromResult(_featureFlagDefinitions.FirstOrDefault(definitions => definitions.Name.Equals(featureName, StringComparison.OrdinalIgnoreCase))); + } + + public Task GetDynamicFeatureDefinitionAsync(string featureName, CancellationToken cancellationToken = default) + { + return Task.FromResult(_dynamicFeatureDefinitions.FirstOrDefault(definitions => definitions.Name.Equals(featureName, StringComparison.OrdinalIgnoreCase))); + } + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async IAsyncEnumerable GetAllDynamicFeatureDefinitionsAsync(CancellationToken cancellationToken = default) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + foreach (DynamicFeatureDefinition definition in _dynamicFeatureDefinitions) + { + yield return definition; + } } } } diff --git a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj index f036243b..460c52c7 100644 --- a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj +++ b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj @@ -8,9 +8,14 @@ + + + Always + PreserveNewest + Always PreserveNewest diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index 0b7b8b04..8ca72556 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -1,211 +1,208 @@ { - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - "AllowedHosts": "*", - "FeatureManagement": { - "OnTestFeature": true, - "OffTestFeature": false, - "TargetingTestFeature": { - "EnabledFor": [ - { - "Name": "Targeting", - "Parameters": { - "Audience": { - "Users": [ - "Jeff", - "Alicia" - ], - "Groups": [ - { - "Name": "Ring0", - "RolloutPercentage": 100 - }, - { - "Name": "Ring1", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 20 + "FeatureFlags": { + "OnTestFeature": true, + "OffTestFeature": false, + "ConditionalFeature": { + "EnabledFor": [ + { + "Name": "Test", + "Parameters": { + "P1": "V1" } } - } - ] - }, - "ConditionalFeature": { - "EnabledFor": [ - { - "Name": "Test", - "Parameters": { - "P1": "V1" + ] + }, + "TargetingTestFeature": { + "EnabledFor": [ + { + "Name": "Targeting", + "Parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } + } } - } - ] - }, - "ConditionalFeature2": { - "EnabledFor": [ - { - "Name": "Test" - } - ] - }, - "WithSuffixFeature": { - "EnabledFor": [ - { - "Name": "TestFilter", - "Parameters": { + ] + }, + "ConditionalFeature2": { + "EnabledFor": [ + { + "Name": "Test" } - } - ] - }, - "WithoutSuffixFeature": { - "EnabledFor": [ - { - "Name": "Test", - "Parameters": { + ] + }, + "WithSuffixFeature": { + "EnabledFor": [ + { + "Name": "TestFilter", + "Parameters": { + } } - } - ] - }, - "ContextualFeature": { - "EnabledFor": [ - { - "Name": "ContextualTest", - "Parameters": { - "AllowedAccounts": [ - "abc" - ] + ] + }, + "WithoutSuffixFeature": { + "EnabledFor": [ + { + "Name": "Test", + "Parameters": { + } } - } - ] - }, - "VariantFeature": { - "Assigner": "Test", - "Variants": [ - { - "Default": true, - "Name": "V1", - "ConfigurationReference": "Ref1" - }, - { - "Name": "V2", - "ConfigurationReference": "Ref2", - "AssignmentParameters": { - "P1": "V1" + ] + }, + "ContextualFeature": { + "EnabledFor": [ + { + "Name": "ContextualTest", + "Parameters": { + "AllowedAccounts": [ + "abc" + ] + } } - } - ] + ] + } }, - "ContextualVariantFeature": { - "Assigner": "ContextualTest", - "Variants": [ - { - "Default": true, - "Name": "V1", - "ConfigurationReference": "Ref1" - }, - { - "Name": "V2", - "ConfigurationReference": "Ref2", - "AssignmentParameters": { - "AllowedAccounts": [ - "abc" - ] + "DynamicFeatures": { + "VariantFeature": { + "Assigner": "Test", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "P1": "V1" + } } - } - ] - }, - "ContextualVariantTargetingFeature": { - "Assigner": "Targeting", - "Variants": [ - { - "Default": true, - "Name": "V1", - "ConfigurationReference": "Ref1" - }, - { - "Name": "V2", - "ConfigurationReference": "Ref2", - "AssignmentParameters": { - "Audience": { - "Users": [ - "Jeff" + ] + }, + "ContextualVariantFeature": { + "Assigner": "ContextualTest", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "AllowedAccounts": [ + "abc" ] } } - } - ] - }, - "AccumulatedTargetingFeature": { - "Assigner": "Targeting", - "Variants": [ - { - "Default": true, - "Name": "V1", - "ConfigurationReference": "Percentage15" - }, - { - "Name": "V2", - "ConfigurationReference": "Percentage35", - "AssignmentParameters": { - "Audience": { - "DefaultRolloutPercentage": 35 + ] + }, + "ContextualVariantTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } } } - }, - { - "Name": "V3", - "ConfigurationReference": "Percentage50", - "AssignmentParameters": { - "Audience": { - "DefaultRolloutPercentage": 50 + ] + }, + "AccumulatedTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Percentage15" + }, + { + "Name": "V2", + "ConfigurationReference": "Percentage35", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 35 + } } - } - } - ] - }, - "AccumulatedGroupsTargetingFeature": { - "Assigner": "Targeting", - "Variants": [ - { - "Default": true, - "Name": "V1", - "ConfigurationReference": "Percentage15" - }, - { - "Name": "V2", - "ConfigurationReference": "Percentage35", - "AssignmentParameters": { - "Audience": { - "Groups": [ - { - "Name": "r", - "RolloutPercentage": 35 - } - ], - "DefaultRolloutPercentage": 0 + }, + { + "Name": "V3", + "ConfigurationReference": "Percentage50", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 50 + } } } - }, - { - "Name": "V3", - "ConfigurationReference": "Percentage50", - "AssignmentParameters": { - "Audience": { - "Groups": [ - { - "Name": "r", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 0 + ] + }, + "AccumulatedGroupsTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Percentage15" + }, + { + "Name": "V2", + "ConfigurationReference": "Percentage35", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "r", + "RolloutPercentage": 35 + } + ], + "DefaultRolloutPercentage": 0 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Percentage50", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "r", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 0 + } } } - } - ] + ] + } } }, "Ref1": "abc", diff --git a/tests/Tests.FeatureManagement/appsettings.v1.json b/tests/Tests.FeatureManagement/appsettings.v1.json new file mode 100644 index 00000000..93818779 --- /dev/null +++ b/tests/Tests.FeatureManagement/appsettings.v1.json @@ -0,0 +1,16 @@ +{ + "FeatureManagement": { + "OnTestFeature": true, + "OffTestFeature": false, + "ConditionalFeature": { + "EnabledFor": [ + { + "Name": "Test", + "Parameters": { + "P1": "V1" + } + } + ] + } + } +} From 28982a13ad235afac78c5d4512cbed0fa107d62a Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Thu, 20 Jan 2022 17:27:13 -0500 Subject: [PATCH 12/24] Added dynamic feature documentation. (#159) * Added dynamic feature documentation. * Readme fixes. * * Added index. * Moved around sections * Removed enum usage. * Fixed mistyped interfaces. * Clarified variant resolution. * Fix links with '.' character. * Updates. * Updated index. * Default -> Name * Remove `MyFeatureFlags` references. --- README.md | 472 ++++++++++++++++----- docs/schemas/FeatureManagement.v1.0.0.json | 75 ++++ docs/schemas/FeatureManagement.v2.0.0.json | 150 +++++++ docs/schemas/README.md | 17 + 4 files changed, 607 insertions(+), 107 deletions(-) create mode 100644 docs/schemas/FeatureManagement.v1.0.0.json create mode 100644 docs/schemas/FeatureManagement.v2.0.0.json create mode 100644 docs/schemas/README.md diff --git a/README.md b/README.md index 29658c5d..1e64e3bb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ASP.NET Core Feature Flags +# .NET Feature Management -Feature flags provide a way for ASP.NET Core applications to turn features on or off dynamically. Developers can use feature flags in simple use cases like conditional statements to more advanced scenarios like conditionally adding routes or MVC filters. Feature flags build on top of the .NET Core configuration system. Any .NET Core configuration provider is capable of acting as the back-bone for feature flags. +The Microsoft.FeatureManagement library enables developers to use feature flags and dynamic features inside of their applications. Feature flags can be used to turn features on or off dynamically. Developers can use feature flags in simple use cases like conditional statements to more advanced scenarios like conditionally adding routes or MVC filters. Dynamic features can be used to select different variants of a feature's configuration. This enables the possibility of using one version of a feature for one set of users, and another version of the feature for the remaining users. Here are some of the benefits of using this library: @@ -8,11 +8,11 @@ Here are some of the benefits of using this library: * Low barrier-to-entry * Built on `IConfiguration` * Supports JSON file feature flag setup -* Feature Flag lifetime management +* Feature flag lifetime management * Configuration values can change in real-time, feature flags can be consistent across the entire request -* Simple to Complex Scenarios Covered +* Simple to complex scenarios covered * Toggle on/off features through declarative configuration file - * Dynamically evaluate state of feature based on call to server + * Use different variants of a feature in different circumstances * API extensions for ASP.NET Core and MVC framework * Routing * Filters @@ -20,91 +20,76 @@ Here are some of the benefits of using this library: **API Reference**: https://go.microsoft.com/fwlink/?linkid=2091700 -### Feature Flags -Feature flags are composed of two parts, a name and a list of feature-filters that are used to turn the feature on. +## Index +* [Feature Flags](./README.md#Feature-Flags) + * [Feature Flag Declaration](./README.md#Feature-Flag-Declaration) + * [Feature Filters](./README.md#Feature-Filters) + * [ASP.NET Core Integration](./README.md#ASPNET-Core-Integration) + * [Built-in Feature Filters](./README.md#Built-in-Feature-Filters) +* [Dynamic Features](./README.md#Dynamic-Features) + * [Dynamic Feature Declaration](./README.md#Dynamic-Feature-Declaration) + * [Feature Variant Assigners](./README.md#Feature-Variant-Assigners) + * [Built-in Feature Variant Assigners](./README.md#Built-in-Feature-Variant-Assigners) +* [Targeting](./README.md#Targeting) +* [Caching](./README.md#Caching) +* [Custom Feature Providers](./README.md#Custom-Feature-Providers) -### Feature Filters -Feature filters define a scenario for when a feature should be enabled. When a feature is evaluated for whether it is on or off, its list of feature-filters are traversed until one of the filters decides the feature should be enabled. At this point the feature is considered enabled and traversal through the feature filters stops. If no feature filter indicates that the feature should be enabled, then it will be considered disabled. +## Feature Flags +Feature flags can either be on or off. They are composed of two parts, a name and a list of feature-filters that are used to turn the feature on. -As an example, a Microsoft Edge browser feature filter could be designed. This feature filter would activate any features it is attached to as long as an HTTP request is coming from Microsoft Edge. - -## Registration +### Feature Flag Configuration -The .NET Core configuration system is used to determine the state of feature flags. The foundation of this system is `IConfiguration`. Any provider for IConfiguration can be used as the feature state provider for the feature flag library. This enables scenarios ranging from appsettings.json to Azure App Configuration and more. +The .NET Core configuration system is used to determine the state of features. The foundation of this system is `IConfiguration`. Any provider for IConfiguration can be used as the feature state provider for the feature management library. This enables scenarios ranging from appsettings.json to Azure App Configuration and more. ### Feature Flag Declaration -The feature management library supports appsettings.json as a feature flag source since it is a provider for .NET Core's IConfiguration system. Below we have an example of the format used to set up feature flags in a json file. +The feature management library supports appsettings.json as a feature flag source since it is a provider for .NET Core's IConfiguration system. Below we have an example of the format used to set up feature flags in a json file. The example below uses the 2.0.0 configuration schema which is supported in Microsoft.FeatureManagement version 3. For previous schema versions see the configuration [schema details](./docs/schemas/README.md). ``` JavaScript { - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - - // Define feature flags in a json file + // Define feature flags in a json configuration file "FeatureManagement": { - "FeatureT": { - "EnabledFor": [ - { - "Name": "AlwaysOn" - } - ] - }, - "FeatureU": { - "EnabledFor": [] - }, - "FeatureV": { - "EnabledFor": [ - { - "Name": "TimeWindow", - "Parameters": { - "Start": "Wed, 01 May 2019 13:59:59 GMT", - "End": "Mon, 01 July 2019 00:00:00 GMT" + "FeatureFlags": { + "FeatureT": { + "EnabledFor": [ + { + "Name": "AlwaysOn" } - } - ] + ] + }, + "FeatureU": { + "EnabledFor": [ + { + "Name": "TimeWindow", + "Parameters": { + "Start": "Wed, 01 May 2019 13:59:59 GMT", + "End": "Mon, 01 July 2019 00:00:00 GMT" + } + } + ] + } } } } ``` -The `FeatureManagement` section of the json document is used by convention to load feature flag settings. In the section above, we see that we have provided three different features. Features define their feature filters using the `EnabledFor` property. In the feature filters for `FeatureT` we see `AlwaysOn`. This feature filter is built-in and if specified will always enable the feature. The `AlwaysOn` feature filter does not require any configuration so it only has the _Name_ property. `FeatureU` has no filters in its `EnabledFor` property and thus will never be enabled. Any functionality that relies on this feature being enabled will not be accessible as long as the feature filters remain empty. However, as soon as a feature filter is added that enables the feature it can begin working. `FeatureV` specifies a feature filter named `TimeWindow`. This is an example of a configurable feature filter. We can see in the example that the filter has a parameter's property. This is used to configure the filter. In this case, the start and end times for the feature to be active are configured. +The `FeatureManagement` section of the json document is used by convention to load feature flag settings. In the section above, we see that we have provided two different features. Features define their feature filters using the `EnabledFor` property. In the feature filters for `FeatureT` we see `AlwaysOn`. This feature filter is built-in and if specified will always enable the feature. The `AlwaysOn` feature filter does not require any configuration so it only has the _Name_ property. `FeatureU` specifies a feature filter named `TimeWindow`. This is an example of a configurable feature filter. We can see in the example that the filter has a parameter's property. This is used to configure the filter. In this case, the start and end times for the feature to be active are configured. ### On/Off Declaration The following snippet demonstrates an alternative way to define a feature that can be used for on/off features. ``` JavaScript { - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - - // Define feature flags in config file + // Define feature flags in a json configuration file "FeatureManagement": { - "FeatureT": true, // On feature - "FeatureX": false // Off feature + "FeatureFlags": { + "FeatureT": true, // On feature + "FeatureX": false // Off feature + } } } ``` -### Referencing - -To make it easier to reference these feature flags in code, we recommend to define feature flag variables like below. -``` C# -// Define feature flags in an enum -public enum MyFeatureFlags -{ - FeatureT, - FeatureU, - FeatureV -} -``` - ### Service Registration Feature flags rely on .NET Core dependency injection. We can register the feature management services using standard conventions. @@ -115,12 +100,12 @@ using Microsoft.FeatureManagement.FeatureFilters; public class Startup { - public void ConfigureServices(IServiceCollection services) - { - services.AddFeatureManagement() - .AddFeatureFilter() - .AddFeatureFilter(); - } + public void ConfigureServices(IServiceCollection services) + { + services.AddFeatureManagement() + .AddFeatureFilter() + .AddFeatureFilter(); + } } ``` @@ -139,7 +124,7 @@ The basic form of feature management is checking if a feature is enabled and the … IFeatureManager featureManager; … -if (await featureManager.IsEnabledAsync(nameof(MyFeatureFlags.FeatureU))) +if (await featureManager.IsEnabledAsync("FeatureU")) { // Do something } @@ -161,11 +146,15 @@ public class HomeController : Controller } ``` +## ASP.NET Core Integration + +The feature management library provides functionality in ASP.NET Core and MVC to enable common feature flag scenarios in web applications. These capabilities are available by referencing the [Microsoft.FeatureManagement.AspNetCore](https://www.nuget.org/packages/Microsoft.FeatureManagement.AspNetCore/) NuGet package. + ### Controllers and Actions -MVC controller and actions can require that a given feature, or one of any list of features, be enabled in order to execute. This can be done by using a `FeatureGateAttribute`, which can be found in the `Microsoft.FeatureManagement.Mvc` namespace. +MVC controller and actions can require that a given feature flag, or one of any list of feature flags, be enabled in order to execute. This can be done by using a `FeatureGateAttribute`, which can be found in the `Microsoft.FeatureManagement.Mvc` namespace. ``` C# -[FeatureGate(MyFeatureFlags.FeatureX)] +[FeatureGate("FeatureX")] public class HomeController : Controller { … @@ -175,7 +164,7 @@ public class HomeController : Controller The `HomeController` above is gated by "FeatureX". "FeatureX" must be enabled before any action the `HomeController` contains can be executed. ``` C# -[FeatureGate(MyFeatureFlags.FeatureY)] +[FeatureGate("FeatureY")] public IActionResult Index() { return View(); @@ -186,7 +175,7 @@ The `Index` MVC action above requires "FeatureY" to be enabled before it can exe ### Disabled Action Handling -When an MVC controller or action is blocked because none of the features it specifies are enabled, a registered `IDisabledFeaturesHandler` will be invoked. By default, a minimalistic handler is registered which returns HTTP 404. This can be overridden using the `IFeatureManagementBuilder` when registering feature flags. +When an MVC controller or action is blocked because none of the feature flags it specifies are enabled, a registered `IDisabledFeaturesHandler` will be invoked. By default, a minimalistic handler is registered which returns HTTP 404. This can be overridden using the `IFeatureManagementBuilder` when registering feature flags. ``` C# public interface IDisabledFeaturesHandler @@ -200,41 +189,50 @@ public interface IDisabledFeaturesHandler In MVC views `` tags can be used to conditionally render content based on whether a feature is enabled or not. ``` HTML+Razor - +

This can only be seen if 'FeatureX' is enabled.

``` The `` tag requires a tag helper to work. This can be done by adding the feature management tag helper to the _ViewImports.cshtml_ file. + ``` HTML+Razor @addTagHelper *, Microsoft.FeatureManagement.AspNetCore ``` +The feature tag can also be used to show content if a feature is disabled. This is done by using the `negate` attribute. + +``` HTML+Razor + +

This can only be seen if 'FeatureX' is disabled.

+
+``` + ### MVC Filters -MVC action filters can be set up to conditionally execute based on the state of a feature. This is done by registering MVC filters in a feature aware manner. +MVC action filters can be set up to conditionally execute based on the state of a feature flag. This is done by registering MVC filters in a feature flag aware manner. The feature management pipeline supports async MVC Action filters, which implement `IAsyncActionFilter`. ``` C# services.AddMvc(o => { - o.Filters.AddForFeature(nameof(MyFeatureFlags.FeatureV)); + o.Filters.AddForFeature("FeatureV"); }); ``` -The code above adds an MVC filter named `SomeMvcFilter`. This filter is only triggered within the MVC pipeline if the feature it specifies, "FeatureV", is enabled. +The code above adds an MVC filter named `SomeMvcFilter`. This filter is only triggered within the MVC pipeline if the feature flag it specifies, "FeatureV", is enabled. ### Application building -The feature management library can be used to add application branches and middleware that execute conditionally based on feature state. +The feature management library can be used to add application branches and middleware that execute conditionally based on feature flag state. ``` C# -app.UseMiddlewareForFeature(nameof(MyFeatureFlags.FeatureU)); +app.UseMiddlewareForFeature("FeatureU"); ``` -With the above call, the application adds a middleware component that only appears in the request pipeline if the feature "FeatureU" is enabled. If the feature is enabled/disabled during runtime, the middleware pipeline can be changed dynamically. +With the above call, the application adds a middleware component that only appears in the request pipeline if the feature flag "FeatureU" is enabled. If the feature flag is enabled/disabled during runtime, the middleware pipeline can be changed dynamically. -This builds off the more generic capability to branch the entire application based on a feature. +This builds off the more generic capability to branch the entire application based on a feature flag. ``` C# app.UseForFeature(featureName, appBuilder => @@ -243,15 +241,21 @@ app.UseForFeature(featureName, appBuilder => }); ``` -## Implementing a Feature Filter +## Feature Filters + +Feature filters define a scenario for when a feature flag should be enabled. When a feature flag is evaluated for whether it is on or off, its list of feature-filters are traversed until one of the filters decides the feature flag should be enabled. At this point the feature flag is considered enabled and traversal through the feature filters stops. If no feature filter indicates that the feature flag should be enabled, then it will be considered disabled. -Creating a feature filter provides a way to enable features based on criteria that you define. To implement a feature filter, the `IFeatureFilter` interface must be implemented. `IFeatureFilter` has a single method named `EvaluateAsync`. When a feature specifies that it can be enabled for a feature filter, the `EvaluateAsync` method is called. If `EvaluateAsync` returns `true` it means the feature should be enabled. +As an example, a Microsoft Edge browser feature filter could be designed. This feature filter would activate any features it is attached to as long as an HTTP request is coming from Microsoft Edge. + +### Implementing a Feature Filter + +Creating a feature filter provides a way to enable feature flags based on criteria that you define. To implement a feature filter, the `IFeatureFilter` interface must be implemented. `IFeatureFilter` has a single method named `EvaluateAsync`. When a feature flag specifies that it can be enabled for a feature filter, the `EvaluateAsync` method is called. If `EvaluateAsync` returns `true` it means the feature flag should be enabled. Feature filters are registered by the `IFeatureManagementBuilder` when `AddFeatureManagement` is called. These feature filters have access to the services that exist within the service collection that was used to add feature flags. Dependency injection can be used to retrieve these services. ### Parameterized Feature Filters -Some feature filters require parameters to decide whether a feature should be turned on or not. For example a browser feature filter may turn on a feature for a certain set of browsers. It may be desired that Edge and Chrome browsers enable a feature, while Firefox does not. To do this a feature filter can be designed to expect parameters. These parameters would be specified in the feature configuration, and in code would be accessible via the `FeatureFilterEvaluationContext` parameter of `IFeatureFilter.EvaluateAsync`. +Some feature filters require parameters to decide whether a feature flag should be turned on or not. For example a browser feature filter may turn on a feature flag for a certain set of browsers. It may be desired that Edge and Chrome browsers enable a feature flag, while Firefox does not. To do this a feature filter can be designed to expect parameters. These parameters would be specified in the feature configuration, and in code would be accessible via the `FeatureFilterEvaluationContext` parameter of `IFeatureFilter.EvaluateAsync`. ``` C# public class FeatureFilterEvaluationContext @@ -268,7 +272,7 @@ public class FeatureFilterEvaluationContext } ``` -`FeatureFilterEvaluationContext` has a property named `Parameters`. These parameters represent a raw configuration that the feature filter can use to decide how to evaluate whether the feature should be enabled or not. To use the browser feature filter as an example once again, the filter could use `Parameters` to extract a set of allowed browsers that would have been specified for the feature and then check if the request is being sent from one of those browsers. +`FeatureFilterEvaluationContext` has a property named `Parameters`. These parameters represent a raw configuration that the feature filter can use to decide how to evaluate whether the feature flag should be enabled or not. To use the browser feature filter as an example once again, the filter could use `Parameters` to extract a set of allowed browsers that would have been specified for the feature flag and then check if the request is being sent from one of those browsers. ``` C# [FilterAlias("Browser")] @@ -303,7 +307,7 @@ This can be overridden through the use of the `FilterAliasAttribute`. A feature ### Missing Feature Filters -If a feature is configured to be enabled for a specific feature filter and that feature filter hasn't been registered, then an exception will be thrown when the feature is evaluated. The exception can be disabled by using the feature management options. +If a feature flag is configured to be enabled for a specific feature filter and that feature filter hasn't been registered, then an exception will be thrown when the feature flag is evaluated. The exception can be disabled by using the feature management options. ``` C# services.Configure(options => @@ -314,7 +318,7 @@ services.Configure(options => ### Using HttpContext -Feature filters can evaluate whether a feature should be enabled based off the properties of an HTTP Request. This is performed by inspecting the HTTP Context. A feature filter can get a reference to the HTTP Context by obtaining an `IHttpContextAccessor` through dependency injection. +Feature filters can evaluate whether a feature flag should be enabled based off the properties of an HTTP Request. This is performed by inspecting the HTTP Context. A feature filter can get a reference to the HTTP Context by obtaining an `IHttpContextAccessor` through dependency injection. ``` C# public class BrowserFilter : IFeatureFilter @@ -341,7 +345,7 @@ public void ConfigureServices(IServiceCollection services) ## Providing a Context For Feature Evaluation -In console applications there is no ambient context such as `HttpContext` that feature filters can acquire and utilize to check if a feature should be on or off. In this case, applications need to provide an object representing a context into the feature management system for use by feature filters. This is done by using `IFeatureManager.IsEnabledAsync(string featureName, TContext appContext)`. The appContext object that is provided to the feature manager can be used by feature filters to evaluate the state of a feature. +In console applications there is no ambient context such as `HttpContext` that feature filters can acquire and utilize to check if a feature should be on or off. In this case, applications need to provide an object representing a context into the feature management system for use by feature filters. This is done by using `IFeatureManager.IsEnabledAsync(string featureName, TContext appContext)`. The appContext object that is provided to the feature manager can be used by feature filters to evaluate the state of a feature flag. ``` C# MyAppContext context = new MyAppContext @@ -357,7 +361,7 @@ if (await featureManager.IsEnabledAsync(feature, context)) ### Contextual Feature Filters -Contextual feature filters implement the `IContextualFeatureFilter` interface. These special feature filters can take advantage of the context that is passed in when `IFeatureManager.IsEnabledAsync` is called. The `TContext` type parameter in `IContextualFeatureFilter` describes what context type the filter is capable of handling. This allows the developer of a contextual feature filter to describe what is required of those who wish to utilize it. Since every type is a descendant of object, a filter that implements `IContextualFeatureFilter` can be called for any provided context. To illustrate an example of a more specific contextual feature filter, consider a feature that is enabled if an account is in a configured list of enabled accounts. +Contextual feature filters implement the `IContextualFeatureFilter` interface. These special feature filters can take advantage of the context that is passed in when `IFeatureManager.IsEnabledAsync` is called. The `TContext` type parameter in `IContextualFeatureFilter` describes what context type the filter is capable of handling. This allows the developer of a contextual feature filter to describe what is required of those who wish to utilize it. Since every type is a descendant of object, a filter that implements `IContextualFeatureFilter` can be called for any provided context. To illustrate an example of a more specific contextual feature filter, consider a feature flag that is enabled if an account is in a configured list of enabled accounts. ``` C# public interface IAccountContext @@ -376,7 +380,7 @@ class AccountIdFilter : IContextualFeatureFilter } ``` -We can see that the `AccountIdFilter` requires an object that implements `IAccountContext` to be provided to be able to evalute the state of a feature. When using this feature filter, the caller needs to make sure that the passed in object implements `IAccountContext`. +We can see that the `AccountIdFilter` requires an object that implements `IAccountContext` to be provided to be able to evalute the state of a feature flag. When using this feature filter, the caller needs to make sure that the passed in object implements `IAccountContext`. **Note:** Only a single feature filter interface can be implemented by a single type. Trying to add a feature filter that implements more than a single feature filter interface will result in an `ArgumentException`. @@ -388,7 +392,7 @@ Each of the built-in feature filters have their own parameters. Here is the list #### Microsoft.Percentage -This filter provides the capability to enable a feature based on a set percentage. +This filter provides the capability to enable a feature flag based on a set percentage. ``` JavaScript "EnhancedPipeline": { @@ -405,7 +409,7 @@ This filter provides the capability to enable a feature based on a set percentag #### Microsoft.TimeWindow -This filter provides the capability to enable a feature based on a time window. If only `End` is specified, the feature will be considered on until that time. If only start is specified, the feature will be considered on at all points after that time. +This filter provides the capability to enable a feature flag based on a time window. If only `End` is specified, the feature flag will be considered on until that time. If only start is specified, the feature flag will be considered on at all points after that time. ``` JavaScript "EnhancedPipeline": { @@ -423,7 +427,7 @@ This filter provides the capability to enable a feature based on a time window. #### Microsoft.Targeting -This filter provides the capability to enable a feature for a target audience. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section below. The filter parameters include an audience object which describes users, groups, and a default percentage of the user base that should have access to the feature. Each group object that is listed in the target audience must also specify what percentage of the group's members should have access. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will have the feature enabled. +This filter provides the capability to enable a feature flag for a target audience. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section below. The filter parameters include an audience object which describes users, groups, and a default percentage of the user base that should have access to the feature flag. Each group object that is listed in the target audience must also specify what percentage of the group's members should have access. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will have the feature flag enabled. ``` JavaScript "EnhancedPipeline": { @@ -458,6 +462,254 @@ This filter provides the capability to enable a feature for a target audience. A All of the built-in feature filter alias' are in the 'Microsoft' feature filter namespace. This is to prevent conflicts with other feature filters that may share the same simple alias. The segments of a feature filter namespace are split by the '.' character. A feature filter can be referenced by its fully qualified alias such as 'Microsoft.Percentage' or by the last segment which in the case of 'Microsoft.Percentage' is 'Percentage'. +## Dynamic Features + +When new features are being added to an application there may come a time when a feature has multiple different proposed design options. A common pattern when this happens is to do some form of A/B testing. That is, provide a different version of the feature to different segments of the user base, and judge off user interaction which is better. The dynamic feature functionality contained in this library aims to proivde a simplistic, standardized method for developers to perform this form of A/B testing. + + + In the scenario above, the different proposals for the design of a feature are referred to as variants of the feature. The feature itself is referred to as a dynamic feature. The variants of a dynamic feature can have types ranging from object, to string, to integer and so on. There is no limit to the amount of variants a dynamic feature may have. A developer is free to choose what type should be returned when a variant of a dynamic feature is requested. They are also free to choose how many variants are available to select from. + +Each variant of a dynamic feature is associated with a different configuration of the feature. Additionally, each variant of a dynamic feature contains information describing under what circumstances the variant should be used. + +### Consumption + +Dynamic features are accessible through the `IDynamicFeatureManager` interface. + +``` C# +public interface IDynamicFeatureManager +{ + IAsyncEnumerable GetDynamicFeatureNamesAsync(CancellationToken cancellationToken = default); + + ValueTask GetVariantAsync(string dynamicFeature, CancellationToken cancellationToken = default); + + ValueTask GetVariantAsync(string dynamicFeature, TContext context, CancellationToken cancellationToken = default); +} +``` + +The dynamic feature manager performs a resolution process that takes the name of a feature and returns a strongly typed value to represent the variant's value. + +The following steps are performed during the retrieval of a dynamic feature's variant +1. Lookup the configuration of the specified dynamic feature to find the registered variants +2. Assign one of the registered variants to be used. +3. Resolve typed value based off of the assigned variant. + +The dynamic feature manager is made available by using the `AddFeatureManagement` call. Make sure to add any required feature variant assigners referenced by dynamic features within the application by using `AddFeatureVariantAssigner`. + +``` C# +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Assigners; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddFeatureManagement() + .AddFeatureVariantAssigner(); + } +} +``` + +### Usage Example + +One possible example of when variants may be used is in a web application when there is a desire to test different visuals. In the following examples a mock of how one might assign different variants of a web page background to their users is shown. + +``` C# +// +// Modify view based off multiple possible variants +model.BackgroundUrl = featureVariantManager.GetVariantAsync("HomeBackground", cancellationToken); + +return View(model); +``` + +### Dynamic Feature Declaration + +Dynamic features can be configured in a configuration file similarly to feature flags. Instead of being defined in the `FeatureManagement:FeatureFlags` section, they are defined in the `FeatureManagement:DynamicFeatures` section. Additionally, dynamic features have the following properties. + +* Assigner: The assigner that should be used to select which variant should be used any time this feature is accessed. +* Variants: The different variants of the dynamic feature. + * Name: The name of the variant. + * Default: Whether the variant should be used if no variant could be explicitly assigned. One and only one default variant is required. + * ConfigurationReference: A reference to the configuration of the variant to be used as typed options in the application. + * AssignmentParameters: The parameters used in the assignment process to determine if this variant should be used. + +An example of a dynamic feature named "ShoppingCart" is shown below. + +``` JavaScript +{ + "FeatureManagement": + { + "DynamicFeatures": { + "ShoppingCart": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "Big", + "ConfigurationReference": "ShoppingCart:Big", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Alec", + ], + "Groups": [ + ] + } + } + }, + { + "Name": "Small", + "ConfigurationReference": "ShoppingCart:Small", + "AssignmentParameters": { + "Audience": { + "Users": [ + ], + "Groups": [ + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 30 + } + } + } + ] + } + } + }, + "ShoppingCart": { + "Big": { + "Size": 400, + "Color": "green" + }, + "Small": { + "Size": 150, + "Color": "gray" + } + } +} +``` + +In the example above we see the declaration of a dynamic feature in a json configuration file. The dynamic feature is defined in the `FeatureManagement:DynamicFeatures` section of configuration. The name of this dynamic feature is `ShoppingCart`. A dynamic feature must declare a feature variant assigner that should be used to select a variant when requested. In this case the built-in `Targeting` feature variant assigner is used. The dynamic feature has two different variants that are available to the application. One variant is named `Big` and the other is named `Small`. Each variant contains a configuration reference denoted by the `ConfigurationReference` property. The configuration reference is a pointer to a section of application configuration that contains the options that should be used for that variant. The variant also contains assignment parameters denoted by the `AssignmentParameters` property. The assignment parameters are used by the assigner associated with the dynamic feature. The assigner reads the assignment parameters at run time when a variant of the dynamic feature is requested to choose which variant should be returned. + +An application that is configured with this `ShoppingCart` dynamic feature may request the value of a variant of the feature at runtime through the use of `IDynamicFeatureManager.GetVariantAsync`. The dynamic feature uses targeting for [variant assignment](./README.md#Feature-Variant-Assignment) so each of the variants' assignment parameters specify a target audience that should receive the variant. For a walkthrough of how the targeting assigner would choose a variant in this scenario reference the [Microsoft.Targeting Assigner](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) section. When the feature manager chooses one of the variants it resolves the value of the variant by resolving the configuration reference declared in the variant. The example above includes the configuration that is referenced by the `ConfigurationReference` of each variant. + +### Feature Variant Assigners +A feature variant assigner is a component that uses contextual information within an application to decide which feature variant should be chosen when a variant of a dynamic feature is requested. + +### Feature Variant Assignment + +When requesting the value of a dynamic feature the feature manager needs to determine which variant of the feature should be used. The act of choosing which variant should be used is called assignment. A built-in method of assignment is provided that allows the variants of a dynamic features to be assigned to segments of an application's audience. This is the same [targeting](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) strategy introduced by the targeting feature filter. + +To perform assignment the feature manager uses components known as feature variant assigners. Feature variant assigners have the job of choosing which of the variants of a dynamic feature should be used when the value of a dynamic feature is requested. Each variant of a dynamic feature declares assignment parameters so that when an assigner is invoked the assigner can tell under which conditions each variant should be selected. It is possible that an assigner is unable to choose between the list of available variants based off of their configured assignment parameters. In this case the feature manager chooses the **default variant**. The default variant is a variant that is marked explicitly as default. It is required to have a default variant when configuring a dynamic feature in order to handle the possibility that an assigner is not able to select a variant of a dynamic feature. + +### Custom Assignment + +There may come a time when custom criteria is needed to decide which variant of a feature should be assigned when a feature is referenced. This is made possible by an extensibility model that allows the act of assignment to be overriden. Every feature registered in the feature management system that uses feature variants specifies what assigner should be used to choose a variant. + + +``` C# + 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); + } +} +``` + +An example implementation can be found in [this example](./examples/CustomAssignmentConsoleApp/RecurringAssigner.cs). + +### Built-In Feature Variant Assigners + +There is a built-in feature variant assigner that uses targeting that comes with the `Microsoft.FeatureManagement` package. This assigner is not added automatically, but it can be referenced and registered as soon as the package is registered. + +#### Microsoft.Targeting Feature Variant Assigner + +This feature variant assigner provides the capability to assign the variants of a dynamic feature to targeted audiences. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section. + +The assignment parameters used by the targeting feature variant assigner include an audience object which describes users, groups, and a default percentage of the user base that should receive the associated variant. Each group object that is listed in the target audience must also specify what percentage of the group's members should have receive the variant. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will receive the associated variant. + +``` JavaScript +"ShoppingCart": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "Big", + "ConfigurationReference": "ShoppingCart:Big", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Alec", + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ] + } + } + }, + { + "Name": "Small", + "ConfigurationReference": "ShoppingCart:Small", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Susan", + ], + "Groups": [ + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 80 + } + } + } + ] +} +``` + +Based on the configured audiences for the variants included in this feature, if the application is executing under the context of a user named `Alec` then the value of the `Big` variant will be returned. If the application is executing under the context of a user named `Susan` then the value of the `Small` variant will be returned. If a user match does not occur, then group matches are evaluated. If the application is executing under the context of a user in the group `Ring0` then the `Big` variant will be returned. If the user's group is `Ring1` instead, then the user has a 50% chance between being assigned to `Big` or `Small`. If there is not user match nor group match then the default rollout percentage is used. In this case, 80% of unmatched users will get the `Small` variant leaving the other 20% to get the `Big` variant since it is marked as the `Default`. + +Example usage of this assigner can be found in the [FeatureFlagDemo example](./examples/FeatureFlagDemo/Startup.cs#L63). + +When using the targeting feature variant assigner, make sure to register it as well as an implementation of [ITargetingContextAccessor](./README.md#ITargetingContextAccessor). + +``` C# +services.AddSingleton(); + +services.AddFeatureManagement(); + .AddFeatureVariantAssigner(); +``` + +### Variant Resolution + +When a variant of a dynamic feature has been chosen, the feature management system needs to resolve the configuration reference associated with that variant. A feature variant references configuration through its `ConfigurationReference` property. In the "[Configuring a Dynamic Feature](./README.md#Configuring-a-Dynamic-Feature)" section we see a dynamic feature named "ShoppingCart". The first variant of the feature, named "Big", has a configuration reference to the `ShoppingCart:Big` configuration section. The referenced section is shown below. + +``` Javascript + "ShoppingCart": { + "Big": { + "Size": 400, + "Color": "green" + } + } +``` + +The feature management system resolves the configuration reference and binds the resolved configuration section to the type specfied when a variant of a dynamic feature is requested. This is performed by an implementation of the `IFeatureVariantOptionsResolver`. By providing a custom implementation of `IFeatureVariantOptionsResolver`, a developer can resolve configuration references from sources other than configuration. + ## Targeting Targeting is a feature management strategy that enables developers to progressively roll out new features to their user base. The strategy is built on the concept of targeting a set of users known as the target _audience_. An audience is made up of specific users, groups, and a designated percentage of the entire user base. The groups that are included in the audience can be broken down further into percentages of their total members. @@ -481,7 +733,7 @@ To begin using the `TargetingFilter` in an application it must be added to the a The implementation type used for the `ITargetingContextAccessor` service must be implemented by the application that is using the targeting filter. Here is an example setting up feature management in a web application to use the `TargetingFilter` with an implementation of `ITargetingContextAccessor` called `HttpContextTargetingContextAccessor`. -``` +``` C# services.AddSingleton(); services.AddFeatureManagement(); @@ -499,14 +751,14 @@ An example that extracts targeting context information from the application's HT The targeting filter relies on a targeting context to evaluate whether a feature should be turned on. This targeting context contains information such as what user is currently being evaluated, and what groups the user in. In console applications there is typically no ambient context available to flow this information in to the targeting filter, thus it must be passed directly when `FeatureManager.IsEnabledAsync` is called. This is supported through the use of the `ContextualTargetingFilter`. Applications that need to float the targeting context into the feature manager should use this instead of the `TargetingFilter.` -``` +``` C# services.AddFeatureManagement() .AddFeatureFilter(); ``` Since `ContextualTargetingFilter` is an [`IContextualTargetingFilter`](./README.md#Contextual-Feature-Filters), an implementation of `ITargetingContext` must be passed in to `IFeatureManager.IsEnabledAsync` for it to be able to evaluate and turn a feature on. -``` +``` C# IFeatureManager fm; … // userId and groups defined somewhere earlier in application @@ -527,42 +779,48 @@ An example that uses the `ContextualTargetingFilter` in a console application is Options are available to customize how targeting evaluation is performed across all features. These options can be configured when setting up feature management. -``` +``` C# services.Configure(options => { options.IgnoreCase = true; }); ``` +### Targeting in Dynamic Features + +The concept of targeting can be extended to dynamic features. Instead of targeting an audience to see a feature as enabled, the variants of a dynamic feature can be configured to target different audiences. For an in depth view of how this can be done see the [targeting feature variant assigner](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) section. + ## Caching Feature state is provided by the IConfiguration system. Any caching and dynamic updating is expected to be handled by configuration providers. The feature manager asks IConfiguration for the latest value of a feature's state whenever a feature is checked to be enabled. ### Snapshot -There are scenarios which require the state of a feature to remain consistent during the lifetime of a request. The values returned from the standard `IFeatureManager` may change if the `IConfiguration` source which it is pulling from is updated during the request. This can be prevented by using `IFeatureManagerSnapshot`. `IFeatureManagerSnapshot` can be retrieved in the same manner as `IFeatureManager`. `IFeatureManagerSnapshot` implements the interface of `IFeatureManager`, but it caches the first evaluated state of a feature during a request and will return the same state of a feature during its lifetime. +There are scenarios which require the state of a feature to remain consistent during the lifetime of a request. The values returned from the standard `IFeatureManager` may change if the `IConfiguration` source which it is pulling from is updated during the request. This can be prevented by using `IFeatureManagerSnapshot`. `IFeatureManagerSnapshot` can be retrieved in the same manner as `IFeatureManager`. `IFeatureManagerSnapshot` implements the interface of `IFeatureManager`, but it caches the first evaluated state of a feature during a request and will return the same state of a feature during its lifetime. Symmetric functionality is available for dynamic features through the use of `IDynamicFeatureManagerSnapshot`. ## Custom Feature Providers Implementing a custom feature provider enable developers to pull feature flags from sources such as a database or a feature management service. The included feature provider that is used by default pulls feature flags from .NET Core's configuration system. This allows for features to be defined in an [appsettings.json](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1#jcp) file or in configuration providers like [Azure App Configuration](https://docs.microsoft.com/en-us/azure/azure-app-configuration/quickstart-feature-flag-aspnet-core?tabs=core2x). This behavior can be substituted to provide complete control of where feature definitions are read from. -To customize the loading of feature definitions, one must implement the `IFeatureDefinitionProvider` interface. +To customize the loading of feature definitions, one must implement the `IFeatureFlagDefinitionProvider` interface. -``` -public interface IFeatureDefinitionProvider +``` C# +public interface IFeatureFlagDefinitionProvider { - Task GetFeatureDefinitionAsync(string featureName); + Task GetFeatureFlagDefinitionAsync(string featureName, CancellationToken cancellationToken = default); - IAsyncEnumerable GetAllFeatureDefinitionsAsync(); + IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync(CancellationToken cancellationToken = default); } ``` -To use an implementation of `IFeatureDefinitionProvider` it must be added into the service collection before adding feature management. The following example adds an implementation of `IFeatureDefinitionProvider` named `InMemoryFeatureDefinitionProvider`. +To use an implementation of `IFeatureFlagDefinitionProvider` it must be added into the service collection before adding feature management. The following example adds an implementation of `IFeatureFlagDefinitionProvider` named `InMemoryFeatureDefinitionProvider`. -``` -services.AddSingleton() +``` C# +services.AddSingleton() .AddFeatureManagement() ``` +It is also possible to provide custom dynamic feature definitions. This is done by implementing the `IDynamicFeatureDefinitionProvider` interface and registering it as mentioned above. + # Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a diff --git a/docs/schemas/FeatureManagement.v1.0.0.json b/docs/schemas/FeatureManagement.v1.0.0.json new file mode 100644 index 00000000..83f6a1df --- /dev/null +++ b/docs/schemas/FeatureManagement.v1.0.0.json @@ -0,0 +1,75 @@ +{ + "$schema":"http://json-schema.org/draft-07/schema#", + "$id":"http://azconfig.io/schemas/FeatureManagement-DotNet/v1.0.0/FeatureManagement.json", + "title":"Decalaration of features in the Microsoft.FeatureManagement library.", + "definitions": { + "FeatureFlag": { + "type":"object", + "properties": { + "EnabledFor": { + "type":"array", + "items": { + "type":"object", + "required":[ + "Name" + ], + "properties": { + "Name": { + "type":"string" + }, + "Parameters": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "type":"string" + }, + { + "type":"null" + }, + { + "type":"object" + }, + { + "type":"number" + }, + { + "type":"array" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + } + } + } + } + } + } + }, + "type":"object", + "required":[ + ], + "properties": { + "FeatureManagement": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "$ref":"#/definitions/FeatureFlag" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + } + } +} diff --git a/docs/schemas/FeatureManagement.v2.0.0.json b/docs/schemas/FeatureManagement.v2.0.0.json new file mode 100644 index 00000000..a01338b6 --- /dev/null +++ b/docs/schemas/FeatureManagement.v2.0.0.json @@ -0,0 +1,150 @@ +{ + "$schema":"http://json-schema.org/draft-07/schema#", + "$id":"http://azconfig.io/schemas/FeatureManagement-DotNet/v2.0.0/FeatureManagement.json", + "title":"Decalaration of features in the Microsoft.FeatureManagement library.", + "definitions": { + "FeatureFlag": { + "type":"object", + "properties": { + "EnabledFor": { + "type":"array", + "items": { + "type":"object", + "required":[ + "Name" + ], + "properties": { + "Name": { + "type":"string" + }, + "Parameters": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "type":"string" + }, + { + "type":"null" + }, + { + "type":"object" + }, + { + "type":"number" + }, + { + "type":"array" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + } + } + } + } + } + }, + "DynamicFeature": { + "type":"object", + "required": [ + "Assigner", + "Variants" + ], + "properties": { + "Assigner": { + "type":"string" + }, + "Variants": { + "type":"array", + "items": { + "type":"object", + "required":[ + "Name", + "ConfigurationReference" + ], + "properties": { + "Default": { + "type":"boolean" + }, + "Name": { + "type":"string" + }, + "ConfigurationReference": { + "type":"string" + }, + "AssignmentParameters": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "type":"string" + }, + { + "type":"null" + }, + { + "type":"object" + }, + { + "type":"number" + }, + { + "type":"array" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + } + } + } + } + } + } + }, + "type":"object", + "required":[ + ], + "properties": { + "FeatureManagement": { + "type":"object", + "properties": { + "FeatureFlags": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "$ref":"#/definitions/FeatureFlag" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + }, + "DynamicFeatures": { + "type":"object", + "patternProperties": { + "^.*$": { + "$ref":"#/definitions/DynamicFeature" + } + }, + "additionalProperties":false + } + } + } + } +} diff --git a/docs/schemas/README.md b/docs/schemas/README.md new file mode 100644 index 00000000..2b94065e --- /dev/null +++ b/docs/schemas/README.md @@ -0,0 +1,17 @@ +# Configuration Schemas + +This folder contains the schemas for the configuration used by the Microsoft.FeatureManagement library. + +# 1.0.0 + +The [1.0.0 schema](./FeatureManagement.v1.0.0.json) is supported by Microsoft.FeatureManagement version 1.x - 3.x. + +* Allows feature flags to be defined. + +# 2.0.0 + +The [2.0.0 schema](./FeatureManagement.v2.0.0.json) is supported by Microsoft.FeatureManagement version 3.x. + +* Allows dynamic features to be defined. +* Uses a more explicit path to define feature flags. + * "FeatureManagement:FeatureFlags:{flagName}" instead of "FeatureManagement:{flagName}". From 44a782df8f0bc66d9133f1c0e623d5ac5f17eff4 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Tue, 8 Feb 2022 09:05:16 -0800 Subject: [PATCH 13/24] Updated missed "feature name" occurrences. --- README.md | 10 +-- .../FeatureGateAttribute.cs | 62 +++++++++---------- .../FeatureGatedAsyncActionFilter.cs | 14 ++--- .../FeatureFilterEvaluationContext.cs | 8 +-- .../FeatureFilters/PercentageFilter.cs | 6 +- .../PercentageFilterSettings.cs | 2 +- .../FeatureFilters/TimeWindowFilter.cs | 8 +-- .../TimeWindowFilterSettings.cs | 4 +- .../FeatureManagementOptions.cs | 2 +- .../FeatureManager.cs | 2 +- .../IContextualFeatureFilter.cs | 8 +-- .../IFeatureFilter.cs | 4 +- .../IFeatureFilterMetadata.cs | 2 +- .../IFeatureManagementBuilder.cs | 4 +- .../IFeatureManagerSnapshot.cs | 2 +- ...ntextualTargetingFeatureVariantAssigner.cs | 2 +- .../Targeting/ContextualTargetingFilter.cs | 8 +-- .../TargetingFeatureVariantAssigner.cs | 4 +- .../Targeting/TargetingFilter.cs | 6 +- .../Targeting/TargetingFilterSettings.cs | 2 +- .../FeatureManagement.cs | 8 +-- 21 files changed, 84 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 1e64e3bb..e546173b 100644 --- a/README.md +++ b/README.md @@ -261,12 +261,12 @@ Some feature filters require parameters to decide whether a feature flag should public class FeatureFilterEvaluationContext { /// - /// The name of the feature being evaluated. + /// The name of the feature flag being evaluated. /// - public string FeatureName { get; set; } + public string FeaturFlagName { get; set; } /// - /// The settings provided for the feature filter to use when evaluating whether the feature should be enabled. + /// The settings provided for the feature filter to use when evaluating whether the feature flag should be enabled. /// public IConfiguration Parameters { get; set; } } @@ -345,7 +345,7 @@ public void ConfigureServices(IServiceCollection services) ## Providing a Context For Feature Evaluation -In console applications there is no ambient context such as `HttpContext` that feature filters can acquire and utilize to check if a feature should be on or off. In this case, applications need to provide an object representing a context into the feature management system for use by feature filters. This is done by using `IFeatureManager.IsEnabledAsync(string featureName, TContext appContext)`. The appContext object that is provided to the feature manager can be used by feature filters to evaluate the state of a feature flag. +In console applications there is no ambient context such as `HttpContext` that feature filters can acquire and utilize to check if a feature should be on or off. In this case, applications need to provide an object representing a context into the feature management system for use by feature filters. This is done by using `IFeatureManager.IsEnabledAsync(string featureFlagName, TContext appContext)`. The appContext object that is provided to the feature manager can be used by feature filters to evaluate the state of a feature flag. ``` C# MyAppContext context = new MyAppContext @@ -806,7 +806,7 @@ To customize the loading of feature definitions, one must implement the `IFeatur ``` C# public interface IFeatureFlagDefinitionProvider { - Task GetFeatureFlagDefinitionAsync(string featureName, CancellationToken cancellationToken = default); + Task GetFeatureFlagDefinitionAsync(string featureflagName, CancellationToken cancellationToken = default); IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync(CancellationToken cancellationToken = default); } diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs index 427df019..c5d8336f 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs @@ -11,90 +11,90 @@ namespace Microsoft.FeatureManagement.Mvc { /// - /// An attribute that can be placed on MVC actions to require all or any of a set of features to be enabled. If none of the feature are enabled the registered will be invoked. + /// An attribute that can be placed on MVC actions to require all or any of a set of feature flags to be enabled. If none of the feature flags are enabled, the registered will be invoked. /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] public class FeatureGateAttribute : ActionFilterAttribute { /// - /// Creates an attribute that will gate actions unless all the provided feature(s) are enabled. + /// Creates an attribute that will gate actions unless all the provided feature flag(s) are enabled. /// - /// The names of the features that the attribute will represent. - public FeatureGateAttribute(params string[] features) - : this(RequirementType.All, features) + /// The names of the feature flags that the attribute will represent. + public FeatureGateAttribute(params string[] featureFlags) + : this(RequirementType.All, featureFlags) { } /// - /// Creates an attribute that can be used to gate actions. The gate can be configured to require all or any of the provided feature(s) to pass. + /// Creates an attribute that can be used to gate actions. The gate can be configured to require all or any of the provided feature flag(s) to pass. /// - /// Specifies whether all or any of the provided features should be enabled in order to pass. - /// The names of the features that the attribute will represent. - public FeatureGateAttribute(RequirementType requirementType, params string[] features) + /// Specifies whether all or any of the provided feature flags should be enabled in order to pass. + /// The names of the feature flags that the attribute will represent. + public FeatureGateAttribute(RequirementType requirementType, params string[] featureFlags) { - if (features == null || features.Length == 0) + if (featureFlags == null || featureFlags.Length == 0) { - throw new ArgumentNullException(nameof(features)); + throw new ArgumentNullException(nameof(featureFlags)); } - Features = features; + FeatureFlags = featureFlags; RequirementType = requirementType; } /// - /// Creates an attribute that will gate actions unless all the provided feature(s) are enabled. + /// Creates an attribute that will gate actions unless all the provided feature flag(s) are enabled. /// - /// A set of enums representing the features that the attribute will represent. + /// A set of enums representing the feature flags that the attribute will represent. public FeatureGateAttribute(params object[] features) : this(RequirementType.All, features) { } /// - /// Creates an attribute that can be used to gate actions. The gate can be configured to require all or any of the provided feature(s) to pass. + /// Creates an attribute that can be used to gate actions. The gate can be configured to require all or any of the provided feature flag(s) to pass. /// - /// Specifies whether all or any of the provided features should be enabled in order to pass. - /// A set of enums representing the features that the attribute will represent. - public FeatureGateAttribute(RequirementType requirementType, params object[] features) + /// Specifies whether all or any of the provided feature flags should be enabled in order to pass. + /// A set of enums representing the feature flags that the attribute will represent. + public FeatureGateAttribute(RequirementType requirementType, params object[] featureFlags) { - if (features == null || features.Length == 0) + if (featureFlags == null || featureFlags.Length == 0) { - throw new ArgumentNullException(nameof(features)); + throw new ArgumentNullException(nameof(featureFlags)); } var fs = new List(); - foreach (object feature in features) + foreach (object feature in featureFlags) { var type = feature.GetType(); if (!type.IsEnum) { // invalid - throw new ArgumentException("The provided features must be enums.", nameof(features)); + throw new ArgumentException("The provided feature flags must be enums.", nameof(featureFlags)); } fs.Add(Enum.GetName(feature.GetType(), feature)); } - Features = fs; + FeatureFlags = fs; RequirementType = requirementType; } /// - /// The name of the features that the feature attribute will activate for. + /// The name of the feature flags that the feature gate attribute will activate for. /// - public IEnumerable Features { get; } + public IEnumerable FeatureFlags { get; } /// - /// Controls whether any or all features in should be enabled to pass. + /// Controls whether any or all feature flags in should be enabled to pass. /// public RequirementType RequirementType { get; } /// - /// Performs controller action pre-procesing to ensure that at least one of the specified features are enabled. + /// Performs controller action pre-procesing to ensure that at least one of the specified feature flags are enabled. /// /// The context of the MVC action. /// The action delegate. @@ -104,10 +104,10 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context IFeatureManagerSnapshot fm = context.HttpContext.RequestServices.GetRequiredService(); // - // Enabled state is determined by either 'any' or 'all' features being enabled. + // Enabled state is determined by either 'any' or 'all' feature flags being enabled. bool enabled = RequirementType == RequirementType.All ? - await Features.All(async feature => await fm.IsEnabledAsync(feature, context.HttpContext.RequestAborted).ConfigureAwait(false)).ConfigureAwait(false) : - await Features.Any(async feature => await fm.IsEnabledAsync(feature, context.HttpContext.RequestAborted).ConfigureAwait(false)).ConfigureAwait(false); + await FeatureFlags.All(async feature => await fm.IsEnabledAsync(feature, context.HttpContext.RequestAborted).ConfigureAwait(false)).ConfigureAwait(false) : + await FeatureFlags.Any(async feature => await fm.IsEnabledAsync(feature, context.HttpContext.RequestAborted).ConfigureAwait(false)).ConfigureAwait(false); if (enabled) { @@ -117,7 +117,7 @@ await Features.All(async feature => await fm.IsEnabledAsync(feature, context.Htt { IDisabledFeaturesHandler disabledFeaturesHandler = context.HttpContext.RequestServices.GetService() ?? new NotFoundDisabledFeaturesHandler(); - await disabledFeaturesHandler.HandleDisabledFeatures(Features, context).ConfigureAwait(false); + await disabledFeaturesHandler.HandleDisabledFeatures(FeatureFlags, context).ConfigureAwait(false); } } } diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs index 80a68a7e..cbac0b57 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs @@ -9,28 +9,28 @@ namespace Microsoft.FeatureManagement { /// - /// A place holder MVC filter that is used to dynamically activate a filter based on whether a feature is enabled. + /// A place holder MVC filter that is used to dynamically activate a filter based on whether a feature flag is enabled. /// /// The filter that will be used instead of this placeholder. class FeatureGatedAsyncActionFilter : IAsyncActionFilter where T : IAsyncActionFilter { - public FeatureGatedAsyncActionFilter(string featureName) + public FeatureGatedAsyncActionFilter(string featureFlagName) { - if (string.IsNullOrEmpty(featureName)) + if (string.IsNullOrEmpty(featureFlagName)) { - throw new ArgumentNullException(nameof(featureName)); + throw new ArgumentNullException(nameof(featureFlagName)); } - FeatureName = featureName; + FeatureFlagName = featureFlagName; } - public string FeatureName { get; } + public string FeatureFlagName { get; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { IFeatureManager featureManager = context.HttpContext.RequestServices.GetRequiredService(); - if (await featureManager.IsEnabledAsync(FeatureName, context.HttpContext.RequestAborted).ConfigureAwait(false)) + if (await featureManager.IsEnabledAsync(FeatureFlagName, context.HttpContext.RequestAborted).ConfigureAwait(false)) { IServiceProvider serviceProvider = context.HttpContext.RequestServices.GetRequiredService(); diff --git a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs index 5d461c7f..d35c5736 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs @@ -6,17 +6,17 @@ namespace Microsoft.FeatureManagement { /// - /// A context used by to gain insight into what feature is being evaluated and the parameters needed to check whether the feature should be enabled. + /// A context used by to gain insight into what feature flag is being evaluated and the parameters needed to check whether the feature flag should be enabled. /// public class FeatureFilterEvaluationContext { /// - /// The name of the feature being evaluated. + /// The name of the feature flag being evaluated. /// - public string FeatureName { get; set; } + public string FeatureFlagName { get; set; } /// - /// The settings provided for the feature filter to use when evaluating whether the feature should be enabled. + /// The settings provided for the feature filter to use when evaluating whether the feature flag should be enabled. /// public IConfiguration Parameters { get; set; } } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs index c63441cc..7ff23aba 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs @@ -28,11 +28,11 @@ public PercentageFilter(ILoggerFactory loggerFactory) } /// - /// Performs a percentage based evaluation to determine whether a feature is enabled. + /// Performs a percentage based evaluation to determine whether a feature flag is enabled. /// /// The feature evaluation context. /// The cancellation token to cancel the operation. - /// True if the feature is enabled, false otherwise. + /// True if the feature flag is enabled, false otherwise. public Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { PercentageFilterSettings settings = context.Parameters.Get() ?? new PercentageFilterSettings(); @@ -41,7 +41,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, Cancella if (settings.Value < 0) { - _logger.LogWarning($"The '{Alias}' feature filter does not have a valid '{nameof(settings.Value)}' value for feature '{context.FeatureName}'"); + _logger.LogWarning($"The '{Alias}' feature filter does not have a valid '{nameof(settings.Value)}' value for the feature flag '{context.FeatureFlagName}'"); result = false; } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilterSettings.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilterSettings.cs index 81124e28..9a68bc8a 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilterSettings.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilterSettings.cs @@ -9,7 +9,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters public class PercentageFilterSettings { /// - /// A value between 0 and 100 specifying the chance that a feature configured to use the should be enabled. + /// A value between 0 and 100 specifying the chance that a feature flag configured to use the should be enabled. /// public int Value { get; set; } = -1; } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 4c3abe48..0a9282d1 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -10,7 +10,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters { /// - /// A feature filter that can be used to activate a feature based on a time window. + /// A feature filter that can be used to activate a feature flag based on a time window. /// [FilterAlias(Alias)] public class TimeWindowFilter : IFeatureFilter @@ -28,11 +28,11 @@ public TimeWindowFilter(ILoggerFactory loggerFactory) } /// - /// Evaluates whether a feature is enabled based on a configurable time window. + /// Evaluates whether a feature flag is enabled based on a configurable time window. /// /// The feature evaluation context. /// The cancellation token to cancel the operation. - /// True if the feature is enabled, false otherwise. + /// True if the feature flag is enabled, false otherwise. public Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { TimeWindowFilterSettings settings = context.Parameters.Get() ?? new TimeWindowFilterSettings(); @@ -41,7 +41,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, Cancella if (!settings.Start.HasValue && !settings.End.HasValue) { - _logger.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); + _logger.LogWarning($"The '{Alias}' feature filter is not valid for the feature flag '{context.FeatureFlagName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); return Task.FromResult(false); } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs index 41f87cf3..48709dbf 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs @@ -11,13 +11,13 @@ namespace Microsoft.FeatureManagement.FeatureFilters public class TimeWindowFilterSettings { /// - /// An optional start time used to determine when a feature configured to use the feature filter should be enabled. + /// An optional start time used to determine when a feature flag configured to use the feature filter should be enabled. /// If no start time is specified the time window is considered to have already started. /// public DateTimeOffset? Start { get; set; } // E.g. "Wed, 01 May 2019 22:59:30 GMT" /// - /// An optional end time used to determine when a feature configured to use the feature filter should be enabled. + /// An optional end time used to determine when a feature flag configured to use the feature filter should be enabled. /// If no end time is specified the time window is considered to never end. /// public DateTimeOffset? End { get; set; } // E.g. "Wed, 01 May 2019 23:00:00 GMT" diff --git a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs index 9322934e..6e7e67ce 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs @@ -10,7 +10,7 @@ public class FeatureManagementOptions { /// /// Controls the behavior of feature evaluation when dependent feature filters are missing. - /// 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. + /// If missing feature filters are not ignored an exception will be thrown when attempting to evaluate a feature flag that depends on a missing feature filter. /// public bool IgnoreMissingFeatureFilters { get; set; } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index b119d4f8..7fd9002c 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -121,7 +121,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo var context = new FeatureFilterEvaluationContext() { - FeatureName = featureDefinition.Name, + FeatureFlagName = featureDefinition.Name, Parameters = featureFilterConfiguration.Parameters }; diff --git a/src/Microsoft.FeatureManagement/IContextualFeatureFilter.cs b/src/Microsoft.FeatureManagement/IContextualFeatureFilter.cs index 5df783d0..1f1eff68 100644 --- a/src/Microsoft.FeatureManagement/IContextualFeatureFilter.cs +++ b/src/Microsoft.FeatureManagement/IContextualFeatureFilter.cs @@ -7,8 +7,8 @@ namespace Microsoft.FeatureManagement { /// - /// A filter that can be used to determine whether some criteria is met to enable a feature. A feature filter is free to use any criteria available, such as process state or request content. - /// Feature filters can be registered for a given feature and if any feature filter evaluates to true, that feature will be considered enabled. + /// A filter that can be used to determine whether some criteria is met to enable a feature flag. A feature filter is free to use any criteria available, such as process state or request content. + /// Feature filters can be registered for a given feature and if any feature filter evaluates to true, that feature flag will be considered enabled. /// A contextual feature filter can take advantage of contextual data passed in from callers of the feature management system. /// A contextual feature filter will only be executed if a context that is assignable from TContext is available. /// @@ -17,8 +17,8 @@ public interface IContextualFeatureFilter : IFeatureFilterMetadata /// /// Evaluates the feature filter to see if the filter's criteria for being enabled has been satisfied. /// - /// A feature filter evaluation context that contains information that may be needed to evaluate the filter. This context includes configuration, if any, for this filter for the feature being evaluated. - /// A context defined by the application that is passed in to the feature management system to provide contextual information for evaluating a feature's state. + /// A feature filter evaluation context that contains information that may be needed to evaluate the filter. This context includes configuration, if any, for this filter for the feature flag being evaluated. + /// A context defined by the application that is passed in to the feature management system to provide contextual information for evaluating a feature flag's state. /// The cancellation token to cancel the operation. /// True if the filter's criteria has been met, false otherwise. Task EvaluateAsync(FeatureFilterEvaluationContext featureFilterContext, TContext appContext, CancellationToken cancellationToken = default); diff --git a/src/Microsoft.FeatureManagement/IFeatureFilter.cs b/src/Microsoft.FeatureManagement/IFeatureFilter.cs index 416772db..a5b9daab 100644 --- a/src/Microsoft.FeatureManagement/IFeatureFilter.cs +++ b/src/Microsoft.FeatureManagement/IFeatureFilter.cs @@ -7,14 +7,14 @@ namespace Microsoft.FeatureManagement { /// - /// A filter that can be used to determine whether some criteria is met to enable a feature. A feature filter is free to use any criteria available, such as process state or request content. Feature filters can be registered for a given feature and if any feature filter evaluates to true, that feature will be considered enabled. + /// A filter that can be used to determine whether some criteria is met to enable a feature flag. A feature filter is free to use any criteria available, such as process state or request content. Feature filters can be registered for a given feature flag and if any feature filter evaluates to true, that feature flag will be considered enabled. /// public interface IFeatureFilter : IFeatureFilterMetadata { /// /// Evaluates the feature filter to see if the filter's criteria for being enabled has been satisfied. /// - /// A feature filter evaluation context that contains information that may be needed to evaluate the filter. This context includes configuration, if any, for this filter for the feature being evaluated. + /// A feature filter evaluation context that contains information that may be needed to evaluate the filter. This context includes configuration, if any, for this filter for the feature flag being evaluated. /// The cancellation token to cancel the operation. /// True if the filter's criteria has been met, false otherwise. Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken = default); diff --git a/src/Microsoft.FeatureManagement/IFeatureFilterMetadata.cs b/src/Microsoft.FeatureManagement/IFeatureFilterMetadata.cs index bfa75a02..d7df2439 100644 --- a/src/Microsoft.FeatureManagement/IFeatureFilterMetadata.cs +++ b/src/Microsoft.FeatureManagement/IFeatureFilterMetadata.cs @@ -4,7 +4,7 @@ namespace Microsoft.FeatureManagement { /// - /// Marker interface for feature filters used to evaluate the state of a feature + /// Marker interface for feature filters used to evaluate the state of a feature flag. /// public interface IFeatureFilterMetadata { diff --git a/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs b/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs index b2277139..4ac42822 100644 --- a/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs +++ b/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs @@ -16,7 +16,7 @@ public interface IFeatureManagementBuilder IServiceCollection Services { get; } /// - /// Adds a given feature filter to the list of feature filters that will be available to enable features during runtime. + /// Adds a given feature filter to the list of feature filters that will be available to enable feature flags during runtime. /// Possible feature filter metadata types include and /// Only one feature filter interface can be implemented by a single type. /// @@ -25,7 +25,7 @@ public interface IFeatureManagementBuilder IFeatureManagementBuilder AddFeatureFilter() where T : IFeatureFilterMetadata; /// - /// Adds an to be used for storing feature state in a session. + /// Adds an to be used for storing feature flag state in a session. /// /// An implementation of /// The feature management builder. diff --git a/src/Microsoft.FeatureManagement/IFeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/IFeatureManagerSnapshot.cs index 5ffcdd6b..302e54ab 100644 --- a/src/Microsoft.FeatureManagement/IFeatureManagerSnapshot.cs +++ b/src/Microsoft.FeatureManagement/IFeatureManagerSnapshot.cs @@ -4,7 +4,7 @@ namespace Microsoft.FeatureManagement { /// - /// Provides a snapshot of feature state to ensure consistency across a given request. + /// Provides a snapshot of feature flag state to ensure consistency across a given request. /// public interface IFeatureManagerSnapshot : IFeatureManager { diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs index e703da76..f323a3af 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -31,7 +31,7 @@ public ContextualTargetingFeatureVariantAssigner(IOptions - /// Assigns one of the variants configured for a feature based off the provided targeting context. + /// Assigns one of the variants configured for a dynamic 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. diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index a6e4ffd2..6fed444e 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -11,7 +11,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters { /// - /// A feature filter that can be used to activate features for targeted audiences. + /// A feature filter that can be used to activate feature flags for targeted audiences. /// [FilterAlias(Alias)] public class ContextualTargetingFilter : IContextualFeatureFilter @@ -29,13 +29,13 @@ public ContextualTargetingFilter(IOptions options) } /// - /// Performs a targeting evaluation using the provided to determine if a feature should be enabled. + /// Performs a targeting evaluation using the provided to determine if a feature flag should be enabled. /// /// The feature evaluation context. /// The targeting context to use during targeting evaluation. /// The cancellation token to cancel the operation. /// Thrown if either or is null. - /// True if the feature is enabled, false otherwise. + /// True if the feature flag is enabled, false otherwise. public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargetingContext targetingContext, CancellationToken cancellationToken) { if (context == null) @@ -50,7 +50,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargeti TargetingFilterSettings settings = context.Parameters.Get() ?? new TargetingFilterSettings(); - return Task.FromResult(TargetingEvaluator.IsTargeted(settings, targetingContext, _options.IgnoreCase, context.FeatureName)); + return Task.FromResult(TargetingEvaluator.IsTargeted(settings, targetingContext, _options.IgnoreCase, context.FeatureFlagName)); } } } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs index 7068f0b3..542df322 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs @@ -22,7 +22,7 @@ public class TargetingFeatureVariantAssigner : IFeatureVariantAssigner private readonly ILogger _logger; /// - /// Creates a feature variant assigner that uses targeting to assign which of a feature's registered variants should be used. + /// Creates a feature variant assigner that uses targeting to assign which of a dynamic 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. @@ -37,7 +37,7 @@ public TargetingFeatureVariantAssigner(IOptions opti } /// - /// Assigns one of the variants configured for a feature based off the provided targeting context. + /// Assigns one of the variants configured for a dynamic feature based off the provided targeting context. /// /// Contextual information available for use during the assignment process. /// The cancellation token to cancel the operation. diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs index 716ee126..502fdb4a 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs @@ -10,7 +10,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters { /// - /// A feature filter that can be used to activate features for targeted audiences. + /// A feature filter that can be used to activate feature flags for targeted audiences. /// [FilterAlias(Alias)] public class TargetingFilter : IFeatureFilter @@ -34,12 +34,12 @@ public TargetingFilter(IOptions options, ITargetingC } /// - /// Performs a targeting evaluation using the current to determine if a feature should be enabled. + /// Performs a targeting evaluation using the current to determine if a feature flag should be enabled. /// /// The feature evaluation context. /// The cancellation token to cancel the operation. /// Thrown if is null. - /// True if the feature is enabled, false otherwise. + /// True if the feature flag is enabled, false otherwise. public async Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { if (context == null) diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFilterSettings.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFilterSettings.cs index 1fde45d7..3855238a 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFilterSettings.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFilterSettings.cs @@ -9,7 +9,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters public class TargetingFilterSettings { /// - /// The audience that a feature configured to use the should be enabled for. + /// The audience that a feature flag configured to use the should be enabled for. /// public Audience Audience { get; set; } } diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 4f14944f..925c97b6 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -66,7 +66,7 @@ public async Task ReadsConfiguration() Assert.Equal("V1", evaluationContext.Parameters["P1"]); - Assert.Equal(ConditionalFeature, evaluationContext.FeatureName); + Assert.Equal(ConditionalFeature, evaluationContext.FeatureFlagName); return true; }; @@ -153,7 +153,7 @@ public async Task ReadsV1Configuration() Assert.Equal("V1", evaluationContext.Parameters["P1"]); - Assert.Equal(ConditionalFeature, evaluationContext.FeatureName); + Assert.Equal(ConditionalFeature, evaluationContext.FeatureFlagName); return true; }; @@ -291,7 +291,7 @@ public async Task GatesFeatures() // // Enable 1/2 features - testFeatureFilter.Callback = ctx => ctx.FeatureName == Features.ConditionalFeature; + testFeatureFilter.Callback = ctx => ctx.FeatureFlagName == Features.ConditionalFeature; gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); @@ -950,7 +950,7 @@ public async Task CustomFeatureDefinitionProvider() Assert.Equal("V1", evaluationContext.Parameters["P1"]); - Assert.Equal(ConditionalFeature, evaluationContext.FeatureName); + Assert.Equal(ConditionalFeature, evaluationContext.FeatureFlagName); return true; }; From 43ed8c1510f2cb9b46c89450d6b9bc6d774887eb Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Mon, 28 Feb 2022 16:22:30 -0800 Subject: [PATCH 14/24] feature definition -> feature flag --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e546173b..e02d599d 100644 --- a/README.md +++ b/README.md @@ -801,7 +801,7 @@ There are scenarios which require the state of a feature to remain consistent du Implementing a custom feature provider enable developers to pull feature flags from sources such as a database or a feature management service. The included feature provider that is used by default pulls feature flags from .NET Core's configuration system. This allows for features to be defined in an [appsettings.json](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1#jcp) file or in configuration providers like [Azure App Configuration](https://docs.microsoft.com/en-us/azure/azure-app-configuration/quickstart-feature-flag-aspnet-core?tabs=core2x). This behavior can be substituted to provide complete control of where feature definitions are read from. -To customize the loading of feature definitions, one must implement the `IFeatureFlagDefinitionProvider` interface. +To customize the loading of feature flag definitions, one must implement the `IFeatureFlagDefinitionProvider` interface. ``` C# public interface IFeatureFlagDefinitionProvider From 6976c3cc85234801840133b05e2f9c2113d5aad7 Mon Sep 17 00:00:00 2001 From: mrm9084 Date: Fri, 22 Apr 2022 11:03:55 -0700 Subject: [PATCH 15/24] SuggestedReadmeChanges --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e02d599d..e9f10ecc 100644 --- a/README.md +++ b/README.md @@ -516,7 +516,7 @@ One possible example of when variants may be used is in a web application when t ``` C# // // Modify view based off multiple possible variants -model.BackgroundUrl = featureVariantManager.GetVariantAsync("HomeBackground", cancellationToken); +model.BackgroundUrl = dynamicFeatureManager.GetVariantAsync("HomeBackground", cancellationToken); return View(model); ``` @@ -549,7 +549,7 @@ An example of a dynamic feature named "ShoppingCart" is shown below. "AssignmentParameters": { "Audience": { "Users": [ - "Alec", + "Alec" ], "Groups": [ ] @@ -599,13 +599,13 @@ A feature variant assigner is a component that uses contextual information withi ### Feature Variant Assignment -When requesting the value of a dynamic feature the feature manager needs to determine which variant of the feature should be used. The act of choosing which variant should be used is called assignment. A built-in method of assignment is provided that allows the variants of a dynamic features to be assigned to segments of an application's audience. This is the same [targeting](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) strategy introduced by the targeting feature filter. +When requesting the value of a dynamic feature, the feature manager needs to determine which variant of the feature to assign. The act of choosing which of the variants to be used is called "assignment." A built-in method of assignment allows the variants of a dynamic feature to be assigned to segments of an application's audience. This is the same [targeting](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) strategy used by the targeting feature filter. -To perform assignment the feature manager uses components known as feature variant assigners. Feature variant assigners have the job of choosing which of the variants of a dynamic feature should be used when the value of a dynamic feature is requested. Each variant of a dynamic feature declares assignment parameters so that when an assigner is invoked the assigner can tell under which conditions each variant should be selected. It is possible that an assigner is unable to choose between the list of available variants based off of their configured assignment parameters. In this case the feature manager chooses the **default variant**. The default variant is a variant that is marked explicitly as default. It is required to have a default variant when configuring a dynamic feature in order to handle the possibility that an assigner is not able to select a variant of a dynamic feature. +To perform assignments, the feature manager uses components known as feature variant assigners. Feature variant assigners choose which of the variants of a dynamic feature should be assigned when a dynamic feature is requested. Each variant of a dynamic feature defines assignment parameters so that when an assigner is invoked, the assigner can tell under which conditions each variant should be selected. It is possible that an assigner is unable to choose between the list of available variants based on the configured assignment parameters. In this case, the feature manager chooses the **default variant**. The default variant is a variant that is marked explicitly as the default. It is required to have a default variant when configuring a dynamic feature in order to handle the possibility that an assigner is not able to select a variant of a dynamic feature. ### Custom Assignment -There may come a time when custom criteria is needed to decide which variant of a feature should be assigned when a feature is referenced. This is made possible by an extensibility model that allows the act of assignment to be overriden. Every feature registered in the feature management system that uses feature variants specifies what assigner should be used to choose a variant. +There may come a time when custom criteria is needed to decide which variant of a feature should be assigned when a feature is referenced. This is made possible by an extensibility model that allows the act of assignment to be overridden. Every feature registered in the feature management system that uses feature variants specifies what assigner should be used to choose a variant. ``` C# @@ -626,13 +626,13 @@ An example implementation can be found in [this example](./examples/CustomAssign ### Built-In Feature Variant Assigners -There is a built-in feature variant assigner that uses targeting that comes with the `Microsoft.FeatureManagement` package. This assigner is not added automatically, but it can be referenced and registered as soon as the package is registered. +There is a built-in feature variant assigner that uses targeting. It comes with the `Microsoft.FeatureManagement` package. This assigner is not added automatically, but it can be referenced and registered as soon as the package is registered. #### Microsoft.Targeting Feature Variant Assigner This feature variant assigner provides the capability to assign the variants of a dynamic feature to targeted audiences. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section. -The assignment parameters used by the targeting feature variant assigner include an audience object which describes users, groups, and a default percentage of the user base that should receive the associated variant. Each group object that is listed in the target audience must also specify what percentage of the group's members should have receive the variant. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will receive the associated variant. +The assignment parameters used by the targeting feature variant assigner include an audience object which describes the user base that should receive the associated variant. The user base is made of users, groups, and a default percentage of the users. Each group object that is listed in the target audience is required to specify what percentage of the group's members should have receive the variant. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will receive the associated variant. ``` JavaScript "ShoppingCart": { @@ -645,7 +645,7 @@ The assignment parameters used by the targeting feature variant assigner include "AssignmentParameters": { "Audience": { "Users": [ - "Alec", + "Alec" ], "Groups": [ { @@ -682,7 +682,7 @@ The assignment parameters used by the targeting feature variant assigner include } ``` -Based on the configured audiences for the variants included in this feature, if the application is executing under the context of a user named `Alec` then the value of the `Big` variant will be returned. If the application is executing under the context of a user named `Susan` then the value of the `Small` variant will be returned. If a user match does not occur, then group matches are evaluated. If the application is executing under the context of a user in the group `Ring0` then the `Big` variant will be returned. If the user's group is `Ring1` instead, then the user has a 50% chance between being assigned to `Big` or `Small`. If there is not user match nor group match then the default rollout percentage is used. In this case, 80% of unmatched users will get the `Small` variant leaving the other 20% to get the `Big` variant since it is marked as the `Default`. +Based on the configured audiences for the variants included in this feature, if the application is executed under the context of a user named `Alec` then the value of the `Big` variant will be returned. If the application is executing under the context of a user named `Susan` then the value of the `Small` variant will be returned, unless `Susan` is part of one of the groups or `DefaultRolloutPercentage` of `Big`. If a user match does not occur, then group matches are evaluated. If the application is executed under the context of a user in the group `Ring0` then the `Big` variant will be returned. If the user's group is `Ring1` instead, then the user has a 50% chance of being assigned to `Small`. If there is no user match nor group match, then the default rollout percentage is used. In this case, 80% of unmatched users will get the `Small` variant, leaving the other 20% to get the `Big` variant since it is marked as the `Default`. Example usage of this assigner can be found in the [FeatureFlagDemo example](./examples/FeatureFlagDemo/Startup.cs#L63). @@ -697,7 +697,7 @@ services.AddFeatureManagement(); ### Variant Resolution -When a variant of a dynamic feature has been chosen, the feature management system needs to resolve the configuration reference associated with that variant. A feature variant references configuration through its `ConfigurationReference` property. In the "[Configuring a Dynamic Feature](./README.md#Configuring-a-Dynamic-Feature)" section we see a dynamic feature named "ShoppingCart". The first variant of the feature, named "Big", has a configuration reference to the `ShoppingCart:Big` configuration section. The referenced section is shown below. +When a variant of a dynamic feature has been chosen, the feature management system resolves the configuration reference associated with that variant. The resolution is done through the `ConfigurationReference` property. In the "[Configuring a Dynamic Feature](./README.md#Configuring-a-Dynamic-Feature)" section we see a dynamic feature named `ShoppingCart`. The first variant of the feature, is named "Big", and is being referenced in the feature variant as `ShoppingCart:Big` in the configuration reference. The referenced section is shown below. ``` Javascript "ShoppingCart": { @@ -708,7 +708,7 @@ When a variant of a dynamic feature has been chosen, the feature management syst } ``` -The feature management system resolves the configuration reference and binds the resolved configuration section to the type specfied when a variant of a dynamic feature is requested. This is performed by an implementation of the `IFeatureVariantOptionsResolver`. By providing a custom implementation of `IFeatureVariantOptionsResolver`, a developer can resolve configuration references from sources other than configuration. +The feature management system resolves the configuration reference and binds the resolved configuration section to the type specified when a variant of a dynamic feature is requested. This is performed by an implementation of the `IFeatureVariantOptionsResolver`. By providing a custom implementation of `IFeatureVariantOptionsResolver`, a developer can resolve configuration references from sources other than configuration. ## Targeting From e47503da76bd9bb96539eb517075c944d2e62472 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 28 Apr 2022 15:07:06 -0700 Subject: [PATCH 16/24] Updated comments from readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e9f10ecc..5ee6fd7c 100644 --- a/README.md +++ b/README.md @@ -599,7 +599,7 @@ A feature variant assigner is a component that uses contextual information withi ### Feature Variant Assignment -When requesting the value of a dynamic feature, the feature manager needs to determine which variant of the feature to assign. The act of choosing which of the variants to be used is called "assignment." A built-in method of assignment allows the variants of a dynamic feature to be assigned to segments of an application's audience. This is the same [targeting](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) strategy used by the targeting feature filter. +When requesting the value of a dynamic feature, the feature manager needs to determine which variant to use. The act of choosing which of the variants to be used is called "assignment." A built-in method of assignment allows the variants of a dynamic feature to be assigned to segments of an application's audience. This is the same [targeting](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) strategy used by the targeting feature filter. To perform assignments, the feature manager uses components known as feature variant assigners. Feature variant assigners choose which of the variants of a dynamic feature should be assigned when a dynamic feature is requested. Each variant of a dynamic feature defines assignment parameters so that when an assigner is invoked, the assigner can tell under which conditions each variant should be selected. It is possible that an assigner is unable to choose between the list of available variants based on the configured assignment parameters. In this case, the feature manager chooses the **default variant**. The default variant is a variant that is marked explicitly as the default. It is required to have a default variant when configuring a dynamic feature in order to handle the possibility that an assigner is not able to select a variant of a dynamic feature. @@ -632,7 +632,7 @@ There is a built-in feature variant assigner that uses targeting. It comes with This feature variant assigner provides the capability to assign the variants of a dynamic feature to targeted audiences. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section. -The assignment parameters used by the targeting feature variant assigner include an audience object which describes the user base that should receive the associated variant. The user base is made of users, groups, and a default percentage of the users. Each group object that is listed in the target audience is required to specify what percentage of the group's members should have receive the variant. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will receive the associated variant. +The assignment parameters used by the targeting feature variant assigner include an audience object which describes the user base that should receive the associated variant. The audience is made of users, groups, and a percentage of the entire user base. Each group object that is listed in the target audience is required to specify what percentage of the group's members should have receive the variant. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will receive the associated variant. ``` JavaScript "ShoppingCart": { @@ -682,7 +682,7 @@ The assignment parameters used by the targeting feature variant assigner include } ``` -Based on the configured audiences for the variants included in this feature, if the application is executed under the context of a user named `Alec` then the value of the `Big` variant will be returned. If the application is executing under the context of a user named `Susan` then the value of the `Small` variant will be returned, unless `Susan` is part of one of the groups or `DefaultRolloutPercentage` of `Big`. If a user match does not occur, then group matches are evaluated. If the application is executed under the context of a user in the group `Ring0` then the `Big` variant will be returned. If the user's group is `Ring1` instead, then the user has a 50% chance of being assigned to `Small`. If there is no user match nor group match, then the default rollout percentage is used. In this case, 80% of unmatched users will get the `Small` variant, leaving the other 20% to get the `Big` variant since it is marked as the `Default`. +Based on the configured audiences for the variants included in this feature, if the application is executed under the context of a user named `Alec` then the value of the `Big` variant will be returned. If the application is executing under the context of a user named `Susan` then the value of the `Small` variant will be returned. If a user match does not occur, then group matches are evaluated. If the application is executed under the context of a user in the group `Ring0` then the `Big` variant will be returned. If the user's group is `Ring1` instead, then the user has a 50% chance of being assigned to `Small`. If there is no user match nor group match, then the default rollout percentage is used. In this case, 80% of unmatched users will get the `Small` variant, leaving the other 20% to get the `Big` variant since it is marked as the `Default`. Example usage of this assigner can be found in the [FeatureFlagDemo example](./examples/FeatureFlagDemo/Startup.cs#L63). From 686f204e63692b0222e0a28cc7c0cafec0d41d04 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 18 May 2022 10:09:13 -0700 Subject: [PATCH 17/24] Fixed targeting assignment precedence. --- ...ntextualTargetingFeatureVariantAssigner.cs | 119 +++++++++++++----- .../Targeting/ContextualTargetingFilter.cs | 2 +- .../Targeting/TargetingEvaluator.cs | 114 +++++++++++++++-- .../FeatureManagement.cs | 57 +++++++++ tests/Tests.FeatureManagement/Features.cs | 1 + .../Tests.FeatureManagement/appsettings.json | 46 +++++++ 6 files changed, 295 insertions(+), 44 deletions(-) diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs index e703da76..6d1355f2 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -67,69 +67,128 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont FeatureVariant variant = null; - double cumulativePercentage = 0; + var lookup = new Dictionary(); + + // + // Check users + foreach (FeatureVariant v in featureDefinition.Variants) + { + TargetingFilterSettings targetingSettings = v.AssignmentParameters.Get(); + + // + // Put in lookup table to avoid repeatedly creating targeting settings + lookup[v] = targetingSettings; + + if (targetingSettings == null && + v.Default) + { + // + // Valid to omit audience for default variant + continue; + } + + if (!TargetingEvaluator.TryValidateSettings(targetingSettings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); + } + + // + // Check if the user is being targeted directly + if (targetingSettings.Audience.Users != null && + TargetingEvaluator.IsTargeted( + targetingContext, + targetingSettings.Audience.Users, + _options.IgnoreCase)) + { + return new ValueTask(v); + } + } var cumulativeGroups = new Dictionary( _options.IgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + // + // Check Groups foreach (FeatureVariant v in featureDefinition.Variants) { - TargetingFilterSettings targetingSettings = v.AssignmentParameters.Get(); + TargetingFilterSettings targetingSettings = lookup[v]; - if (targetingSettings == null) + if (targetingSettings == null || + targetingSettings.Audience.Groups == null) { - if (v.Default) - { - // - // Valid to omit audience for default variant - continue; - } + continue; } - if (!TargetingEvaluator.TryValidateSettings(targetingSettings, out string paramName, out string reason)) + AccumulateGroups(targetingSettings.Audience, cumulativeGroups); + + if (TargetingEvaluator.IsTargeted( + targetingContext, + targetingSettings.Audience.Groups, + _options.IgnoreCase, + featureDefinition.Name)) { - throw new ArgumentException(reason, paramName); + return new ValueTask(v); } + } + + double cumulativePercentage = 0; - AccumulateAudience(targetingSettings.Audience, cumulativeGroups, ref cumulativePercentage); + // + // Check default rollout percentage + foreach (FeatureVariant v in featureDefinition.Variants) + { + TargetingFilterSettings targetingSettings = lookup[v]; - if (TargetingEvaluator.IsTargeted(targetingSettings, targetingContext, _options.IgnoreCase, featureDefinition.Name)) + if (targetingSettings == null) { - variant = v; + continue; + } + + AccumulateDefaultRollout(targetingSettings.Audience, ref cumulativePercentage); - break; + if (TargetingEvaluator.IsTargeted( + targetingContext, + targetingSettings.Audience.DefaultRolloutPercentage, + _options.IgnoreCase, + featureDefinition.Name)) + { + return new ValueTask(v); } } - return new ValueTask(variant); + return new ValueTask((FeatureVariant)null); } /// - /// Accumulates percentages for groups and the default rollout for an audience. + /// Accumulates percentages for groups of an audience. /// /// The audience that will have its percentages updated based on currently accumulated percentages - /// The current cumulative default rollout percentage /// The current cumulative rollout percentage for each group - private static void AccumulateAudience(Audience audience, Dictionary cumulativeGroups, ref double cumulativeDefaultPercentage) + private static void AccumulateGroups(Audience audience, Dictionary cumulativeGroups) { - if (audience.Groups != null) + foreach (GroupRollout gr in audience.Groups) { - foreach (GroupRollout gr in audience.Groups) - { - double percentage = gr.RolloutPercentage; + double percentage = gr.RolloutPercentage; - if (cumulativeGroups.TryGetValue(gr.Name, out double p)) - { - percentage += p; - } + if (cumulativeGroups.TryGetValue(gr.Name, out double p)) + { + percentage += p; + } - cumulativeGroups[gr.Name] = percentage; + cumulativeGroups[gr.Name] = percentage; - gr.RolloutPercentage = percentage; - } + gr.RolloutPercentage = percentage; } + } + /// + /// Accumulates percentages for the default rollout of an audience. + /// + /// The audience that will have its percentages updated based on currently accumulated percentages + /// The current cumulative default rollout percentage + private static void AccumulateDefaultRollout(Audience audience, ref double cumulativeDefaultPercentage) + { cumulativeDefaultPercentage = cumulativeDefaultPercentage + audience.DefaultRolloutPercentage; audience.DefaultRolloutPercentage = cumulativeDefaultPercentage; diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index a6e4ffd2..47f6b03e 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -50,7 +50,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargeti TargetingFilterSettings settings = context.Parameters.Get() ?? new TargetingFilterSettings(); - return Task.FromResult(TargetingEvaluator.IsTargeted(settings, targetingContext, _options.IgnoreCase, context.FeatureName)); + return Task.FromResult(TargetingEvaluator.IsTargeted(targetingContext, settings, _options.IgnoreCase, context.FeatureName)); } } } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs index 3e33b38b..08b7b685 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -17,7 +17,10 @@ private static StringComparison GetComparisonType(bool ignoreCase) => StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - public static bool IsTargeted(TargetingFilterSettings settings, ITargetingContext targetingContext, bool ignoreCase, string hint) + /// + /// Checks if a provided targeting context should be targeted given targeting settings. + /// + public static bool IsTargeted(ITargetingContext targetingContext, TargetingFilterSettings settings, bool ignoreCase, string hint) { if (settings == null) { @@ -36,29 +39,95 @@ public static bool IsTargeted(TargetingFilterSettings settings, ITargetingContex // // Check if the user is being targeted directly + if (settings.Audience.Users != null && + IsTargeted( + targetingContext, + settings.Audience.Users, + ignoreCase)) + { + return true; + } + + // + // Check if the user is in a group that is being targeted + if (settings.Audience.Groups != null && + IsTargeted( + targetingContext, + settings.Audience.Groups, + ignoreCase, + hint)) + { + return true; + } + + // + // Check if the user is being targeted by a default rollout percentage + return IsTargeted( + targetingContext, + settings.Audience.DefaultRolloutPercentage, + ignoreCase, + hint); + } + + /// + /// Determines if a targeting context is targeted by presence in a list of users + /// + public static bool IsTargeted( + ITargetingContext targetingContext, + IEnumerable users, + bool ignoreCase) + { + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + if (users == null) + { + throw new ArgumentNullException(nameof(users)); + } + if (targetingContext.UserId != null && - settings.Audience.Users != null && - settings.Audience.Users.Any(user => targetingContext.UserId.Equals(user, GetComparisonType(ignoreCase)))) + users.Any(user => targetingContext.UserId.Equals(user, GetComparisonType(ignoreCase)))) { return true; } + return false; + } + + /// + /// Determine if a targeting context is targeted by presence in a group + /// + public static bool IsTargeted( + ITargetingContext targetingContext, + IEnumerable groups, + bool ignoreCase, + string hint) + { + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + if (groups == null) + { + throw new ArgumentNullException(nameof(groups)); + } + 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) + if (targetingContext.Groups != null) { - IEnumerable groups = ignoreCase ? + IEnumerable normalizedGroups = ignoreCase ? targetingContext.Groups.Select(g => g.ToLower()) : targetingContext.Groups; - foreach (string group in groups) + foreach (string group in normalizedGroups) { - GroupRollout groupRollout = settings.Audience.Groups.FirstOrDefault(g => g.Name.Equals(group, GetComparisonType(ignoreCase))); + GroupRollout groupRollout = groups.FirstOrDefault(g => g.Name.Equals(group, GetComparisonType(ignoreCase))); if (groupRollout != null) { @@ -72,11 +141,30 @@ public static bool IsTargeted(TargetingFilterSettings settings, ITargetingContex } } - // - // Check if the user is being targeted by a default rollout percentage + return false; + } + + /// + /// Determines if a targeting context is targeted by presence in a default rollout percentage. + /// + public static bool IsTargeted( + ITargetingContext targetingContext, + double defaultRolloutPercentage, + bool ignoreCase, + string hint) + { + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + string userId = ignoreCase ? + targetingContext.UserId.ToLower() : + targetingContext.UserId; + string defaultContextId = $"{userId}\n{hint}"; - return IsTargeted(defaultContextId, settings.Audience.DefaultRolloutPercentage); + return IsTargeted(defaultContextId, defaultRolloutPercentage); } /// diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 4a935744..b84a970f 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -486,6 +486,63 @@ public async Task VariantTargeting() CancellationToken.None)); } + [Fact] + public async Task TargetingAssignmentPrecedence() + { + 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(); + + IDynamicFeatureManager variantManager = serviceProvider.GetRequiredService(); + + // + // Assigned variant by default rollout due to no higher precedence match + Assert.Equal("def", await variantManager.GetVariantAsync( + Features.PrecedenceTestingFeature, + new TargetingContext + { + UserId = "Patty" + }, + CancellationToken.None)); + + // + // Assigned variant by group due to higher precedence than default rollout + Assert.Equal("ghi", await variantManager.GetVariantAsync( + Features.PrecedenceTestingFeature, + new TargetingContext + { + UserId = "Patty", + Groups = new string[] + { + "Ring0" + } + }, + CancellationToken.None)); + + // + // Assigned variant by user name to higher precedence than default rollout, and group match + Assert.Equal("jkl", await variantManager.GetVariantAsync( + Features.PrecedenceTestingFeature, + new TargetingContext + { + UserId = "Jeff" + }, + CancellationToken.None)); + } + [Fact] public async Task AccumulatesAudience() { diff --git a/tests/Tests.FeatureManagement/Features.cs b/tests/Tests.FeatureManagement/Features.cs index 3e8d791d..be136e16 100644 --- a/tests/Tests.FeatureManagement/Features.cs +++ b/tests/Tests.FeatureManagement/Features.cs @@ -13,5 +13,6 @@ static class Features public const string VariantFeature = "VariantFeature"; public const string ContextualVariantFeature = "ContextualVariantFeature"; public const string ContextualVariantTargetingFeature = "ContextualVariantTargetingFeature"; + public const string PrecedenceTestingFeature = "PrecedenceTestingFeature"; } } diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index 8ca72556..b26dc6a4 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -202,11 +202,57 @@ } } ] + }, + "PrecedenceTestingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 100 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Ref3", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + } + ] + } + } + }, + { + "Name": "V4", + "ConfigurationReference": "Ref4", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } + } + } + ] } } }, "Ref1": "abc", "Ref2": "def", + "Ref3": "ghi", + "Ref4": "jkl", "Percentage15": 15, "Percentage35": 35, "Percentage50": 50 From d4cc815023c9ee2c0508688a44b9e90bdafb138c Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Tue, 24 May 2022 16:41:35 -0700 Subject: [PATCH 18/24] Remove unused variable. Update method signature. Added missing declaration in test. --- .../ContextualTargetingFeatureVariantAssigner.cs | 10 ++++------ tests/Tests.FeatureManagement/FeatureManagement.cs | 6 +++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs index 17af5506..d2de2e10 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -65,8 +65,6 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont nameof(variantAssignmentContext)); } - FeatureVariant variant = null; - var lookup = new Dictionary(); // @@ -120,7 +118,7 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont continue; } - AccumulateGroups(targetingSettings.Audience, cumulativeGroups); + AccumulateGroups(targetingSettings.Audience.Groups, cumulativeGroups); if (TargetingEvaluator.IsTargeted( targetingContext, @@ -163,11 +161,11 @@ public ValueTask AssignVariantAsync(FeatureVariantAssignmentCont /// /// Accumulates percentages for groups of an audience. /// - /// The audience that will have its percentages updated based on currently accumulated percentages + /// The groups that will have their percentages updated based on currently accumulated percentages /// The current cumulative rollout percentage for each group - private static void AccumulateGroups(Audience audience, Dictionary cumulativeGroups) + private static void AccumulateGroups(IEnumerable groups, Dictionary cumulativeGroups) { - foreach (GroupRollout gr in audience.Groups) + foreach (GroupRollout gr in groups) { double percentage = gr.RolloutPercentage; diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 18cb0ce4..f06fc789 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -538,7 +538,11 @@ public async Task TargetingAssignmentPrecedence() Features.PrecedenceTestingFeature, new TargetingContext { - UserId = "Jeff" + UserId = "Jeff", + Groups = new string[] + { + "Ring0" + } }, CancellationToken.None)); } From 8185bad02f1b33c4b20663ce5c92cf9737c7c166 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 25 May 2022 08:46:15 -0700 Subject: [PATCH 19/24] Add null/empty check for feature evaluation. --- .../DynamicFeatureManager.cs | 10 ++++++++++ src/Microsoft.FeatureManagement/FeatureManager.cs | 10 ++++++++++ .../Targeting/TargetingEvaluator.cs | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs b/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs index 4ac7dcd1..17ff110a 100644 --- a/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs +++ b/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs @@ -44,11 +44,21 @@ public async IAsyncEnumerable GetDynamicFeatureNamesAsync([EnumeratorCan public ValueTask GetVariantAsync(string feature, TContext appContext, CancellationToken cancellationToken) { + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + return GetVariantAsync(feature, appContext, true, cancellationToken); } public ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken) { + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + return GetVariantAsync(feature, null, false, cancellationToken); } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 15f5d83d..25784045 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -44,11 +44,21 @@ public FeatureManager( public Task IsEnabledAsync(string feature, CancellationToken cancellationToken) { + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + return IsEnabledAsync(feature, null, false, cancellationToken); } public Task IsEnabledAsync(string feature, TContext appContext, CancellationToken cancellationToken) { + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + return IsEnabledAsync(feature, appContext, true, cancellationToken); } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs index 08b7b685..e5c26ab2 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -115,6 +115,11 @@ public static bool IsTargeted( throw new ArgumentNullException(nameof(groups)); } + if (string.IsNullOrEmpty(hint)) + { + throw new ArgumentNullException(nameof(hint)); + } + string userId = ignoreCase ? targetingContext.UserId.ToLower() : targetingContext.UserId; @@ -158,6 +163,11 @@ public static bool IsTargeted( throw new ArgumentNullException(nameof(targetingContext)); } + if (string.IsNullOrEmpty(hint)) + { + throw new ArgumentNullException(nameof(hint)); + } + string userId = ignoreCase ? targetingContext.UserId.ToLower() : targetingContext.UserId; From 7f84b25ce8655e758429e02699c1c187c1a94c7d Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 25 May 2022 11:47:05 -0700 Subject: [PATCH 20/24] Added .NET 6 as a target framework and removed .NET 5 --- examples/ConsoleApp/ConsoleApp.csproj | 6 +++--- .../CustomAssignmentConsoleApp.csproj | 6 +++--- examples/FeatureFlagDemo/FeatureFlagDemo.csproj | 2 +- examples/TargetingConsoleApp/TargetingConsoleApp.csproj | 6 +++--- .../Microsoft.FeatureManagement.AspNetCore.csproj | 2 +- .../Microsoft.FeatureManagement.csproj | 2 +- tests/Tests.FeatureManagement/FeatureManagement.cs | 2 +- .../Tests.FeatureManagement.csproj | 8 ++++---- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/examples/ConsoleApp/ConsoleApp.csproj b/examples/ConsoleApp/ConsoleApp.csproj index c638b298..1230fedf 100644 --- a/examples/ConsoleApp/ConsoleApp.csproj +++ b/examples/ConsoleApp/ConsoleApp.csproj @@ -2,13 +2,13 @@ Exe - net5.0 + net6.0 Consoto.Banking.AccountService - - + + diff --git a/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj index 68f2c659..1230fedf 100644 --- a/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj +++ b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj @@ -2,13 +2,13 @@ Exe - net5.0 + net6.0 Consoto.Banking.AccountService - - + + diff --git a/examples/FeatureFlagDemo/FeatureFlagDemo.csproj b/examples/FeatureFlagDemo/FeatureFlagDemo.csproj index 3bf22dc9..7ab458e3 100644 --- a/examples/FeatureFlagDemo/FeatureFlagDemo.csproj +++ b/examples/FeatureFlagDemo/FeatureFlagDemo.csproj @@ -1,7 +1,7 @@  - net5.0 + net6.0 diff --git a/examples/TargetingConsoleApp/TargetingConsoleApp.csproj b/examples/TargetingConsoleApp/TargetingConsoleApp.csproj index c638b298..1230fedf 100644 --- a/examples/TargetingConsoleApp/TargetingConsoleApp.csproj +++ b/examples/TargetingConsoleApp/TargetingConsoleApp.csproj @@ -2,13 +2,13 @@ Exe - net5.0 + net6.0 Consoto.Banking.AccountService - - + + diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj index 821ff397..9cd08636 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj +++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj @@ -11,7 +11,7 @@ - netstandard2.0;netcoreapp3.1;net5.0 + netstandard2.0;netcoreapp3.1;net6.0 true false ..\..\build\Microsoft.FeatureManagement.snk diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index 620a1264..6d74225e 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -11,7 +11,7 @@ - netstandard2.0;netcoreapp3.1;net5.0 + netstandard2.0;netcoreapp3.1;net6.0 true false ..\..\build\Microsoft.FeatureManagement.snk diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index f06fc789..886e03f1 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -1137,7 +1137,7 @@ public async Task ThreadsafeSnapshot() private static void DisableEndpointRouting(MvcOptions options) { -#if NET5_0 || NETCOREAPP3_1 +#if NET6_0 || NETCOREAPP3_1 // // Endpoint routing is disabled by default in .NET Core 2.1 since it didn't exist. options.EnableEndpointRouting = false; diff --git a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj index 460c52c7..371c28d7 100644 --- a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj +++ b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1;netcoreapp3.1;net5.0 + netcoreapp2.1;netcoreapp3.1;net6.0 false 8.0 @@ -40,10 +40,10 @@ - + - - + + From 2fcb0b1428ab9bf3f084ab62636fdbd1440d92d2 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 25 May 2022 11:56:28 -0700 Subject: [PATCH 21/24] Update Azure App Configuration provider reference in example project to pull features using v2 schema. --- examples/FeatureFlagDemo/FeatureFlagDemo.csproj | 2 +- examples/FeatureFlagDemo/Program.cs | 5 +++++ examples/FeatureFlagDemo/Startup.cs | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/examples/FeatureFlagDemo/FeatureFlagDemo.csproj b/examples/FeatureFlagDemo/FeatureFlagDemo.csproj index 3bf22dc9..33b04b4f 100644 --- a/examples/FeatureFlagDemo/FeatureFlagDemo.csproj +++ b/examples/FeatureFlagDemo/FeatureFlagDemo.csproj @@ -5,7 +5,7 @@ - + diff --git a/examples/FeatureFlagDemo/Program.cs b/examples/FeatureFlagDemo/Program.cs index c9aff31a..ef411bc9 100644 --- a/examples/FeatureFlagDemo/Program.cs +++ b/examples/FeatureFlagDemo/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -12,6 +13,10 @@ public class Program { public static void Main(string[] args) { + // + // Opt-in to use new schema with features received from Azure App Configuration + Environment.SetEnvironmentVariable("AZURE_APP_CONFIGURATION_FEATURE_MANAGEMENT_SCHEMA_VERSION", "2"); + CreateWebHostBuilder(args).Build().Run(); } diff --git a/examples/FeatureFlagDemo/Startup.cs b/examples/FeatureFlagDemo/Startup.cs index 11adfb93..8ddba6a1 100644 --- a/examples/FeatureFlagDemo/Startup.cs +++ b/examples/FeatureFlagDemo/Startup.cs @@ -63,6 +63,8 @@ public void ConfigureServices(IServiceCollection services) .AddFeatureVariantAssigner() .UseDisabledFeaturesHandler(new FeatureNotEnabledDisabledHandler()); + services.AddAzureAppConfiguration(); + services.AddMvc(o => { o.Filters.AddForFeature(nameof(MyFeatureFlags.EnhancedPipeline)); From d06f7e7bb3ba1a15fde5367817368c433ffd0ddd Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Tue, 7 Jun 2022 11:20:12 -0700 Subject: [PATCH 22/24] Build with .NET 6 (#186) * Build with .NET 6 * Update build step name. --- .pipelines/pipeline.user.windows.yml | 17 ++++++++++++----- build.cmd | 8 -------- build.ps1 | 6 ++++-- build/CallPowerShell.cmd | 1 + build/ChoosePowerShell.cmd | 23 ----------------------- build/install-dotnet.ps1 | 8 ++++++++ build/resolve-dotnet.ps1 | 14 ++++++++++++++ pack.cmd | 8 -------- pack.ps1 | 4 +++- test.cmd | 3 --- test.ps1 | 7 +++++++ 11 files changed, 49 insertions(+), 50 deletions(-) delete mode 100644 build.cmd create mode 100644 build/CallPowerShell.cmd delete mode 100644 build/ChoosePowerShell.cmd create mode 100644 build/install-dotnet.ps1 create mode 100644 build/resolve-dotnet.ps1 delete mode 100644 pack.cmd delete mode 100644 test.cmd create mode 100644 test.ps1 diff --git a/.pipelines/pipeline.user.windows.yml b/.pipelines/pipeline.user.windows.yml index 28c87dcb..4750d66c 100644 --- a/.pipelines/pipeline.user.windows.yml +++ b/.pipelines/pipeline.user.windows.yml @@ -7,16 +7,21 @@ environment: restore: commands: + - !!defaultcommand + name: 'Install .NET' + command: 'build/CallPowerShell.cmd' + arguments: 'build/install-dotnet.ps1 -RestoreOnly' - !!defaultcommand name: 'Restore' - command: 'build.cmd' - arguments: '-RestoreOnly' + command: 'build/CallPowerShell.cmd' + arguments: 'build.ps1 -RestoreOnly' build: commands: - !!buildcommand name: 'Dotnet Build' - command: 'build.cmd' + command: 'build/CallPowerShell.cmd' + arguments: 'build.ps1' logs: - from: 'buildlogs' to: 'Build Logs' @@ -32,7 +37,8 @@ package: commands: - !!buildcommand name: 'Dotnet Pack' - command: 'pack.cmd' + command: 'build/CallPowerShell.cmd' + arguments: 'pack.ps1' logs: - from: 'buildlogs' to: 'Build Logs' @@ -48,7 +54,8 @@ test: commands: - !!testcommand name: 'Dotnet Test' - command: 'test.cmd' + command: 'build/CallPowerShell.cmd' + arguments: 'test.ps1' fail_on_stderr: false testresults: - title: 'Unit Tests' diff --git a/build.cmd b/build.cmd deleted file mode 100644 index df877382..00000000 --- a/build.cmd +++ /dev/null @@ -1,8 +0,0 @@ -call %~dp0build\ChoosePowerShell.cmd - -IF %ERRORLEVEL% NEQ 0 ( - - exit /B 1 -) - -%PowerShell% "%~dp0build.ps1" %* \ No newline at end of file diff --git a/build.ps1 b/build.ps1 index 02d792fd..cd90b5c9 100644 --- a/build.ps1 +++ b/build.ps1 @@ -26,15 +26,17 @@ if ((Test-Path -Path $LogDirectory) -ne $true) { New-Item -ItemType Directory -Path $LogDirectory | Write-Verbose } +$dotnet = & "$PSScriptRoot/build/resolve-dotnet.ps1" + if ($RestoreOnly) { # Restore - dotnet restore "$Solution" + & $dotnet restore "$Solution" } else { # Build - dotnet build -c $BuildConfig "$Solution" | Tee-Object -FilePath "$LogDirectory\build.log" + & $dotnet build -c $BuildConfig "$Solution" | Tee-Object -FilePath "$LogDirectory\build.log" } exit $LASTEXITCODE diff --git a/build/CallPowerShell.cmd b/build/CallPowerShell.cmd new file mode 100644 index 00000000..fd8eb280 --- /dev/null +++ b/build/CallPowerShell.cmd @@ -0,0 +1 @@ +PowerShell %~dp0..\%* \ No newline at end of file diff --git a/build/ChoosePowerShell.cmd b/build/ChoosePowerShell.cmd deleted file mode 100644 index 45798129..00000000 --- a/build/ChoosePowerShell.cmd +++ /dev/null @@ -1,23 +0,0 @@ -:: where.exe does not exist in windows container, application specific test must be used to check for existence - -pwsh -Command Write-Host "a" - -IF %ERRORLEVEL% == 0 ( - - set PowerShell=pwsh - - exit /B 0 -) - -PowerShell -Command Write-Host "a" - -IF %ERRORLEVEL% == 0 ( - - set PowerShell=PowerShell - - exit /B 0 -) - -echo Could not find a suitable PowerShell executable. - -EXIT /B 1 diff --git a/build/install-dotnet.ps1 b/build/install-dotnet.ps1 new file mode 100644 index 00000000..7088094f --- /dev/null +++ b/build/install-dotnet.ps1 @@ -0,0 +1,8 @@ +# Installs .NET Core 2.1 and .NET 6 for CI/CD environment +# see: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script#examples + +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; + +&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Version 2.1.816 + +&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) diff --git a/build/resolve-dotnet.ps1 b/build/resolve-dotnet.ps1 new file mode 100644 index 00000000..79219a8b --- /dev/null +++ b/build/resolve-dotnet.ps1 @@ -0,0 +1,14 @@ +# Resolves dotnet execution path +# Locations considered include dotnet install script default location and somewhere on path +$CI_CD_INSTALL_PATH = "$env:LOCALAPPDATA\Microsoft\dotnet\dotnet.exe" + +if (Test-Path $CI_CD_INSTALL_PATH) +{ + $CI_CD_INSTALL_PATH + + return +} + +$dotnet = Get-Command dotnet.exe -ErrorAction Stop + +$dotnet.Source \ No newline at end of file diff --git a/pack.cmd b/pack.cmd deleted file mode 100644 index 209cd5e0..00000000 --- a/pack.cmd +++ /dev/null @@ -1,8 +0,0 @@ -call %~dp0build\ChoosePowerShell.cmd - -IF %ERRORLEVEL% NEQ 0 ( - - exit /B 1 -) - -%PowerShell% "%~dp0pack.ps1" %* \ No newline at end of file diff --git a/pack.ps1 b/pack.ps1 index a05e485c..12434211 100644 --- a/pack.ps1 +++ b/pack.ps1 @@ -29,12 +29,14 @@ if ((Test-Path -Path $LogDirectory) -ne $true) { New-Item -ItemType Directory -Path $LogDirectory | Write-Verbose } +$dotnet = & "$PSScriptRoot/build/resolve-dotnet.ps1" + foreach ($project in $targetProjects) { $projectPath = "$PSScriptRoot\src\$project\$project.csproj" $outputPath = "$PSScriptRoot\src\$project\$PublishRelativePath" - dotnet pack -c $BuildConfig -o "$outputPath" "$projectPath" --no-build | Tee-Object -FilePath "$LogDirectory\build.log" + & $dotnet pack -c $BuildConfig -o "$outputPath" "$projectPath" --no-build | Tee-Object -FilePath "$LogDirectory\build.log" } exit $LASTEXITCODE diff --git a/test.cmd b/test.cmd deleted file mode 100644 index 6b02a979..00000000 --- a/test.cmd +++ /dev/null @@ -1,3 +0,0 @@ -cd /D "%~dp0" - -dotnet test tests\Tests.FeatureManagement\Tests.FeatureManagement.csproj --logger trx || exit /b 1 diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 00000000..8cf5fdc2 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" + +$dotnet = & "$PSScriptRoot/build/resolve-dotnet.ps1" + +& $dotnet test "$PSScriptRoot\tests\Tests.FeatureManagement\Tests.FeatureManagement.csproj" --logger trx + +exit $LASTEXITCODE \ No newline at end of file From 1f69bf98dfeb0bedb1b4f662deaa5739e114a1c5 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 8 Jun 2022 13:17:09 -0700 Subject: [PATCH 23/24] Add link to v2 readme. (#184) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 5ee6fd7c..071efbcd 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ Here are some of the benefits of using this library: **API Reference**: https://go.microsoft.com/fwlink/?linkid=2091700 +## Content Version + +The content of this README relates to version 3 of the Microsoft.FeatureManagement library. + +The README for version 2 can be found [here](https://github.com/microsoft/FeatureManagement-Dotnet/tree/release/v2#aspnet-core-feature-flags). + ## Index * [Feature Flags](./README.md#Feature-Flags) * [Feature Flag Declaration](./README.md#Feature-Flag-Declaration) From f0f8ca200a0563573ba715f1fb63f67f6691d18c Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 8 Jun 2022 14:13:42 -0700 Subject: [PATCH 24/24] Throw exception if feature management schemas are mixed to avoid unintentionally hiding feature flags. (#187) --- .../ConfigurationDynamicFeatureDefinitionProvider.cs | 2 +- .../ConfigurationFeatureFlagDefinitionProvider.cs | 12 ++++++++++++ .../FeatureManagementError.cs | 7 ++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs index 82794ef6..4c50702e 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs @@ -19,8 +19,8 @@ namespace Microsoft.FeatureManagement /// sealed class ConfigurationDynamicFeatureDefinitionProvider : IDynamicFeatureDefinitionProvider, IDisposable { + public const string DynamicFeatureDefinitionsSectionName = "DynamicFeatures"; private const string FeatureManagementSectionName = "FeatureManagement"; - private const string DynamicFeatureDefinitionsSectionName= "DynamicFeatures"; private const string FeatureVariantsSectionName = "Variants"; private readonly IConfiguration _configuration; private readonly ConcurrentDictionary _dynamicFeatureDefinitions; diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs index d302cff2..e3aea38d 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs @@ -186,6 +186,18 @@ private IEnumerable GetFeatureFlagDefinitionSections() IConfigurationSection featureFlagsSection = featureManagementChildren.FirstOrDefault(s => s.Key == FeatureFlagDefinitionsSectionName); + // + // Check for mixed schema to avoid confusing scenario where feature flags defined in separate sources with different schemas don't mix. + if (featureFlagsSection != null && + featureManagementChildren.Any(section => + !section.Key.Equals(FeatureFlagDefinitionsSectionName) && + !section.Key.Equals(ConfigurationDynamicFeatureDefinitionProvider.DynamicFeatureDefinitionsSectionName))) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfiguration, + "Detected feature flags defined using different feature management schemas."); + } + // // Support backward compatability where feature flag definitions were directly under the feature management section return featureFlagsSection == null ? diff --git a/src/Microsoft.FeatureManagement/FeatureManagementError.cs b/src/Microsoft.FeatureManagement/FeatureManagementError.cs index 8eb6208a..a8deb232 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementError.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementError.cs @@ -61,6 +61,11 @@ public enum FeatureManagementError /// /// A dynamic feature does not have a default feature variant configured. /// - MissingDefaultFeatureVariant + MissingDefaultFeatureVariant, + + /// + /// A configuration error is present in the feature management system. + /// + InvalidConfiguration } }