Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<!-- Official Version -->
<PropertyGroup>
<MajorVersion>4</MajorVersion>
<MinorVersion>2</MinorVersion>
<PatchVersion>1</PatchVersion>
<MinorVersion>3</MinorVersion>
<PatchVersion>0</PatchVersion>
</PropertyGroup>

<Import Project="..\..\build\Versioning.props" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<!-- Official Version -->
<PropertyGroup>
<MajorVersion>4</MajorVersion>
<MinorVersion>2</MinorVersion>
<PatchVersion>1</PatchVersion>
<MinorVersion>3</MinorVersion>
<PatchVersion>0</PatchVersion>
</PropertyGroup>

<Import Project="..\..\build\Versioning.props" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP
// 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 readonly IConfiguration _configuration;
private readonly ConfigurationFeatureDefinitionProviderOptions _options;
private IEnumerable<IConfigurationSection> _dotnetFeatureDefinitionSections;
private IEnumerable<IConfigurationSection> _microsoftFeatureDefinitionSections;
private readonly ConcurrentDictionary<string, Task<FeatureDefinition>> _definitions;
Expand All @@ -37,9 +38,13 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP
/// Creates a configuration feature definition provider.
/// </summary>
/// <param name="configuration">The configuration of feature definitions.</param>
public ConfigurationFeatureDefinitionProvider(IConfiguration configuration)
/// <param name="options">The options for the configuration feature definition provider.</param>
public ConfigurationFeatureDefinitionProvider(
IConfiguration configuration,
ConfigurationFeatureDefinitionProviderOptions options = null)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_options = options ?? new ConfigurationFeatureDefinitionProviderOptions();
_definitions = new ConcurrentDictionary<string, Task<FeatureDefinition>>();

_changeSubscription = ChangeToken.OnChange(
Expand Down Expand Up @@ -229,6 +234,13 @@ private IEnumerable<IConfigurationSection> GetDotnetFeatureDefinitionSections()

private IEnumerable<IConfigurationSection> GetMicrosoftFeatureDefinitionSections()
{
if (!_options.CustomConfigurationMergingEnabled)
{
return _configuration.GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName)
.GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName)
.GetChildren();
}

var featureDefinitionSections = new List<IConfigurationSection>();

