diff --git a/src/Microsoft.FeatureManagement/Comparers/CompareByNameFeatureFilterConfigurationComparer.cs b/src/Microsoft.FeatureManagement/Comparers/CompareByNameFeatureFilterConfigurationComparer.cs new file mode 100644 index 00000000..ca394748 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Comparers/CompareByNameFeatureFilterConfigurationComparer.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Microsoft.FeatureManagement.Comparers +{ + /// + /// Compares two FeatureFilterConfiguration using only the name + /// + public class CompareByNameFeatureFilterConfigurationComparer : IEqualityComparer + { + public bool Equals(FeatureFilterConfiguration x, FeatureFilterConfiguration y) + { + if (ReferenceEquals(x, y)) return true; + if (ReferenceEquals(x, null)) return false; + if (ReferenceEquals(y, null)) return false; + if (x.GetType() != y.GetType()) return false; + return x.Name == y.Name; + } + + public int GetHashCode(FeatureFilterConfiguration obj) + { + return (obj.Name != null ? obj.Name.GetHashCode() : 0); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 8763b850..7ff118c8 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // + using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Primitives; using System; @@ -9,6 +10,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.FeatureManagement.Comparers; namespace Microsoft.FeatureManagement { @@ -18,6 +20,7 @@ namespace Microsoft.FeatureManagement sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionProvider, IDisposable { private const string FeatureFiltersSectionName = "EnabledFor"; + private const string DefaultDefinitionSectionName = "Default"; private readonly IConfiguration _configuration; private readonly ConcurrentDictionary _definitions; private IDisposable _changeSubscription; @@ -56,6 +59,11 @@ public Task GetFeatureDefinitionAsync(string featureName) // Query by feature name FeatureDefinition definition = _definitions.GetOrAdd(featureName, (name) => ReadFeatureDefinition(name)); + FeatureDefinition defaultDefinition = + _definitions.GetOrAdd(DefaultDefinitionSectionName, (name) => ReadFeatureDefinition(name)); + + definition = MergeDefinitions(definition, defaultDefinition); + return Task.FromResult(definition); } @@ -77,14 +85,14 @@ public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() { // // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - yield return _definitions.GetOrAdd(featureSection.Key, (_) => ReadFeatureDefinition(featureSection)); + yield return _definitions.GetOrAdd(featureSection.Key, (_) => ReadFeatureDefinition(featureSection)); } } private FeatureDefinition ReadFeatureDefinition(string featureName) { IConfigurationSection configuration = GetFeatureDefinitionSections() - .FirstOrDefault(section => section.Key.Equals(featureName, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(section => section.Key.Equals(featureName, StringComparison.OrdinalIgnoreCase)); if (configuration == null) { @@ -149,14 +157,16 @@ We support } else { - IEnumerable filterSections = configurationSection.GetSection(FeatureFiltersSectionName).GetChildren(); + IEnumerable filterSections = + configurationSection.GetSection(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 i) && + !string.IsNullOrEmpty(section[nameof(FeatureFilterConfiguration.Name)])) { enabledFor.Add(new FeatureFilterConfiguration() { @@ -178,7 +188,8 @@ 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(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase))) { // // Look for feature definitions under the "FeatureManagement" section @@ -189,5 +200,26 @@ private IEnumerable GetFeatureDefinitionSections() return _configuration.GetChildren(); } } + + private FeatureDefinition MergeDefinitions(FeatureDefinition targetDefinition, + FeatureDefinition otherDefinition) + { + if (targetDefinition is null) + { + return null; + } + + if (otherDefinition is null) + { + return targetDefinition; + } + + return new FeatureDefinition + { + Name = targetDefinition.Name, + EnabledFor = targetDefinition.EnabledFor.Union(otherDefinition.EnabledFor, + new CompareByNameFeatureFilterConfigurationComparer()) + }; + } } -} +} \ No newline at end of file diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index af67fe8e..076571fe 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -669,6 +669,27 @@ public async Task ThreadsafeSnapshot() } } + [Fact] + public async Task DefaultFeatureDefinition() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings-with-default.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + var featureDefinitionProvider = serviceProvider.GetRequiredService(); + + var definition = await featureDefinitionProvider.GetFeatureDefinitionAsync(nameof(Features.OffTestFeature)); + + Assert.Collection(definition.EnabledFor, configuration => configuration.Name.Equals("Test")); + } + private static void DisableEndpointRouting(MvcOptions options) { #if NET5_0 || NETCOREAPP3_1 diff --git a/tests/Tests.FeatureManagement/appsettings-with-default.json b/tests/Tests.FeatureManagement/appsettings-with-default.json new file mode 100644 index 00000000..d17e5830 --- /dev/null +++ b/tests/Tests.FeatureManagement/appsettings-with-default.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "AllowedHosts": "*", + + "FeatureManagement": { + "Default": { + "EnabledFor": [ + { + "name": "Test" + } + ] + }, + "OffTestFeature": false + } +}