diff --git a/examples/FeatureFlagDemo/appsettings.json b/examples/FeatureFlagDemo/appsettings.json index d0a37270..73c707a8 100644 --- a/examples/FeatureFlagDemo/appsettings.json +++ b/examples/FeatureFlagDemo/appsettings.json @@ -5,7 +5,7 @@ } }, "AllowedHosts": "*", - + // Define feature flags in config file "FeatureManagement": { @@ -36,7 +36,7 @@ } ] }, - "CustomViewData": { + "CustomViewData": { "EnabledFor": [ { "Name": "Browser", diff --git a/src/Microsoft.FeatureManagement/Allocation/Allocation.cs b/src/Microsoft.FeatureManagement/Allocation/Allocation.cs new file mode 100644 index 00000000..b10ce33d --- /dev/null +++ b/src/Microsoft.FeatureManagement/Allocation/Allocation.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; + +namespace Microsoft.FeatureManagement +{ + /// + /// The definition of how variants are allocated for a feature. + /// + public class Allocation + { + /// + /// The default variant used if the feature is enabled and no variant is assigned. + /// + public string DefaultWhenEnabled { get; set; } + + /// + /// The default variant used if the feature is disabled. + /// + public string DefaultWhenDisabled { get; set; } + + /// + /// Describes a mapping of user ids to variants. + /// + public IEnumerable User { get; set; } + + /// + /// Describes a mapping of group names to variants. + /// + public IEnumerable Group { get; set; } + + /// + /// Allocates percentiles of user base to variants. + /// + public IEnumerable Percentile { get; set; } + + /// + /// Maps users to the same percentile across multiple feature flags. + /// + public string Seed { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/Allocation/GroupAllocation.cs b/src/Microsoft.FeatureManagement/Allocation/GroupAllocation.cs new file mode 100644 index 00000000..6787f8c8 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Allocation/GroupAllocation.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using System.Collections.Generic; + +namespace Microsoft.FeatureManagement +{ + /// + /// The definition of a group allocation. + /// + public class GroupAllocation + { + /// + /// The name of the variant. + /// + public string Variant { get; set; } + + /// + /// A list of groups that can be assigned this variant. + /// + public IEnumerable Groups { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/Allocation/PercentileAllocation.cs b/src/Microsoft.FeatureManagement/Allocation/PercentileAllocation.cs new file mode 100644 index 00000000..341d7d5d --- /dev/null +++ b/src/Microsoft.FeatureManagement/Allocation/PercentileAllocation.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.FeatureManagement +{ + /// + /// The definition of a percentile allocation. + /// + public class PercentileAllocation + { + /// + /// The name of the variant. + /// + public string Variant { get; set; } + + /// + /// The inclusive lower bound of the percentage to which the variant will be assigned. + /// + public double From { get; set; } + + /// + /// The exclusive upper bound of the percentage to which the variant will be assigned. + /// + public double To { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/Allocation/UserAllocation.cs b/src/Microsoft.FeatureManagement/Allocation/UserAllocation.cs new file mode 100644 index 00000000..9443de7a --- /dev/null +++ b/src/Microsoft.FeatureManagement/Allocation/UserAllocation.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using System.Collections.Generic; + +namespace Microsoft.FeatureManagement +{ + /// + /// The definition of a user allocation. + /// + public class UserAllocation + { + /// + /// The name of the variant. + /// + public string Variant { get; set; } + + /// + /// A list of users that will be assigned this variant. + /// + public IEnumerable Users { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 4774fd5e..8e84aecb 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -21,13 +22,13 @@ sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionProvider // IFeatureDefinitionProviderCacheable interface is only used to mark this provider as cacheable. This allows our test suite's // provider to be marked for caching as well. - private const string FeatureFiltersSectionName = "EnabledFor"; - private const string RequirementTypeKeyword = "RequirementType"; private readonly IConfiguration _configuration; private readonly ConcurrentDictionary _definitions; private IDisposable _changeSubscription; private int _stale = 0; + const string ParseValueErrorString = "Invalid setting '{0}' with value '{1}' for feature '{2}'."; + public ConfigurationFeatureDefinitionProvider(IConfiguration configuration) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); @@ -136,13 +137,19 @@ We support RequirementType requirementType = RequirementType.Any; + FeatureStatus featureStatus = FeatureStatus.Conditional; + + Allocation allocation = null; + + List variants = null; + var enabledFor = new List(); string val = configurationSection.Value; // configuration[$"{featureName}"]; if (string.IsNullOrEmpty(val)) { - val = configurationSection[FeatureFiltersSectionName]; + val = configurationSection[ConfigurationFields.FeatureFiltersSectionName]; } if (!string.IsNullOrEmpty(val) && bool.TryParse(val, out bool result) && result) @@ -160,57 +167,173 @@ We support } else { - string rawRequirementType = configurationSection[RequirementTypeKeyword]; + string rawRequirementType = configurationSection[ConfigurationFields.RequirementType]; - // - // If requirement type is specified, parse it and set the requirementType variable - if (!string.IsNullOrEmpty(rawRequirementType) && !Enum.TryParse(rawRequirementType, ignoreCase: true, out requirementType)) + string rawFeatureStatus = configurationSection[ConfigurationFields.FeatureStatus]; + + if (!string.IsNullOrEmpty(rawRequirementType)) { - throw new FeatureManagementException( - FeatureManagementError.InvalidConfigurationSetting, - $"Invalid requirement type '{rawRequirementType}' for feature '{configurationSection.Key}'."); + requirementType = ParseEnum(configurationSection.Key, rawRequirementType, ConfigurationFields.RequirementType); } - IEnumerable filterSections = configurationSection.GetSection(FeatureFiltersSectionName).GetChildren(); + if (!string.IsNullOrEmpty(rawFeatureStatus)) + { + featureStatus = ParseEnum(configurationSection.Key, rawFeatureStatus, ConfigurationFields.FeatureStatus); + } + + IEnumerable filterSections = configurationSection.GetSection(ConfigurationFields.FeatureFiltersSectionName).GetChildren(); foreach (IConfigurationSection section in filterSections) { // // 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[ConfigurationFields.NameKeyword])) { enabledFor.Add(new FeatureFilterConfiguration() { - Name = section[nameof(FeatureFilterConfiguration.Name)], - Parameters = new ConfigurationWrapper(section.GetSection(nameof(FeatureFilterConfiguration.Parameters))) + Name = section[ConfigurationFields.NameKeyword], + Parameters = new ConfigurationWrapper(section.GetSection(ConfigurationFields.FeatureFilterConfigurationParameters)) }); } } + + IConfigurationSection allocationSection = configurationSection.GetSection(ConfigurationFields.AllocationSectionName); + + if (allocationSection.Exists()) + { + allocation = new Allocation() + { + DefaultWhenDisabled = allocationSection[ConfigurationFields.AllocationDefaultWhenDisabled], + DefaultWhenEnabled = allocationSection[ConfigurationFields.AllocationDefaultWhenEnabled], + User = allocationSection.GetSection(ConfigurationFields.UserAllocationSectionName).GetChildren().Select(userAllocation => + { + return new UserAllocation() + { + Variant = userAllocation[ConfigurationFields.AllocationVariantKeyword], + Users = userAllocation.GetSection(ConfigurationFields.UserAllocationUsers).Get>() + }; + }), + Group = allocationSection.GetSection(ConfigurationFields.GroupAllocationSectionName).GetChildren().Select(groupAllocation => + { + return new GroupAllocation() + { + Variant = groupAllocation[ConfigurationFields.AllocationVariantKeyword], + Groups = groupAllocation.GetSection(ConfigurationFields.GroupAllocationGroups).Get>() + }; + }), + Percentile = allocationSection.GetSection(ConfigurationFields.PercentileAllocationSectionName).GetChildren().Select(percentileAllocation => + { + double from = 0; + + double to = 0; + + string rawFrom = percentileAllocation[ConfigurationFields.PercentileAllocationFrom]; + + string rawTo = percentileAllocation[ConfigurationFields.PercentileAllocationTo]; + + if (!string.IsNullOrEmpty(rawFrom)) + { + from = ParseDouble(configurationSection.Key, rawFrom, ConfigurationFields.PercentileAllocationFrom); + } + + if (!string.IsNullOrEmpty(rawTo)) + { + to = ParseDouble(configurationSection.Key, rawTo, ConfigurationFields.PercentileAllocationTo); + } + + return new PercentileAllocation() + { + Variant = percentileAllocation[ConfigurationFields.AllocationVariantKeyword], + From = from, + To = to + }; + }), + Seed = allocationSection[ConfigurationFields.AllocationSeed] + }; + } + + IEnumerable variantsSections = configurationSection.GetSection(ConfigurationFields.VariantsSectionName).GetChildren(); + variants = new List(); + + foreach (IConfigurationSection section in variantsSections) + { + if (int.TryParse(section.Key, out int _) && !string.IsNullOrEmpty(section[ConfigurationFields.NameKeyword])) + { + StatusOverride statusOverride = StatusOverride.None; + + string rawStatusOverride = section[ConfigurationFields.VariantDefinitionStatusOverride]; + + if (!string.IsNullOrEmpty(rawStatusOverride)) + { + statusOverride = ParseEnum(configurationSection.Key, rawStatusOverride, ConfigurationFields.VariantDefinitionStatusOverride); + } + + VariantDefinition variant = new VariantDefinition() + { + Name = section[ConfigurationFields.NameKeyword], + ConfigurationValue = section.GetSection(ConfigurationFields.VariantDefinitionConfigurationValue), + ConfigurationReference = section[ConfigurationFields.VariantDefinitionConfigurationReference], + StatusOverride = statusOverride + }; + + variants.Add(variant); + } + } } return new FeatureDefinition() { Name = configurationSection.Key, EnabledFor = enabledFor, - RequirementType = requirementType + RequirementType = requirementType, + Status = featureStatus, + Allocation = allocation, + Variants = variants }; } private IEnumerable GetFeatureDefinitionSections() { - const string FeatureManagementSectionName = "FeatureManagement"; - - if (_configuration.GetChildren().Any(s => s.Key.Equals(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase))) + if (_configuration.GetChildren().Any(s => s.Key.Equals(ConfigurationFields.FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase))) { // // Look for feature definitions under the "FeatureManagement" section - return _configuration.GetSection(FeatureManagementSectionName).GetChildren(); + return _configuration.GetSection(ConfigurationFields.FeatureManagementSectionName).GetChildren(); } else { return _configuration.GetChildren(); } } + + private T ParseEnum(string feature, string rawValue, string fieldKeyword) + where T: struct, Enum + { + Debug.Assert(!string.IsNullOrEmpty(rawValue)); + + if (!Enum.TryParse(rawValue, ignoreCase: true, out T value)) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfigurationSetting, + string.Format(ParseValueErrorString, fieldKeyword, rawValue, feature)); + } + + return value; + } + + private double ParseDouble(string feature, string rawValue, string fieldKeyword) + { + Debug.Assert(!string.IsNullOrEmpty(rawValue)); + + if (!double.TryParse(rawValue, out double value)) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfigurationSetting, + string.Format(ParseValueErrorString, fieldKeyword, rawValue, feature)); + } + + return value; + } } } diff --git a/src/Microsoft.FeatureManagement/ConfigurationFields.cs b/src/Microsoft.FeatureManagement/ConfigurationFields.cs new file mode 100644 index 00000000..3642da21 --- /dev/null +++ b/src/Microsoft.FeatureManagement/ConfigurationFields.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.FeatureManagement +{ + internal static class ConfigurationFields + { + // Enum keywords + public const string RequirementType = "RequirementType"; + public const string FeatureStatus = "Status"; + + // Feature filters keywords + public const string FeatureFiltersSectionName = "EnabledFor"; + public const string FeatureFilterConfigurationParameters = "Parameters"; + + // Allocation keywords + public const string AllocationSectionName = "Allocation"; + public const string AllocationDefaultWhenDisabled = "DefaultWhenDisabled"; + public const string AllocationDefaultWhenEnabled = "DefaultWhenEnabled"; + public const string UserAllocationSectionName = "User"; + public const string AllocationVariantKeyword = "Variant"; + public const string UserAllocationUsers = "Users"; + public const string GroupAllocationSectionName = "Group"; + public const string GroupAllocationGroups = "Groups"; + public const string PercentileAllocationSectionName = "Percentile"; + public const string PercentileAllocationFrom = "From"; + public const string PercentileAllocationTo = "To"; + public const string AllocationSeed = "Seed"; + + // Variants keywords + public const string VariantsSectionName = "Variants"; + public const string VariantDefinitionConfigurationValue = "ConfigurationValue"; + public const string VariantDefinitionConfigurationReference = "ConfigurationReference"; + public const string VariantDefinitionStatusOverride = "StatusOverride"; + + // Other keywords + public const string NameKeyword = "Name"; + public const string FeatureManagementSectionName = "FeatureManagement"; + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureDefinition.cs b/src/Microsoft.FeatureManagement/FeatureDefinition.cs index 6736314f..53819fc6 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 { @@ -21,9 +22,25 @@ public class FeatureDefinition public IEnumerable EnabledFor { get; set; } = new List(); /// - /// Determines whether any or all registered feature filters must be enabled for the feature to be considered enabled + /// Determines whether any or all registered feature filters must be enabled for the feature to be considered enabled. /// The default value is . /// public RequirementType RequirementType { get; set; } = RequirementType.Any; + + /// + /// When set to , this feature will always be considered disabled regardless of the rest of the feature definition. + /// The default value is . + /// + public FeatureStatus Status { get; set; } = FeatureStatus.Conditional; + + /// + /// Describes how variants should be allocated. + /// + public Allocation Allocation { get; set; } + + /// + /// A list of variant definitions that specify a configuration to return when assigned. + /// + public IEnumerable Variants { get; set; } = Enumerable.Empty(); } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 17b94c47..76a30859 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -4,11 +4,16 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.FeatureFilters; +using Microsoft.FeatureManagement.Targeting; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement @@ -16,7 +21,7 @@ namespace Microsoft.FeatureManagement /// /// Used to evaluate whether a feature is enabled or disabled. /// - class FeatureManager : IFeatureManager, IDisposable + class FeatureManager : IFeatureManager, IDisposable, IVariantFeatureManager { private readonly TimeSpan ParametersCacheSlidingExpiration = TimeSpan.FromMinutes(5); private readonly TimeSpan ParametersCacheAbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); @@ -28,8 +33,9 @@ class FeatureManager : IFeatureManager, IDisposable private readonly ConcurrentDictionary _filterMetadataCache; private readonly ConcurrentDictionary _contextualFeatureFilterCache; private readonly FeatureManagementOptions _options; + private readonly TargetingEvaluationOptions _assignerOptions; private readonly IMemoryCache _parametersCache; - + private class ConfigurationCacheItem { public IConfiguration Parameters { get; set; } @@ -42,26 +48,101 @@ public FeatureManager( IEnumerable featureFilters, IEnumerable sessionManagers, ILoggerFactory loggerFactory, - IOptions options) + IOptions options, + IOptions assignerOptions) { _featureDefinitionProvider = featureDefinitionProvider; _featureFilters = featureFilters ?? throw new ArgumentNullException(nameof(featureFilters)); _sessionManagers = sessionManagers ?? throw new ArgumentNullException(nameof(sessionManagers)); _logger = loggerFactory.CreateLogger(); + _assignerOptions = assignerOptions?.Value ?? throw new ArgumentNullException(nameof(assignerOptions)); _filterMetadataCache = new ConcurrentDictionary(); _contextualFeatureFilterCache = new ConcurrentDictionary(); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _parametersCache = new MemoryCache(new MemoryCacheOptions()); } + public IConfiguration Configuration { get; init; } + + public ITargetingContextAccessor TargetingContextAccessor { get; init; } + public Task IsEnabledAsync(string feature) { - return IsEnabledAsync(feature, null, false); + return IsEnabledWithVariantsAsync(feature, appContext: null, useAppContext: false, CancellationToken.None); } public Task IsEnabledAsync(string feature, TContext appContext) { - return IsEnabledAsync(feature, appContext, true); + return IsEnabledWithVariantsAsync(feature, appContext, useAppContext: true, CancellationToken.None); + } + + public Task IsEnabledAsync(string feature, CancellationToken cancellationToken) + { + return IsEnabledWithVariantsAsync(feature, appContext: null, useAppContext: false, cancellationToken); + } + + public Task IsEnabledAsync(string feature, TContext appContext, CancellationToken cancellationToken) + { + return IsEnabledWithVariantsAsync(feature, appContext, useAppContext: true, cancellationToken); + } + + private async Task IsEnabledWithVariantsAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) + { + bool isFeatureEnabled = await IsEnabledAsync(feature, appContext, useAppContext, cancellationToken).ConfigureAwait(false); + + FeatureDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureDefinitionAsync(feature).ConfigureAwait(false); + + if (featureDefinition == null || featureDefinition.Status == FeatureStatus.Disabled) + { + isFeatureEnabled = false; + } + else if ((featureDefinition.Variants?.Any() ?? false) && featureDefinition.Allocation != null) + { + VariantDefinition variantDefinition; + + if (!isFeatureEnabled) + { + variantDefinition = featureDefinition.Variants.FirstOrDefault((variant) => variant.Name == featureDefinition.Allocation.DefaultWhenDisabled); + } + else + { + TargetingContext targetingContext; + + if (useAppContext) + { + targetingContext = appContext as TargetingContext; + } + else + { + targetingContext = await ResolveTargetingContextAsync(cancellationToken).ConfigureAwait(false); + } + + variantDefinition = await GetAssignedVariantAsync( + featureDefinition, + targetingContext, + cancellationToken) + .ConfigureAwait(false); + } + + if (variantDefinition != null) + { + if (variantDefinition.StatusOverride == StatusOverride.Enabled) + { + isFeatureEnabled = true; + } + else if (variantDefinition.StatusOverride == StatusOverride.Disabled) + { + isFeatureEnabled = false; + } + } + } + + foreach (ISessionManager sessionManager in _sessionManagers) + { + await sessionManager.SetAsync(feature, isFeatureEnabled).ConfigureAwait(false); + } + + return isFeatureEnabled; } public async IAsyncEnumerable GetFeatureNamesAsync() @@ -77,7 +158,7 @@ public void Dispose() _parametersCache.Dispose(); } - private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext) + private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { foreach (ISessionManager sessionManager in _sessionManagers) { @@ -103,8 +184,8 @@ private async Task IsEnabledAsync(string feature, TContext appCo } // - // Treat an empty list of enabled filters as a disabled feature - if (featureDefinition.EnabledFor == null || !featureDefinition.EnabledFor.Any()) + // Treat an empty list of enabled filters or if status is disabled as a disabled feature + if (featureDefinition.EnabledFor == null || !featureDefinition.EnabledFor.Any() || featureDefinition.Status == FeatureStatus.Disabled) { enabled = false; } @@ -129,15 +210,16 @@ private async Task IsEnabledAsync(string feature, TContext appCo filterIndex++; // - // Handle AlwaysOn filters - if (string.Equals(featureFilterConfiguration.Name, "AlwaysOn", StringComparison.OrdinalIgnoreCase)) + // Handle AlwaysOn and On filters + if (string.Equals(featureFilterConfiguration.Name, "AlwaysOn", StringComparison.OrdinalIgnoreCase) || + string.Equals(featureFilterConfiguration.Name, "On", StringComparison.OrdinalIgnoreCase)) { if (featureDefinition.RequirementType == RequirementType.Any) { enabled = true; break; } - + continue; } @@ -186,7 +268,8 @@ await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false) { BindSettings(filter, context, filterIndex); - if (await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvaluation) { + if (await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvaluation) + { enabled = targetEvaluation; break; @@ -209,12 +292,229 @@ await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false) _logger.LogWarning(errorMessage); } - foreach (ISessionManager sessionManager in _sessionManagers) + return enabled; + } + + public ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(feature)) { - await sessionManager.SetAsync(feature, enabled).ConfigureAwait(false); + throw new ArgumentNullException(nameof(feature)); } - return enabled; + return GetVariantAsync(feature, context: null, useContext: false, cancellationToken); + } + + public ValueTask GetVariantAsync(string feature, TargetingContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return GetVariantAsync(feature, context, useContext: true, cancellationToken); + } + + private async ValueTask GetVariantAsync(string feature, TargetingContext context, bool useContext, CancellationToken cancellationToken) + { + FeatureDefinition featureDefinition = await _featureDefinitionProvider + .GetFeatureDefinitionAsync(feature) + .ConfigureAwait(false); + + if (featureDefinition == null) + { + string errorMessage = $"The feature declaration for the feature '{feature}' was not found."; + + if (!_options.IgnoreMissingFeatures) + { + throw new FeatureManagementException(FeatureManagementError.MissingFeature, errorMessage); + } + + _logger.LogWarning(errorMessage); + } + + if (featureDefinition?.Allocation == null || (!featureDefinition.Variants?.Any() ?? false)) + { + return null; + } + + VariantDefinition variantDefinition = null; + + bool isFeatureEnabled = await IsEnabledAsync(feature, context, useContext, cancellationToken).ConfigureAwait(false); + + if (!isFeatureEnabled) + { + variantDefinition = featureDefinition.Variants.FirstOrDefault((variant) => variant.Name == featureDefinition.Allocation.DefaultWhenDisabled); + } + else + { + if (!useContext) + { + context = await ResolveTargetingContextAsync(cancellationToken).ConfigureAwait(false); + } + + variantDefinition = await GetAssignedVariantAsync(featureDefinition, context, cancellationToken).ConfigureAwait(false); + } + + if (variantDefinition == null) + { + return null; + } + + IConfigurationSection variantConfiguration = null; + + bool configValueSet = variantDefinition.ConfigurationValue.Exists(); + bool configReferenceSet = !string.IsNullOrEmpty(variantDefinition.ConfigurationReference); + + if (configValueSet) + { + variantConfiguration = variantDefinition.ConfigurationValue; + } + else if (configReferenceSet) + { + if (Configuration == null) + { + _logger.LogWarning($"Cannot use {nameof(variantDefinition.ConfigurationReference)} as no instance of {nameof(IConfiguration)} is present."); + + return null; + } + else + { + variantConfiguration = Configuration.GetSection(variantDefinition.ConfigurationReference); + } + } + + return new Variant() + { + Name = variantDefinition.Name, + Configuration = variantConfiguration + }; + } + + private async ValueTask ResolveTargetingContextAsync(CancellationToken cancellationToken) + { + if (TargetingContextAccessor == null) + { + _logger.LogWarning($"No instance of {nameof(ITargetingContextAccessor)} is available for variant assignment."); + + return null; + } + + // + // Acquire targeting context via accessor + TargetingContext context = await TargetingContextAccessor.GetContextAsync().ConfigureAwait(false); + + // + // Ensure targeting can be performed + if (context == null) + { + _logger.LogWarning($"No instance of {nameof(TargetingContext)} could be found using {nameof(ITargetingContextAccessor)} for variant assignment."); + } + + return context; + } + + private async ValueTask GetAssignedVariantAsync(FeatureDefinition featureDefinition, TargetingContext context, CancellationToken cancellationToken) + { + VariantDefinition variantDefinition = null; + + if (context != null) + { + variantDefinition = await AssignVariantAsync(featureDefinition, context, cancellationToken).ConfigureAwait(false); + } + + if (variantDefinition == null) + { + variantDefinition = featureDefinition.Variants.FirstOrDefault((variant) => variant.Name == featureDefinition.Allocation.DefaultWhenEnabled); + } + + return variantDefinition; + } + + private ValueTask AssignVariantAsync(FeatureDefinition featureDefinition, TargetingContext targetingContext, CancellationToken cancellationToken) + { + VariantDefinition variant = null; + + if (featureDefinition.Allocation.User != null) + { + foreach (UserAllocation user in featureDefinition.Allocation.User) + { + if (TargetingEvaluator.IsTargeted(targetingContext.UserId, user.Users, _assignerOptions.IgnoreCase)) + { + if (string.IsNullOrEmpty(user.Variant)) + { + _logger.LogWarning($"Missing variant name for user allocation in feature {featureDefinition.Name}"); + + return new ValueTask((VariantDefinition)null); + } + + Debug.Assert(featureDefinition.Variants != null); + + return new ValueTask( + featureDefinition + .Variants + .FirstOrDefault((variant) => variant.Name == user.Variant)); + } + } + } + + if (featureDefinition.Allocation.Group != null) + { + foreach (GroupAllocation group in featureDefinition.Allocation.Group) + { + if (TargetingEvaluator.IsTargeted(targetingContext.Groups, group.Groups, _assignerOptions.IgnoreCase)) + { + if (string.IsNullOrEmpty(group.Variant)) + { + _logger.LogWarning($"Missing variant name for group allocation in feature {featureDefinition.Name}"); + + return new ValueTask((VariantDefinition)null); + } + + Debug.Assert(featureDefinition.Variants != null); + + return new ValueTask( + featureDefinition + .Variants + .FirstOrDefault((variant) => variant.Name == group.Variant)); + } + } + } + + if (featureDefinition.Allocation.Percentile != null) + { + foreach (PercentileAllocation percentile in featureDefinition.Allocation.Percentile) + { + if (TargetingEvaluator.IsTargeted( + targetingContext, + percentile.From, + percentile.To, + _assignerOptions.IgnoreCase, + featureDefinition.Allocation.Seed ?? $"allocation\n{featureDefinition.Name}")) + { + if (string.IsNullOrEmpty(percentile.Variant)) + { + _logger.LogWarning($"Missing variant name for percentile allocation in feature {featureDefinition.Name}"); + + return new ValueTask((VariantDefinition)null); + } + + Debug.Assert(featureDefinition.Variants != null); + + return new ValueTask( + featureDefinition + .Variants + .FirstOrDefault((variant) => variant.Name == percentile.Variant)); + } + } + } + + return new ValueTask(variant); } private void BindSettings(IFeatureFilterMetadata filter, FeatureFilterEvaluationContext context, int filterIndex) diff --git a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs index 1c676cbb..9e20e8c6 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.FeatureManagement.FeatureFilters; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement @@ -11,13 +13,14 @@ namespace Microsoft.FeatureManagement /// /// Provides a snapshot of feature state to ensure consistency across a given request. /// - class FeatureManagerSnapshot : IFeatureManagerSnapshot + class FeatureManagerSnapshot : IFeatureManagerSnapshot, IVariantFeatureManagerSnapshot { - private readonly IFeatureManager _featureManager; + private readonly FeatureManager _featureManager; private readonly ConcurrentDictionary> _flagCache = new ConcurrentDictionary>(); + private readonly ConcurrentDictionary _variantCache = new ConcurrentDictionary(); private IEnumerable _featureNames; - public FeatureManagerSnapshot(IFeatureManager featureManager) + public FeatureManagerSnapshot(FeatureManager featureManager) { _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); } @@ -55,5 +58,60 @@ public Task IsEnabledAsync(string feature, TContext context) feature, (key) => _featureManager.IsEnabledAsync(key, context)); } + + public Task IsEnabledAsync(string feature, CancellationToken cancellationToken) + { + return _flagCache.GetOrAdd( + feature, + (key) => _featureManager.IsEnabledAsync(key)); + } + + public Task IsEnabledAsync(string feature, TContext context, CancellationToken cancellationToken) + { + return _flagCache.GetOrAdd( + feature, + (key) => _featureManager.IsEnabledAsync(key, context)); + } + + public async ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken) + { + string cacheKey = GetVariantCacheKey(feature); + + // + // First, check local cache + if (_variantCache.ContainsKey(feature)) + { + return _variantCache[cacheKey]; + } + + Variant variant = await _featureManager.GetVariantAsync(feature, cancellationToken).ConfigureAwait(false); + + _variantCache[cacheKey] = variant; + + return variant; + } + + public async ValueTask GetVariantAsync(string feature, TargetingContext context, CancellationToken cancellationToken) + { + string cacheKey = GetVariantCacheKey(feature); + + // + // First, check local cache + if (_variantCache.ContainsKey(feature)) + { + return _variantCache[cacheKey]; + } + + Variant variant = await _featureManager.GetVariantAsync(feature, context, cancellationToken).ConfigureAwait(false); + + _variantCache[cacheKey] = variant; + + return variant; + } + + private string GetVariantCacheKey(string feature) + { + return $"{typeof(Variant).FullName}\n{feature}"; + } } } diff --git a/src/Microsoft.FeatureManagement/FeatureStatus.cs b/src/Microsoft.FeatureManagement/FeatureStatus.cs new file mode 100644 index 00000000..3c1b9089 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureStatus.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Describes how a feature's state will be evaluated. + /// + public enum FeatureStatus + { + /// + /// The state of the feature is conditional upon the feature evaluation pipeline. + /// + Conditional, + /// + /// The state of the feature is always disabled. + /// + Disabled + } +} diff --git a/src/Microsoft.FeatureManagement/IVariantFeatureManager.cs b/src/Microsoft.FeatureManagement/IVariantFeatureManager.cs new file mode 100644 index 00000000..10395327 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IVariantFeatureManager.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading.Tasks; +using System.Threading; +using Microsoft.FeatureManagement.FeatureFilters; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to evaluate the enabled state of a feature and/or get the assigned variant of a feature, if any. + /// + public interface IVariantFeatureManager + { + /// + /// Checks whether a given feature is enabled. + /// + /// The name of the feature to check. + /// The cancellation token to cancel the operation. + /// True if the feature is enabled, otherwise false. + Task IsEnabledAsync(string feature, CancellationToken cancellationToken); + + /// + /// Checks whether a given feature 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 cancellation token to cancel the operation. + /// True if the feature is enabled, otherwise false. + Task IsEnabledAsync(string feature, TContext context, CancellationToken cancellationToken); + + /// + /// Gets the assigned variant for a specfic feature. + /// + /// The name of the feature to evaluate. + /// The cancellation token to cancel the operation. + /// A variant assigned to the user based on the feature's configured allocation. + ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken); + + /// + /// Gets the assigned variant for a specfic feature. + /// + /// The name of the feature to evaluate. + /// An instance of used to evaluate which variant the user will be assigned. + /// The cancellation token to cancel the operation. + /// A variant assigned to the user based on the feature's configured allocation. + ValueTask GetVariantAsync(string feature, TargetingContext context, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.FeatureManagement/IVariantFeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/IVariantFeatureManagerSnapshot.cs new file mode 100644 index 00000000..d2a64dbd --- /dev/null +++ b/src/Microsoft.FeatureManagement/IVariantFeatureManagerSnapshot.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 IVariantFeatureManagerSnapshot : IVariantFeatureManager + { + } +} diff --git a/src/Microsoft.FeatureManagement/IsExternalInit.cs b/src/Microsoft.FeatureManagement/IsExternalInit.cs new file mode 100644 index 00000000..279b080b --- /dev/null +++ b/src/Microsoft.FeatureManagement/IsExternalInit.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +// The init accessor for properties is supported in C# 9.0 and later. +// This class is used to compile .NET frameworks that don't support C# 9.0 or later while still using the init accessor for a property. +// The code referenced for this file can be found here: https://github.com/dotnet/roslyn/issues/45510#issuecomment-725091019 + +#if NETSTANDARD2_0 || NETCOREAPP2_1 || NETCOREAPP3_1 + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices +{ + [EditorBrowsable(EditorBrowsableState.Never)] + internal static class IsExternalInit + { + } +} + +#endif \ No newline at end of file diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index beb3ceb4..383956c9 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -15,7 +15,7 @@ true false ..\..\build\Microsoft.FeatureManagement.snk - 8.0 + 9.0 @@ -38,6 +38,7 @@ + diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index 507f1394..da5c6d6d 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -4,7 +4,11 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.FeatureFilters; using System; +using System.Collections.Generic; namespace Microsoft.FeatureManagement { @@ -26,11 +30,42 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // Add required services services.TryAddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + + services.TryAddSingleton(sp => + new FeatureManager( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>()) + { + Configuration = sp.GetService(), + TargetingContextAccessor = sp.GetService() + }); + + services.TryAddSingleton(sp => + new FeatureManager( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>()) + { + Configuration = sp.GetService(), + TargetingContextAccessor = sp.GetService() + }); services.AddSingleton(); - services.AddScoped(); + services.AddScoped(); + + services.TryAddScoped(sp => sp.GetRequiredService()); + + services.TryAddScoped(sp => sp.GetRequiredService()); return new FeatureManagementBuilder(services); } diff --git a/src/Microsoft.FeatureManagement/StatusOverride.cs b/src/Microsoft.FeatureManagement/StatusOverride.cs new file mode 100644 index 00000000..ac5c16b1 --- /dev/null +++ b/src/Microsoft.FeatureManagement/StatusOverride.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Provides the capability to override whether a feature is considered enabled or disabled when a variant is assigned. + /// + public enum StatusOverride + { + /// + /// Does not affect the feature state. + /// + None, + /// + /// The feature will be considered enabled. + /// + Enabled, + /// + /// The feature will be considered disabled. + /// + Disabled + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index d29cafcd..34633749 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -4,11 +4,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.Targeting; using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.FeatureFilters @@ -34,9 +31,6 @@ public ContextualTargetingFilter(IOptions options, I _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); } - private StringComparison ComparisonType => _options.IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - private StringComparer ComparerType => _options.IgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - /// /// Binds configuration representing filter parameters to . /// @@ -70,159 +64,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargeti // Check if prebound settings available, otherwise bind from parameters. TargetingFilterSettings settings = (TargetingFilterSettings)context.Settings ?? (TargetingFilterSettings)BindParameters(context.Parameters); - if (!TryValidateSettings(settings, out string paramName, out string message)) - { - throw new ArgumentException(message, paramName); - } - - if (settings.Audience.Exclusion != null) - { - // - // Check if the user is in the exclusion directly - if (targetingContext.UserId != null && - settings.Audience.Exclusion.Users != null && - settings.Audience.Exclusion.Users.Any(user => targetingContext.UserId.Equals(user, ComparisonType))) - { - return Task.FromResult(false); - } - - // - // Check if the user is in a group within exclusion - if (targetingContext.Groups != null && - settings.Audience.Exclusion.Groups != null && - settings.Audience.Exclusion.Groups.Any(group => targetingContext.Groups.Contains(group, ComparerType))) - { - return Task.FromResult(false); - } - } - - // - // 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) - { - // - // Handle edge case of exact 100 bucket - if (percentage == 100) - { - return true; - } - - 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(targetingContext, settings, _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..cc809e27 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -0,0 +1,368 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.FeatureManagement.Targeting +{ + static class TargetingEvaluator + { + private static StringComparison GetComparisonType(bool ignoreCase) => + ignoreCase ? + StringComparison.OrdinalIgnoreCase : + StringComparison.Ordinal; + + const string OutOfRange = "The value is out of the accepted range."; + const string RequiredParameter = "Value cannot be null."; + + /// + /// 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) + { + 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); + } + + if (settings.Audience.Exclusion != null) + { + // + // Check if the user is in the exclusion directly + if (targetingContext.UserId != null && + settings.Audience.Exclusion.Users != null && + settings.Audience.Exclusion.Users.Any(user => targetingContext.UserId.Equals(user, GetComparisonType(ignoreCase)))) + { + return false; + } + + // + // Check if the user is in a group within exclusion + if (targetingContext.Groups != null && + settings.Audience.Exclusion.Groups != null && + settings.Audience.Exclusion.Groups.Any(group => targetingContext.Groups.Any(g => g?.Equals(group, GetComparisonType(ignoreCase)) ?? false))) + { + return false; + } + } + + // + // Check if the user is being targeted directly + if (settings.Audience.Users != null && + IsTargeted( + targetingContext.UserId, + 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( + string userId, + IEnumerable users, + bool ignoreCase) + { + if (users == null) + { + throw new ArgumentNullException(nameof(users)); + } + + if (userId != null && + users.Any(user => userId.Equals(user, GetComparisonType(ignoreCase)))) + { + return true; + } + + return false; + } + + /// + /// Determines if targeting context is targeted by presence in a list of groups + /// + public static bool IsTargeted( + IEnumerable sourceGroups, + IEnumerable targetedGroups, + bool ignoreCase) + { + if (targetedGroups == null) + { + throw new ArgumentNullException(nameof(targetedGroups)); + } + + if (sourceGroups != null) + { + IEnumerable normalizedGroups = ignoreCase ? + sourceGroups.Select(g => g.ToLower()) : + sourceGroups; + + foreach (string group in normalizedGroups) + { + string allocationGroup = targetedGroups.FirstOrDefault(g => g.Equals(group, GetComparisonType(ignoreCase))); + + if (allocationGroup != null) + { + return true; + } + } + } + + return false; + } + + /// + /// Determine if a targeting context is targeted by presence in a group and its rollout percentage + /// + 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)); + } + + if (string.IsNullOrEmpty(hint)) + { + throw new ArgumentNullException(nameof(hint)); + } + + string userId = ignoreCase ? + targetingContext.UserId.ToLower() : + targetingContext.UserId; + + if (targetingContext.Groups != null) + { + IEnumerable normalizedGroups = ignoreCase ? + targetingContext.Groups.Select(g => g.ToLower()) : + targetingContext.Groups; + + foreach (string group in normalizedGroups) + { + GroupRollout groupRollout = groups.FirstOrDefault(g => g.Name.Equals(group, GetComparisonType(ignoreCase))); + + if (groupRollout != null) + { + string audienceContextId = $"{userId}\n{hint}\n{group}"; + + if (IsTargeted(audienceContextId, 0, groupRollout.RolloutPercentage)) + { + return true; + } + } + } + } + + 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)); + } + + if (string.IsNullOrEmpty(hint)) + { + throw new ArgumentNullException(nameof(hint)); + } + + string userId = ignoreCase ? + targetingContext.UserId.ToLower() : + targetingContext.UserId; + + string defaultContextId = $"{userId}\n{hint}"; + + return IsTargeted(defaultContextId, 0, defaultRolloutPercentage); + } + + /// + /// Performs validation of targeting settings. + /// + /// The settings to validate. + /// The name of the invalid setting, if any. + /// The reason that the setting is invalid. + /// True if the provided settings are valid. False if the provided settings are invalid. + private static bool TryValidateSettings(TargetingFilterSettings targetingSettings, out string paramName, out string reason) + { + paramName = null; + + reason = null; + + if (targetingSettings == null) + { + paramName = nameof(targetingSettings); + + reason = RequiredParameter; + + return false; + } + + if (targetingSettings.Audience == null) + { + paramName = nameof(targetingSettings.Audience); + + reason = RequiredParameter; + + return false; + } + + if (targetingSettings.Audience.DefaultRolloutPercentage < 0 || targetingSettings.Audience.DefaultRolloutPercentage > 100) + { + paramName = $"{targetingSettings.Audience}.{targetingSettings.Audience.DefaultRolloutPercentage}"; + + reason = OutOfRange; + + return false; + } + + if (targetingSettings.Audience.Groups != null) + { + int index = 0; + + foreach (GroupRollout groupRollout in targetingSettings.Audience.Groups) + { + index++; + + if (groupRollout.RolloutPercentage < 0 || groupRollout.RolloutPercentage > 100) + { + // + // Audience.Groups[1].RolloutPercentage + paramName = $"{targetingSettings.Audience}.{targetingSettings.Audience.Groups}[{index}].{groupRollout.RolloutPercentage}"; + + reason = OutOfRange; + + return false; + } + } + } + + return true; + } + + /// + /// Determines if a given context id should be targeted based off the provided percentage range + /// + public static bool IsTargeted(ITargetingContext targetingContext, double from, double to, bool ignoreCase, string hint) + { + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + if (string.IsNullOrEmpty(hint)) + { + throw new ArgumentNullException(nameof(hint)); + } + + if (from < 0 || from > 100) + { + throw new ArgumentException(OutOfRange, nameof(from)); + } + + if (to < 0 || to > 100) + { + throw new ArgumentException(OutOfRange, nameof(to)); + } + + if (from > to) + { + throw new ArgumentException($"Value of {nameof(from)} cannot be larger than value of {nameof(to)}."); + } + + string userId = ignoreCase ? + targetingContext.UserId.ToLower() : + targetingContext.UserId; + + string contextId = $"{userId}\n{hint}"; + + return IsTargeted(contextId, from, to); + } + + /// + /// 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 lower bound of the percentage for which the context identifier will be targeted + /// The upper bound of the percentage for which the context identifier will be targeted + /// A boolean representing if the context identifier should be targeted + private static bool IsTargeted(string contextId, double from, double to) + { + 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; + + // + // Handle edge case of exact 100 bucket + if (to == 100) + { + return contextPercentage >= from; + } + + return contextPercentage >= from && contextPercentage < to; + } + } +} diff --git a/src/Microsoft.FeatureManagement/Variant.cs b/src/Microsoft.FeatureManagement/Variant.cs new file mode 100644 index 00000000..f69a47ce --- /dev/null +++ b/src/Microsoft.FeatureManagement/Variant.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; + +namespace Microsoft.FeatureManagement +{ + /// + /// A variant for a feature. + /// + public class Variant + { + /// + /// The name of the variant. + /// + public string Name { get; set; } + + /// + /// The configuration of the variant. + /// + public IConfigurationSection Configuration { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/VariantDefinition.cs b/src/Microsoft.FeatureManagement/VariantDefinition.cs new file mode 100644 index 00000000..77bd6d7d --- /dev/null +++ b/src/Microsoft.FeatureManagement/VariantDefinition.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using Microsoft.Extensions.Configuration; + +namespace Microsoft.FeatureManagement +{ + /// + /// The definition for a variant of a feature. + /// + public class VariantDefinition + { + /// + /// The name of the variant. + /// + public string Name { get; set; } + + /// + /// The value of the configuration for this variant of the feature. + /// + public IConfigurationSection ConfigurationValue { get; set; } + + /// + /// A reference pointing to the configuration for this variant of the feature. + /// + public string ConfigurationReference { get; set; } + + /// + /// Overrides the state of the feature if this variant has been assigned. + /// + public StatusOverride StatusOverride { get; set; } = StatusOverride.None; + } +} diff --git a/tests/Tests.FeatureManagement/CustomTargetingFilter.cs b/tests/Tests.FeatureManagement/CustomTargetingFilter.cs index cc89482f..36579146 100644 --- a/tests/Tests.FeatureManagement/CustomTargetingFilter.cs +++ b/tests/Tests.FeatureManagement/CustomTargetingFilter.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.FeatureManagement; diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 9ecd3e7f..b690efc9 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -15,6 +15,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -23,7 +24,7 @@ namespace Tests.FeatureManagement public class FeatureManagement { private const string OnFeature = "OnTestFeature"; - private const string OffFeature = "OffFeature"; + private const string OffFeature = "OffTestFeature"; private const string ConditionalFeature = "ConditionalFeature"; private const string ContextualFeature = "ContextualFeature"; @@ -813,7 +814,6 @@ public async Task UsesRequirementType() // Set filters to all return true testFeatureFilter.Callback = _ => Task.FromResult(true); - Assert.True(await featureManager.IsEnabledAsync(anyFilterFeature)); Assert.True(await featureManager.IsEnabledAsync(allFilterFeature)); @@ -961,6 +961,126 @@ public async Task BindsFeatureFlagSettings() Assert.True(called); } + [Fact] + public async Task UsesVariants() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + var targetingContextAccessor = new OnDemandTargetingContextAccessor(); + services.AddSingleton(targetingContextAccessor) + .AddSingleton(config) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IVariantFeatureManager featureManager = serviceProvider.GetRequiredService(); + CancellationToken cancellationToken = CancellationToken.None; + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "Marsha", + Groups = new List { "Group1" } + }; + + // Test StatusOverride and Percentile with Seed + Variant variant = await featureManager.GetVariantAsync("VariantFeaturePercentileOn", cancellationToken); + + Assert.Equal("Big", variant.Name); + Assert.Equal("green", variant.Configuration["Color"]); + Assert.False(await featureManager.IsEnabledAsync("VariantFeaturePercentileOn", cancellationToken)); + + variant = await featureManager.GetVariantAsync("VariantFeaturePercentileOff", cancellationToken); + + Assert.Null(variant); + Assert.True(await featureManager.IsEnabledAsync("VariantFeaturePercentileOff", cancellationToken)); + + // Test Status = Disabled + variant = await featureManager.GetVariantAsync("VariantFeatureStatusDisabled", cancellationToken); + + Assert.Equal("Small", variant.Name); + Assert.Equal("300px", variant.Configuration.Value); + Assert.False(await featureManager.IsEnabledAsync("VariantFeatureStatusDisabled", cancellationToken)); + + // Test DefaultWhenEnabled and ConfigurationValue with inline IConfigurationSection + variant = await featureManager.GetVariantAsync("VariantFeatureDefaultEnabled", cancellationToken); + + Assert.Equal("Medium", variant.Name); + Assert.Equal("450px", variant.Configuration["Size"]); + Assert.True(await featureManager.IsEnabledAsync("VariantFeatureDefaultEnabled", cancellationToken)); + + // Test User allocation + variant = await featureManager.GetVariantAsync("VariantFeatureUser", cancellationToken); + + Assert.Equal("Small", variant.Name); + Assert.Equal("300px", variant.Configuration.Value); + Assert.True(await featureManager.IsEnabledAsync("VariantFeatureUser", cancellationToken)); + + // Test Group allocation + variant = await featureManager.GetVariantAsync("VariantFeatureGroup", cancellationToken); + + Assert.Equal("Small", variant.Name); + Assert.Equal("300px", variant.Configuration.Value); + Assert.True(await featureManager.IsEnabledAsync("VariantFeatureGroup", cancellationToken)); + } + + [Fact] + public async Task VariantsInvalidScenarios() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + var targetingContextAccessor = new OnDemandTargetingContextAccessor(); + services.AddSingleton(targetingContextAccessor) + .AddSingleton(config) + .AddFeatureManagement(); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "Jeff" + }; + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IVariantFeatureManager featureManager = serviceProvider.GetRequiredService(); + CancellationToken cancellationToken = CancellationToken.None; + + // Verify null variant returned if no variants are specified + Variant variant = await featureManager.GetVariantAsync("VariantFeatureNoVariants", cancellationToken); + + Assert.Null(variant); + + // Verify null variant returned if no allocation is specified + variant = await featureManager.GetVariantAsync("VariantFeatureNoAllocation", cancellationToken); + + Assert.Null(variant); + + // Verify that ConfigurationValue has priority over ConfigurationReference + variant = await featureManager.GetVariantAsync("VariantFeatureBothConfigurations", cancellationToken); + + Assert.Equal("600px", variant.Configuration.Value); + + // Verify that an exception is thrown for invalid StatusOverride value + FeatureManagementException e = await Assert.ThrowsAsync(async () => + { + variant = await featureManager.GetVariantAsync("VariantFeatureInvalidStatusOverride", cancellationToken); + }); + + Assert.Equal(FeatureManagementError.InvalidConfigurationSetting, e.Error); + Assert.Contains(ConfigurationFields.VariantDefinitionStatusOverride, e.Message); + + // Verify that an exception is thrown for invalid doubles From and To in the Percentile section + e = await Assert.ThrowsAsync(async () => + { + variant = await featureManager.GetVariantAsync("VariantFeatureInvalidFromTo", cancellationToken); + }); + + Assert.Equal(FeatureManagementError.InvalidConfigurationSetting, e.Error); + Assert.Contains(ConfigurationFields.PercentileAllocationFrom, e.Message); + } + private static void DisableEndpointRouting(MvcOptions options) { #if NET6_0 || NET5_0 || NETCOREAPP3_1 diff --git a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj index 7501c259..cbeed357 100644 --- a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj +++ b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj @@ -3,7 +3,7 @@ netcoreapp2.1;netcoreapp3.1;net5.0;net6.0 false - 8.0 + 9.0 True ..\..\build\Microsoft.FeatureManagement.snk diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index 2eeff873..ba6a18d7 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -6,6 +6,17 @@ }, "AllowedHosts": "*", + "ShoppingCart": { + "Big": { + "Size": 600, + "Color": "green" + }, + "Small": { + "Size": 300, + "Color": "gray" + } + }, + "FeatureManagement": { "OnTestFeature": true, "OffTestFeature": false, @@ -147,6 +158,243 @@ } ] + }, + "VariantFeaturePercentileOn": { + "Allocation": { + "Percentile": [ + { + "Variant": "Big", + "From": 0, + "To": 50 + } + ], + "Seed": 1234 + }, + "Variants": [ + { + "Name": "Big", + "ConfigurationReference": "ShoppingCart:Big", + "StatusOverride": "Disabled" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeaturePercentileOff": { + "Allocation": { + "Percentile": [ + { + "Variant": "Big", + "From": 0, + "To": 50 + } + ], + "Seed": 12345 + }, + "Variants": [ + { + "Name": "Big", + "ConfigurationReference": "ShoppingCart:Big", + "StatusOverride": "Disabled" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureStatusDisabled": { + "Status": "Disabled", + "Allocation": { + "DefaultWhenDisabled": "Small" + }, + "Variants": [ + { + "Name": "Small", + "ConfigurationValue": "300px" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureDefaultEnabled": { + "Allocation": { + "DefaultWhenEnabled": "Medium", + "User": [ + { + "Variant": "Small", + "Users": [ + "Jeff" + ] + } + ] + }, + "Variants": [ + { + "Name": "Medium", + "ConfigurationValue": { + "Size": "450px", + "Color": "Purple" + } + }, + { + "Name": "Small", + "ConfigurationValue": "300px" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureUser": { + "Allocation": { + "User": [ + { + "Variant": "Small", + "Users": [ + "Marsha" + ] + } + ] + }, + "Variants": [ + { + "Name": "Small", + "ConfigurationValue": "300px" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureGroup": { + "Allocation": { + "User": [ + { + "Variant": "Small", + "Users": [ + "Jeff" + ] + } + ], + "Group": [ + { + "Variant": "Small", + "Groups": [ + "Group1" + ] + } + ] + }, + "Variants": [ + { + "Name": "Small", + "ConfigurationValue": "300px" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureNoVariants": { + "Allocation": { + "User": [ + { + "Variant": "Small", + "Users": [ + "Marsha" + ] + } + ] + }, + "Variants": [], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureBothConfigurations": { + "Allocation": { + "DefaultWhenEnabled": "Small" + }, + "Variants": [ + { + "Name": "Small", + "ConfigurationValue": "600px", + "ConfigurationReference": "ShoppingCart:Small" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureNoAllocation": { + "Variants": [ + { + "Name": "Small", + "ConfigurationValue": "300px" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureInvalidStatusOverride": { + "Allocation": { + "DefaultWhenEnabled": "Small" + }, + "Variants": [ + { + "Name": "Small", + "ConfigurationValue": "300px", + "StatusOverride": "InvalidValue" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] + }, + "VariantFeatureInvalidFromTo": { + "Allocation": { + "Percentile": [ + { + "Variant": "Small", + "From": "Invalid", + "To": "Invalid" + } + ] + }, + "Variants": [ + { + "Name": "Small", + "ConfigurationReference": "ShoppingCart:Small" + } + ], + "EnabledFor": [ + { + "Name": "On" + } + ] } } }