diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 72ed0757..c71a7ed4 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -21,8 +21,7 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP { private readonly IConfiguration _configuration; private readonly ConfigurationFeatureDefinitionProviderOptions _options; - private IEnumerable _dotnetFeatureDefinitionSections; - private IEnumerable _microsoftFeatureDefinitionSections; + private IEnumerable _featureDefinitionSources; private readonly ConcurrentDictionary> _definitions; private IDisposable _changeSubscription; private int _stale = 0; @@ -31,6 +30,21 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP const string ParseValueErrorString = "Invalid setting '{0}' with value '{1}' for feature '{2}'."; + private sealed class FeatureDefinitionSectionsBySource + { + public FeatureDefinitionSectionsBySource( + IEnumerable dotnetSections, + IEnumerable microsoftSections) + { + DotnetSections = dotnetSections; + MicrosoftSections = microsoftSections; + } + + public IEnumerable DotnetSections { get; } + + public IEnumerable MicrosoftSections { get; } + } + /// /// Creates a configuration feature definition provider. /// @@ -58,7 +72,7 @@ public ConfigurationFeatureDefinitionProvider( _getFeatureDefinitionFunc = (featureName) => { - return Task.FromResult(GetMicrosoftSchemaFeatureDefinition(featureName) ?? GetDotnetSchemaFeatureDefinition(featureName)); + return Task.FromResult(GetFeatureDefinition(featureName)); }; } @@ -103,9 +117,7 @@ public Task GetFeatureDefinitionAsync(string featureName) if (Interlocked.Exchange(ref _stale, 0) != 0) { - _dotnetFeatureDefinitionSections = GetDotnetFeatureDefinitionSections(); - - _microsoftFeatureDefinitionSections = GetMicrosoftFeatureDefinitionSections(); + LoadFeatureDefinitionSections(); _definitions.Clear(); } @@ -128,67 +140,90 @@ public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() if (Interlocked.Exchange(ref _stale, 0) != 0) { - _dotnetFeatureDefinitionSections = GetDotnetFeatureDefinitionSections(); - - _microsoftFeatureDefinitionSections = GetMicrosoftFeatureDefinitionSections(); + LoadFeatureDefinitionSections(); _definitions.Clear(); } - foreach (IConfigurationSection featureSection in _microsoftFeatureDefinitionSections) + HashSet processedFeatureNames = _options.CustomConfigurationMergingEnabled + ? new HashSet(StringComparer.OrdinalIgnoreCase) + : null; + + foreach (FeatureDefinitionSectionsBySource source in _featureDefinitionSources) { - string featureName = featureSection[MicrosoftFeatureManagementFields.Id]; + IEnumerable featureNames = source.MicrosoftSections + .Select(section => section[MicrosoftFeatureManagementFields.Id]) + .Concat(source.DotnetSections.Select(section => section.Key)); - if (string.IsNullOrEmpty(featureName)) + foreach (string featureName in featureNames) { - continue; - } + if (string.IsNullOrEmpty(featureName) || + (processedFeatureNames != null && !processedFeatureNames.Add(featureName))) + { + continue; + } - // - // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - FeatureDefinition definition = _definitions.GetOrAdd(featureName, _getFeatureDefinitionFunc).Result; + FeatureDefinition definition = _definitions.GetOrAdd(featureName, _getFeatureDefinitionFunc).Result; - if (definition != null) - { - yield return definition; + if (definition != null) + { + yield return definition; + } } } + } - foreach (IConfigurationSection featureSection in _dotnetFeatureDefinitionSections) + private void EnsureInit() + { + if (_initialized == 0) { - string featureName = featureSection.Key; + LoadFeatureDefinitionSections(); - if (string.IsNullOrEmpty(featureName)) - { - continue; - } + _initialized = 1; + } + } - // - // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - FeatureDefinition definition = _definitions.GetOrAdd(featureName, _getFeatureDefinitionFunc).Result; + private void LoadFeatureDefinitionSections() + { + // + // Determine root fallback from the full configuration, not from individual providers. + bool useRootConfiguration = RootConfigurationFallbackEnabled && + !_configuration.GetSection(DotnetFeatureManagementFields.FeatureManagementSectionName).Exists() && + !_configuration.GetChildren().Any(section => + string.Equals(section.Key, MicrosoftFeatureManagementFields.FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase)); - if (definition != null) - { - yield return definition; - } + if (!_options.CustomConfigurationMergingEnabled) + { + _featureDefinitionSources = new[] { GetFeatureDefinitionSections(_configuration, useRootConfiguration) }; + return; } + + var featureDefinitionSources = new List(); + + FindFeatureDefinitionSources(_configuration, useRootConfiguration, featureDefinitionSources); + + _featureDefinitionSources = featureDefinitionSources; } - private void EnsureInit() + private FeatureDefinition GetFeatureDefinition(string featureName) { - if (_initialized == 0) + foreach (FeatureDefinitionSectionsBySource source in _featureDefinitionSources) { - _dotnetFeatureDefinitionSections = GetDotnetFeatureDefinitionSections(); - - _microsoftFeatureDefinitionSections = GetMicrosoftFeatureDefinitionSections(); + FeatureDefinition definition = GetMicrosoftSchemaFeatureDefinition(featureName, source.MicrosoftSections) ?? + GetDotnetSchemaFeatureDefinition(featureName, source.DotnetSections); - _initialized = 1; + if (definition != null) + { + return definition; + } } + + return null; } - private FeatureDefinition GetDotnetSchemaFeatureDefinition(string featureName) + private FeatureDefinition GetDotnetSchemaFeatureDefinition(string featureName, IEnumerable sections) { - IConfigurationSection dotnetFeatureDefinitionConfiguration = _dotnetFeatureDefinitionSections + IConfigurationSection dotnetFeatureDefinitionConfiguration = sections .FirstOrDefault(section => string.Equals(section.Key, featureName, StringComparison.OrdinalIgnoreCase)); @@ -200,9 +235,9 @@ private FeatureDefinition GetDotnetSchemaFeatureDefinition(string featureName) return ParseDotnetSchemaFeatureDefinition(dotnetFeatureDefinitionConfiguration); } - private FeatureDefinition GetMicrosoftSchemaFeatureDefinition(string featureName) + private FeatureDefinition GetMicrosoftSchemaFeatureDefinition(string featureName, IEnumerable sections) { - IConfigurationSection microsoftFeatureDefinitionConfiguration = _microsoftFeatureDefinitionSections + IConfigurationSection microsoftFeatureDefinitionConfiguration = sections .LastOrDefault(section => string.Equals(section[MicrosoftFeatureManagementFields.Id], featureName, StringComparison.OrdinalIgnoreCase)); @@ -214,64 +249,33 @@ private FeatureDefinition GetMicrosoftSchemaFeatureDefinition(string featureName return ParseMicrosoftSchemaFeatureDefinition(microsoftFeatureDefinitionConfiguration); } - private IEnumerable GetDotnetFeatureDefinitionSections() - { - IConfigurationSection featureManagementConfigurationSection = _configuration.GetSection(DotnetFeatureManagementFields.FeatureManagementSectionName); - - if (featureManagementConfigurationSection.Exists()) - { - return featureManagementConfigurationSection.GetChildren(); - } - - // - // Root configuration fallback only applies to .NET schema. - // If Microsoft schema can be found, root configuration fallback will not be effective. - if (RootConfigurationFallbackEnabled && - !_configuration.GetChildren() - .Any(section => - string.Equals(section.Key, MicrosoftFeatureManagementFields.FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase))) - { - return _configuration.GetChildren(); - } - - return Enumerable.Empty(); - } - - private IEnumerable GetMicrosoftFeatureDefinitionSections() + private FeatureDefinitionSectionsBySource GetFeatureDefinitionSections(IConfiguration configuration, bool useRootConfiguration) { - if (!_options.CustomConfigurationMergingEnabled) - { - return _configuration.GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) + return new FeatureDefinitionSectionsBySource( + useRootConfiguration + ? configuration.GetChildren() + : configuration.GetSection(DotnetFeatureManagementFields.FeatureManagementSectionName).GetChildren(), + configuration.GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName) - .GetChildren(); - } - - var featureDefinitionSections = new List(); - - FindFeatureFlags(_configuration, featureDefinitionSections); - - return featureDefinitionSections; + .GetChildren()); } - private void FindFeatureFlags(IConfiguration configuration, List featureDefinitionSections) + private void FindFeatureDefinitionSources( + IConfiguration configuration, + bool useRootConfiguration, + List featureDefinitionSources) { if (!(configuration is IConfigurationRoot configurationRoot) || configurationRoot.Providers.Any(provider => !(provider is ConfigurationProvider) && !(provider is ChainedConfigurationProvider))) { - IConfigurationSection featureFlagsSection = configuration - .GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) - .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName); - - if (featureFlagsSection.Exists()) - { - featureDefinitionSections.AddRange(featureFlagsSection.GetChildren()); - } - + featureDefinitionSources.Add(GetFeatureDefinitionSections(configuration, useRootConfiguration)); return; } - foreach (IConfigurationProvider provider in configurationRoot.Providers) + // + // Keep sources in highest-to-lowest precedence order, including chained providers. + foreach (IConfigurationProvider provider in configurationRoot.Providers.Reverse()) { if (provider is ConfigurationProvider configurationProvider) { @@ -281,18 +285,11 @@ private void FindFeatureFlags(IConfiguration configuration, List - /// Controls whether to enable the custom configuration merging logic for Microsoft schema feature flags or fall back to .NET's native configuration merging behavior. + /// Controls whether to enable custom configuration merging for feature flags from multiple configuration sources. /// /// - /// This option only affects Microsoft schema feature flags (e.g. feature_management:feature_flags arrays). .NET schema feature flags are not affected by this setting. - /// - /// The uses custom configuration merging logic for Microsoft schema feature flags to ensure that - /// feature flags with the same ID from different configuration sources are merged correctly based on their logical identity rather than array position. - /// By default, the provider bypasses .NET's native array merging behavior which merges arrays by index position and can lead to unexpected results when feature flags are defined across multiple configuration sources. + /// The uses custom configuration merging logic to ensure that feature flags with the same ID from + /// different configuration sources are merged correctly based on their logical identity rather than array position. The last configuration source that + /// defines a feature flag wins, even when earlier and later sources use different feature management schemas. If the same configuration source defines a + /// feature flag in both the .NET schema and the Microsoft schema, the Microsoft schema definition takes precedence. + /// + /// When custom merging is enabled, both .NET and Microsoft schema feature flag definitions are selected as a whole from the winning source. + /// Properties, feature filters, and filter parameters are not merged across sources. Each overriding source must supply the complete intended definition. + /// This bypasses .NET's native array merging behavior, which merges arrays by index position and can lead to unexpected results when feature flags are defined across multiple configuration sources. + /// For configuration that cannot be inspected provider by provider, the combined configuration is treated as a single source. + /// When custom merging is disabled (the default), .NET's native configuration merging behavior is used, and Microsoft schema definitions take precedence + /// over .NET schema definitions regardless of configuration source order. /// /// Consider the following configuration sources: /// Configuration Source 1: diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index a2aaeed1..701e5c8c 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -75,8 +75,10 @@ public async Task ReadsConfiguration() Assert.True(hasItems); } - [Fact] - public async Task ReadsTopLevelConfiguration() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReadsTopLevelConfiguration(bool customMergingEnabled) { var stream = new MemoryStream(Encoding.UTF8.GetBytes($"{{\"AllowedHosts\": \"*\", \"FeatureFlags\": {{\"FeatureX\": true}}}}")); @@ -84,6 +86,8 @@ public async Task ReadsTopLevelConfiguration() var services = new ServiceCollection(); + services.Configure(options => + options.CustomConfigurationMergingEnabled = customMergingEnabled); services.AddFeatureManagement(config.GetSection("FeatureFlags")); ServiceProvider serviceProvider = services.BuildServiceProvider(); @@ -113,6 +117,8 @@ public async Task ReadsTopLevelConfiguration() services = new ServiceCollection(); + services.Configure(options => + options.CustomConfigurationMergingEnabled = customMergingEnabled); services.AddFeatureManagement(config.GetSection("FeatureFlags")); serviceProvider = services.BuildServiceProvider(); @@ -255,6 +261,438 @@ public async Task RespectsAllFeatureManagementSchemas() Assert.True(await featureManager.IsEnabledAsync("FeatureZ")); } + [Fact] + public async Task CustomMergingUsesConfigurationSourceOrderAcrossSchemas() + { + var microsoftSchemaFeature = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }; + var dotnetSchemaFeature = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:crossschemafeature:EnabledFor:0:Name"] = "Test", + ["FeatureManagement:crossschemafeature:EnabledFor:0:Parameters:Source"] = "Dotnet" + }; + var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }; + + IConfiguration microsoftThenDotnet = new ConfigurationBuilder() + .AddInMemoryCollection(microsoftSchemaFeature) + .AddInMemoryCollection(dotnetSchemaFeature) + .Build(); + + using (var provider = new ConfigurationFeatureDefinitionProvider(microsoftThenDotnet, mergeOptions)) + { + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("CROSSSCHEMAFEATURE"); + FeatureFilterConfiguration filter = Assert.Single(definition.EnabledFor); + + Assert.Equal(FeatureStatus.Conditional, definition.Status); + Assert.Equal("Test", filter.Name); + Assert.Equal("Dotnet", filter.Parameters["Source"]); + } + + IConfiguration dotnetThenMicrosoft = new ConfigurationBuilder() + .AddInMemoryCollection(dotnetSchemaFeature) + .AddInMemoryCollection(microsoftSchemaFeature) + .Build(); + + using (var provider = new ConfigurationFeatureDefinitionProvider(dotnetThenMicrosoft, mergeOptions)) + { + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("CrossSchemaFeature"); + + Assert.Equal(FeatureStatus.Disabled, definition.Status); + Assert.Empty(definition.EnabledFor); + } + } + + [Fact] + public async Task CustomMergingPrefersMicrosoftSchemaWithinSameConfigurationSource() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:CrossSchemaFeature"] = bool.TrueString, + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .Build(); + var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }; + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, mergeOptions); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("CrossSchemaFeature"); + + Assert.Equal(FeatureStatus.Disabled, definition.Status); + } + + [Fact] + public async Task DefaultMergingContinuesToPreferMicrosoftSchema() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:CrossSchemaFeature"] = bool.TrueString + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("CrossSchemaFeature"); + + Assert.Equal(FeatureStatus.Disabled, definition.Status); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CustomMergingDeduplicatesCrossSchemaFeaturesFromChainedConfiguration(bool chainedSourceLast) + { + var microsoftSchemaFeature = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }; + IConfiguration innerConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(microsoftSchemaFeature) + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:crossschemafeature"] = bool.TrueString + }) + .Build(); + IConfiguration chainedConfiguration = new ConfigurationBuilder() + .AddConfiguration(innerConfiguration) + .Build(); + var builder = new ConfigurationBuilder(); + + if (chainedSourceLast) + { + builder.AddInMemoryCollection(microsoftSchemaFeature).AddConfiguration(chainedConfiguration); + } + else + { + builder.AddConfiguration(chainedConfiguration).AddInMemoryCollection(microsoftSchemaFeature); + } + + IConfiguration configuration = builder.Build(); + var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }; + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, mergeOptions); + var definitions = new List(); + + await foreach (FeatureDefinition definition in provider.GetAllFeatureDefinitionsAsync()) + { + definitions.Add(definition); + } + + FeatureDefinition crossSchemaDefinition = Assert.Single(definitions); + Assert.Equal(chainedSourceLast ? FeatureStatus.Conditional : FeatureStatus.Disabled, crossSchemaDefinition.Status); + + var featureManager = new FeatureManager(provider); + var featureNames = new List(); + + await foreach (string featureName in featureManager.GetFeatureNamesAsync()) + { + featureNames.Add(featureName); + } + + Assert.Single(featureNames); + Assert.Equal(chainedSourceLast, await featureManager.IsEnabledAsync("CROSSSCHEMAFEATURE")); + } + + [Fact] + public async Task CustomMergingRecalculatesSchemaPrecedenceAfterReload() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "ReloadFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .AddInMemoryCollection() + .Build(); + IConfigurationProvider lastConfigurationProvider = configuration.Providers.Last(); + var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }; + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, mergeOptions); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("ReloadFeature"); + Assert.Equal(FeatureStatus.Disabled, definition.Status); + + lastConfigurationProvider.Set("FeatureManagement:ReloadFeature", bool.TrueString); + configuration.Reload(); + + definition = await provider.GetFeatureDefinitionAsync("ReloadFeature"); + Assert.Equal(FeatureStatus.Conditional, definition.Status); + Assert.Single(definition.EnabledFor); + + lastConfigurationProvider.Set("feature_management:feature_flags:0:id", "ReloadFeature"); + lastConfigurationProvider.Set("feature_management:feature_flags:0:enabled", bool.FalseString); + configuration.Reload(); + + var definitions = new List(); + + await foreach (FeatureDefinition currentDefinition in provider.GetAllFeatureDefinitionsAsync()) + { + definitions.Add(currentDefinition); + } + + Assert.Equal(FeatureStatus.Disabled, Assert.Single(definitions).Status); + Assert.Equal(FeatureStatus.Disabled, (await provider.GetFeatureDefinitionAsync("ReloadFeature")).Status); + Assert.Null(await provider.GetFeatureDefinitionAsync("RenamedFeature")); + + lastConfigurationProvider.Set("feature_management:feature_flags:0:id", "RenamedFeature"); + configuration.Reload(); + + Assert.Equal(FeatureStatus.Conditional, (await provider.GetFeatureDefinitionAsync("ReloadFeature")).Status); + Assert.Equal(FeatureStatus.Disabled, (await provider.GetFeatureDefinitionAsync("RenamedFeature")).Status); + } + + [Fact] + public async Task CustomMergingDoesNotInheritEarlierAlwaysOnValue() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:TargetedFeature"] = bool.TrueString + }) + .AddInMemoryCollection(new Dictionary + { + ["feature_management:feature_flags:0:id"] = "TargetedFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:TargetedFeature:EnabledFor:0:Name"] = "Microsoft.Targeting", + ["FeatureManagement:TargetedFeature:EnabledFor:0:Parameters:Audience:Users:0"] = "Alice", + ["FeatureManagement:TargetedFeature:EnabledFor:0:Parameters:Audience:DefaultRolloutPercentage"] = "0" + }) + .Build(); + var services = new ServiceCollection(); + + services.AddSingleton(configuration).AddFeatureManagement(); + services.Configure(options => + options.CustomConfigurationMergingEnabled = true); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + Assert.True(await featureManager.IsEnabledAsync("TargetedFeature", new TargetingContext { UserId = "Alice" })); + Assert.False(await featureManager.IsEnabledAsync("TargetedFeature", new TargetingContext { UserId = "Bob" })); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MergesDotnetPropertiesOnlyWhenCustomMergingIsDisabled(bool customMergingEnabled) + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:RequirementType"] = "All", + ["FeatureManagement:Feature:EnabledFor:0:Name"] = "Test", + ["FeatureManagement:Feature:EnabledFor:0:Parameters:Inherited"] = "Earlier", + ["FeatureManagement:Feature:EnabledFor:0:Parameters:Shared"] = "Earlier", + ["FeatureManagement:Feature:EnabledFor:1:Name"] = "AlwaysOn" + }) + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:EnabledFor:0:Name"] = "Test", + ["FeatureManagement:Feature:EnabledFor:0:Parameters:Shared"] = "Later" + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = customMergingEnabled + }); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("Feature"); + List filters = definition.EnabledFor.ToList(); + + Assert.Equal(customMergingEnabled ? RequirementType.Any : RequirementType.All, definition.RequirementType); + Assert.Equal(customMergingEnabled ? 1 : 2, filters.Count); + Assert.Equal("Test", filters[0].Name); + Assert.Equal("Later", filters[0].Parameters["Shared"]); + Assert.Equal(customMergingEnabled ? null : "Earlier", filters[0].Parameters["Inherited"]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MergesPartialDotnetDefinitionsOnlyWhenCustomMergingIsDisabled(bool customMergingEnabled) + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:EnabledFor:0:Name"] = "Test" + }) + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:EnabledFor:0:Parameters:Source"] = "Later" + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = customMergingEnabled + }); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("Feature"); + + if (customMergingEnabled) + { + Assert.Empty(definition.EnabledFor); + } + else + { + FeatureFilterConfiguration filter = Assert.Single(definition.EnabledFor); + + Assert.Equal("Test", filter.Name); + Assert.Equal("Later", filter.Parameters["Source"]); + } + } + + [Fact] + public async Task CustomMergingDoesNotInheritFiltersForDisabledDotnetDefinition() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:EnabledFor:0:Name"] = "AlwaysOn" + }) + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature"] = bool.FalseString + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }); + var featureManager = new FeatureManager(provider); + + Assert.Empty((await provider.GetFeatureDefinitionAsync("Feature")).EnabledFor); + Assert.False(await featureManager.IsEnabledAsync("Feature")); + } + + [Fact] + public async Task CustomMergingTreatsConfigurationSectionAsOneSource() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Flags:feature_management:feature_flags:0:id"] = "Feature", + ["Flags:feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .AddInMemoryCollection(new Dictionary + { + ["Flags:FeatureManagement:Feature"] = bool.TrueString + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration.GetSection("Flags"), new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }); + + Assert.Equal(FeatureStatus.Disabled, (await provider.GetFeatureDefinitionAsync("Feature")).Status); + } + + [Fact] + public async Task CustomMergingParsesOnlyRequestedWinningDefinitions() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature"] = bool.TrueString, + ["feature_management:feature_flags:0:id"] = "Feature", + ["feature_management:feature_flags:0:enabled"] = "Invalid", + ["feature_management:feature_flags:1:id"] = "Feature", + ["feature_management:feature_flags:1:enabled"] = bool.FalseString, + ["feature_management:feature_flags:2:id"] = "UnrequestedFeature", + ["feature_management:feature_flags:2:enabled"] = "Invalid" + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }); + + Assert.Equal(FeatureStatus.Disabled, (await provider.GetFeatureDefinitionAsync("Feature")).Status); + Assert.Null(await provider.GetFeatureDefinitionAsync("MissingFeature")); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task CustomMergingDeterminesRootFallbackAcrossAllSources(bool hasDotnetSection, bool hasMicrosoftSection) + { + var laterSource = new Dictionary + { + ["RootFeature"] = bool.FalseString + }; + + if (hasDotnetSection) + { + laterSource["FeatureManagement:DotnetFeature"] = bool.TrueString; + } + + if (hasMicrosoftSection) + { + laterSource["feature_management:feature_flags:0:id"] = "MicrosoftFeature"; + laterSource["feature_management:feature_flags:0:enabled"] = bool.TrueString; + } + + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["RootFeature"] = bool.TrueString + }) + .AddInMemoryCollection(laterSource) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }) + { + RootConfigurationFallbackEnabled = true + }; + + FeatureDefinition rootFeature = await provider.GetFeatureDefinitionAsync("RootFeature"); + + if (hasDotnetSection || hasMicrosoftSection) + { + Assert.Null(rootFeature); + Assert.NotNull(await provider.GetFeatureDefinitionAsync(hasDotnetSection ? "DotnetFeature" : "MicrosoftFeature")); + } + else + { + Assert.NotNull(rootFeature); + Assert.Empty(rootFeature.EnabledFor); + } + } + [Fact] public async Task ThrowsForMissingFeatures() {