FindFeatureFlags(_configuration, featureDefinitionSections);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
namespace Microsoft.FeatureManagement
{
/// <summary>
/// Options that control the behavior of the <see cref="ConfigurationFeatureDefinitionProvider"/>.
/// </summary>
public class ConfigurationFeatureDefinitionProviderOptions
{
/// <summary>
/// Controls whether to enable the custom configuration merging logic for Microsoft schema feature flags or fall back to .NET's native configuration merging behavior.
/// </summary>
/// <remarks>
/// 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 <see cref="ConfigurationFeatureDefinitionProvider"/> 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.
///
/// Consider the following configuration sources:
/// Configuration Source 1:
/// {
/// "feature_management": {
/// "feature_flags": [
/// {
/// "id": "feature1",
/// "enabled": true
/// },
/// {
/// "id": "feature2",
/// "enabled": false
/// }
/// ]
/// }
/// }
///
/// Configuration Source 2:
/// {
/// "feature_management": {
/// "feature_flags": [
/// {
/// "id": "feature2",
/// "enabled": true
/// }
/// ]
/// }
/// }
///
/// With custom merging:
/// - feature1: enabled = true
/// - feature2: enabled = true (last declaration wins)
///
/// With native .NET merging:
/// - feature1 would be overwritten by feature2 from source 2 (index-based merging, e.g. feature_flags:0:id)
/// - feature2: enabled = false (from source 1, index 1)
/// </remarks>
public bool CustomConfigurationMergingEnabled { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<!-- Official Version -->
<PropertyGroup>
<MajorVersion>4</MajorVersion>
<MinorVersion>2</MinorVersion>
<PatchVersion>1</PatchVersion>
<MinorVersion>3</MinorVersion>
<PatchVersion>0</PatchVersion>
</PropertyGroup>

<Import Project="..\..\build\Versioning.props" />
Expand Down
60 changes: 35 additions & 25 deletions src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,18 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec

AddCommonFeatureManagementServices(services);

services.AddSingleton(sp => new FeatureManager(
sp.GetRequiredService<IFeatureDefinitionProvider>(),
sp.GetRequiredService<IOptions<FeatureManagementOptions>>().Value)
{
FeatureFilters = sp.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>(),
SessionManagers = sp.GetRequiredService<IEnumerable<ISessionManager>>(),
Cache = sp.GetRequiredService<IMemoryCache>(),
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<FeatureManager>(),
TargetingContextAccessor = sp.GetService<ITargetingContextAccessor>(),
AssignerOptions = sp.GetRequiredService<IOptions<TargetingEvaluationOptions>>().Value
});
services.AddSingleton(sp =>
new FeatureManager(
sp.GetRequiredService<IFeatureDefinitionProvider>(),
sp.GetRequiredService<IOptions<FeatureManagementOptions>>().Value)
{
FeatureFilters = sp.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>(),
SessionManagers = sp.GetRequiredService<IEnumerable<ISessionManager>>(),
Cache = sp.GetRequiredService<IMemoryCache>(),
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<FeatureManager>(),
TargetingContextAccessor = sp.GetService<ITargetingContextAccessor>(),
AssignerOptions = sp.GetRequiredService<IOptions<TargetingEvaluationOptions>>().Value
});

services.TryAddSingleton<IFeatureManager>(sp => sp.GetRequiredService<FeatureManager>());

Expand All @@ -70,7 +71,9 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec
}

services.AddSingleton<IFeatureDefinitionProvider>(sp =>
new ConfigurationFeatureDefinitionProvider(configuration)
new ConfigurationFeatureDefinitionProvider(
configuration,
sp.GetRequiredService<IOptions<ConfigurationFeatureDefinitionProviderOptions>>().Value)
{
RootConfigurationFallbackEnabled = true,
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<ConfigurationFeatureDefinitionProvider>()
Expand All @@ -96,17 +99,18 @@ public static IFeatureManagementBuilder AddScopedFeatureManagement(this IService

AddCommonFeatureManagementServices(services);

services.AddScoped(sp => new FeatureManager(
sp.GetRequiredService<IFeatureDefinitionProvider>(),
sp.GetRequiredService<IOptions<FeatureManagementOptions>>().Value)
{
FeatureFilters = sp.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>(),
SessionManagers = sp.GetRequiredService<IEnumerable<ISessionManager>>(),
Cache = sp.GetRequiredService<IMemoryCache>(),
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<FeatureManager>(),
TargetingContextAccessor = sp.GetService<ITargetingContextAccessor>(),
AssignerOptions = sp.GetRequiredService<IOptions<TargetingEvaluationOptions>>().Value
});
services.AddScoped(sp =>
new FeatureManager(
sp.GetRequiredService<IFeatureDefinitionProvider>(),
sp.GetRequiredService<IOptions<FeatureManagementOptions>>().Value)
{
FeatureFilters = sp.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>(),
SessionManagers = sp.GetRequiredService<IEnumerable<ISessionManager>>(),
Cache = sp.GetRequiredService<IMemoryCache>(),
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<FeatureManager>(),
TargetingContextAccessor = sp.GetService<ITargetingContextAccessor>(),
AssignerOptions = sp.GetRequiredService<IOptions<TargetingEvaluationOptions>>().Value
});

services.TryAddScoped<IFeatureManager>(sp => sp.GetRequiredService<FeatureManager>());

Expand All @@ -130,7 +134,9 @@ public static IFeatureManagementBuilder AddScopedFeatureManagement(this IService
}

services.AddSingleton<IFeatureDefinitionProvider>(sp =>
new ConfigurationFeatureDefinitionProvider(configuration)
new ConfigurationFeatureDefinitionProvider(
configuration,
sp.GetRequiredService<IOptions<ConfigurationFeatureDefinitionProviderOptions>>().Value)
{
RootConfigurationFallbackEnabled = true,
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<ConfigurationFeatureDefinitionProvider>()
Expand All @@ -145,7 +151,11 @@ private static void AddCommonFeatureManagementServices(IServiceCollection servic

services.AddMemoryCache();

services.TryAddSingleton<IFeatureDefinitionProvider, ConfigurationFeatureDefinitionProvider>();
services.TryAddSingleton<IFeatureDefinitionProvider>(sp =>
new ConfigurationFeatureDefinitionProvider(
sp.GetRequiredService<IConfiguration>(),
sp.GetRequiredService<IOptions<ConfigurationFeatureDefinitionProviderOptions>>().Value)
);

services.AddScoped<FeatureManagerSnapshot>();

Expand Down
90 changes: 88 additions & 2 deletions tests/Tests.FeatureManagement/FeatureManagementTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,11 @@ public async Task LastFeatureFlagWins()
[Fact]
public async Task MergesFeatureFlagsFromDifferentConfigurationSources()
{
var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions()
{
CustomConfigurationMergingEnabled = true
};

/*
* appsettings1.json
* Feature1: true
Expand Down Expand Up @@ -425,18 +430,99 @@ public async Task MergesFeatureFlagsFromDifferentConfigurationSources()
.AddJsonFile("appsettings3.json")
.Build();

var featureManager1 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration1));
var featureManager1 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration1, mergeOptions));
Assert.True(await featureManager1.IsEnabledAsync("FeatureA"));
Assert.True(await featureManager1.IsEnabledAsync("FeatureB"));
Assert.True(await featureManager1.IsEnabledAsync("Feature1"));
Assert.False(await featureManager1.IsEnabledAsync("Feature2")); // appsettings2 should override appsettings1

var featureManager2 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration2));
var featureManager2 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration2, mergeOptions));
Assert.True(await featureManager2.IsEnabledAsync("FeatureA"));
Assert.True(await featureManager2.IsEnabledAsync("FeatureB"));
Assert.True(await featureManager2.IsEnabledAsync("FeatureC"));
Assert.False(await featureManager2.IsEnabledAsync("Feature1")); // appsettings3 should override previous settings
Assert.False(await featureManager2.IsEnabledAsync("Feature2")); // appsettings3 should override previous settings

//
// default behavior
var featureManager3 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration1));
Assert.False(await featureManager3.IsEnabledAsync("FeatureA")); // it will be overridden by FeatureB
Assert.True(await featureManager3.IsEnabledAsync("FeatureB"));
Assert.True(await featureManager3.IsEnabledAsync("Feature1"));
Assert.False(await featureManager3.IsEnabledAsync("Feature2")); // appsettings2 should override appsettings1

IConfiguration configuration3 = new ConfigurationBuilder()
.AddJsonFile("appsettings1.json")
.AddInMemoryCollection(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["feature_management:feature_flags:0:enabled"] = bool.FalseString,
["feature_management:feature_flags:1:enabled"] = bool.FalseString,
})
.Build();
var featureManager4 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration3));
Assert.False(await featureManager4.IsEnabledAsync("Feature1"));
Assert.False(await featureManager4.IsEnabledAsync("Feature2"));
Assert.True(await featureManager4.IsEnabledAsync("FeatureA"));

//
// DI usage
var services1 = new ServiceCollection();
services1
.AddSingleton(configuration2)
.AddFeatureManagement();
services1.Configure<ConfigurationFeatureDefinitionProviderOptions>(o =>
{
o.CustomConfigurationMergingEnabled = true;
});
ServiceProvider serviceProvider1 = services1.BuildServiceProvider();
IFeatureManager featureManager5 = serviceProvider1.GetRequiredService<IFeatureManager>();

Assert.True(await featureManager5.IsEnabledAsync("FeatureA"));
Assert.True(await featureManager5.IsEnabledAsync("FeatureB"));
Assert.True(await featureManager5.IsEnabledAsync("FeatureC"));
Assert.False(await featureManager5.IsEnabledAsync("Feature1"));
Assert.False(await featureManager5.IsEnabledAsync("Feature2"));

var services2 = new ServiceCollection();
services2
.AddSingleton(configuration2)
.AddFeatureManagement();
ServiceProvider serviceProvider2 = services2.BuildServiceProvider();
IFeatureManager featureManager6 = serviceProvider2.GetRequiredService<IFeatureManager>();

Assert.False(await featureManager6.IsEnabledAsync("FeatureA"));
Assert.False(await featureManager6.IsEnabledAsync("FeatureB"));
Assert.True(await featureManager6.IsEnabledAsync("FeatureC"));
Assert.False(await featureManager6.IsEnabledAsync("Feature1"));
Assert.False(await featureManager6.IsEnabledAsync("Feature2"));

var services3 = new ServiceCollection();
services3
.AddFeatureManagement(configuration2);
services3.Configure<ConfigurationFeatureDefinitionProviderOptions>(o =>
{
o.CustomConfigurationMergingEnabled = true;
});
ServiceProvider serviceProvider3 = services3.BuildServiceProvider();
IFeatureManager featureManager7 = serviceProvider3.GetRequiredService<IFeatureManager>();

Assert.True(await featureManager7.IsEnabledAsync("FeatureA"));
Assert.True(await featureManager7.IsEnabledAsync("FeatureB"));
Assert.True(await featureManager7.IsEnabledAsync("FeatureC"));
Assert.False(await featureManager7.IsEnabledAsync("Feature1"));
Assert.False(await featureManager7.IsEnabledAsync("Feature2"));

var services4 = new ServiceCollection();
services4
.AddFeatureManagement(configuration2);
ServiceProvider serviceProvider4 = services4.BuildServiceProvider();
IFeatureManager featureManager8 = serviceProvider4.GetRequiredService<IFeatureManager>();

Assert.False(await featureManager8.IsEnabledAsync("FeatureA"));
Assert.False(await featureManager8.IsEnabledAsync("FeatureB"));
Assert.True(await featureManager8.IsEnabledAsync("FeatureC"));
Assert.False(await featureManager8.IsEnabledAsync("Feature1"));
Assert.False(await featureManager8.IsEnabledAsync("Feature2"));
}
}

Expand Down
Loading