From 40474ac13479a7b4a69f18afefb7dfc2d4a0c086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Mon, 24 Aug 2026 23:37:05 +0300 Subject: [PATCH 1/6] feat(variants): allow in-memory objects --- src/Microsoft.FeatureManagement/FeatureManager.cs | 5 +++-- src/Microsoft.FeatureManagement/Variant.cs | 6 ++++++ src/Microsoft.FeatureManagement/VariantDefinition.cs | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index f7bca84a..a6ebed3c 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -847,10 +847,11 @@ private Variant GetVariantFromVariantDefinition(VariantDefinition variantDefinit variantConfiguration = variantDefinition.ConfigurationValue; } - return new Variant() + return new Variant { Name = variantDefinition.Name, - Configuration = variantConfiguration + Configuration = variantConfiguration, + ConfigurationObject = variantDefinition.ConfigurationObject, }; } } diff --git a/src/Microsoft.FeatureManagement/Variant.cs b/src/Microsoft.FeatureManagement/Variant.cs index f69a47ce..012b5f3b 100644 --- a/src/Microsoft.FeatureManagement/Variant.cs +++ b/src/Microsoft.FeatureManagement/Variant.cs @@ -19,5 +19,11 @@ public class Variant /// The configuration of the variant. /// public IConfigurationSection Configuration { get; set; } + + /// + /// The configuration of the variant. + /// When set, variants should prefer this over . + /// + public object ConfigurationObject { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/VariantDefinition.cs b/src/Microsoft.FeatureManagement/VariantDefinition.cs index 138d2fc5..0b8b0a3e 100644 --- a/src/Microsoft.FeatureManagement/VariantDefinition.cs +++ b/src/Microsoft.FeatureManagement/VariantDefinition.cs @@ -21,6 +21,14 @@ public class VariantDefinition /// public IConfigurationSection ConfigurationValue { get; set; } + /// + /// A parameter object that can be used as an alternative to . + /// Custom implementations can populate this property directly + /// instead of constructing an instance. + /// When set, variants should prefer this over . + /// + public object ConfigurationObject { get; set; } + /// /// Overrides the state of the feature if this variant has been assigned. /// From 90fe16e4af3ff3fe91659190b9aa7469733d35dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Mon, 24 Aug 2026 23:42:01 +0300 Subject: [PATCH 2/6] docs(variants): allow in-memory objects --- src/Microsoft.FeatureManagement/VariantDefinition.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.FeatureManagement/VariantDefinition.cs b/src/Microsoft.FeatureManagement/VariantDefinition.cs index 0b8b0a3e..b26fb13e 100644 --- a/src/Microsoft.FeatureManagement/VariantDefinition.cs +++ b/src/Microsoft.FeatureManagement/VariantDefinition.cs @@ -22,7 +22,7 @@ public class VariantDefinition public IConfigurationSection ConfigurationValue { get; set; } /// - /// A parameter object that can be used as an alternative to . + /// A configuration object that can be used as an alternative to . /// Custom implementations can populate this property directly /// instead of constructing an instance. /// When set, variants should prefer this over . From ee4128495951dc56ae8599519f6d30df889062fb Mon Sep 17 00:00:00 2001 From: Stepami Date: Fri, 11 Sep 2026 20:10:41 +0300 Subject: [PATCH 3/6] feat(variants): populate ConfigurationObject from configuration section --- .../ConfigurationFeatureDefinitionProvider.cs | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 72ed0757..53906497 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using System.Threading; @@ -119,7 +120,7 @@ public Task GetFeatureDefinitionAsync(string featureName) /// An enumerator which provides asynchronous iteration over feature definitions. // // The async key word is necessary for creating IAsyncEnumerable. - // The need to disable this warning occurs when implementing async stream synchronously. + // The need to disable this warning occurs when implementing async stream synchronously. #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() #pragma warning restore CS1998 @@ -300,9 +301,9 @@ private void FindFeatureFlags(IConfiguration configuration, List(configurationSection.Key, rawStatusOverride, MicrosoftFeatureManagementFields.VariantDefinitionStatusOverride); } + var configurationValue = section.GetSection( + MicrosoftFeatureManagementFields.VariantDefinitionConfigurationValue); + var variant = new VariantDefinition() { Name = section[MicrosoftFeatureManagementFields.Name], - ConfigurationValue = section.GetSection(MicrosoftFeatureManagementFields.VariantDefinitionConfigurationValue), + ConfigurationValue = configurationValue, + ConfigurationObject = configurationValue.Exists() + ? CreateConfigurationObject(configurationValue) + : null, StatusOverride = statusOverride }; @@ -602,6 +609,25 @@ private FeatureDefinition ParseMicrosoftSchemaFeatureDefinition(IConfigurationSe }; } + private static IReadOnlyDictionary CreateConfigurationObject(IConfigurationSection section) + { + var values = section + .AsEnumerable(makePathsRelative: true) + .Where(x => x.Value != null) + .ToDictionary( + entry => entry.Key, + entry => entry.Value, + StringComparer.OrdinalIgnoreCase); + + // Relative enumeration excludes the section's own value. Preserve it under the empty key. + if (section.Value != null) + { + values.Add(string.Empty, section.Value); + } + + return new ReadOnlyDictionary(values); + } + private static T ParseEnum(string feature, string rawValue, string fieldKeyword) where T : struct, Enum { From 2070378fe894d862efcb24079dca3fdbf59113dd Mon Sep 17 00:00:00 2001 From: Stepami Date: Fri, 11 Sep 2026 20:38:45 +0300 Subject: [PATCH 4/6] test(variants): populate ConfigurationObject from configuration section --- tests/Tests.FeatureManagement/FeatureManagementTest.cs | 9 +++++++++ tests/Tests.FeatureManagement/appsettings.json | 8 ++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index a2aaeed1..f6712cd1 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2086,6 +2086,15 @@ public async Task UsesVariants() // Test DefaultWhenEnabled and ConfigurationValue with inline IConfigurationSection variant = await featureManager.GetVariantAsync(Features.VariantFeatureDefaultEnabled, cancellationToken); + var configurationObject = (IReadOnlyDictionary)variant.ConfigurationObject; + Assert.True(configurationObject.Keys.ToHashSet().SetEquals(new[] + { + "Size", + "Color", + "Platform:Id", + "Platform:Screens:0", + "Platform:Screens:1" + })); Assert.Equal("Medium", variant.Name); Assert.Equal("450px", variant.Configuration["Size"]); diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index 018ef5c4..9b555e7e 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -295,7 +295,11 @@ "name": "Medium", "configuration_value": { "Size": "450px", - "Color": "Purple" + "Color": "Purple", + "Platform": { + "Id": "mobile", + "Screens": ["fullhd", "tablet"] + } } }, { @@ -569,4 +573,4 @@ } ] } -} +} From 88c41a66182b85056935cffad0222b14b0b4a921 Mon Sep 17 00:00:00 2001 From: Stepami Date: Fri, 11 Sep 2026 21:05:16 +0300 Subject: [PATCH 5/6] feat(variants): common consumption api --- .../VariantExtensions.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/Microsoft.FeatureManagement/VariantExtensions.cs diff --git a/src/Microsoft.FeatureManagement/VariantExtensions.cs b/src/Microsoft.FeatureManagement/VariantExtensions.cs new file mode 100644 index 00000000..61e2d76b --- /dev/null +++ b/src/Microsoft.FeatureManagement/VariantExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; + +namespace Microsoft.FeatureManagement +{ + /// + /// Extensions for . + /// + public static class VariantExtensions + { + /// + /// Gets the variant configuration as the requested type. + /// + /// The type of the configuration. + /// The variant to read. + /// + /// The supplied configuration object when assignable to ; + /// otherwise, the configuration bound to from . + /// Returns default when the variant or its configuration is absent. + /// + public static T GetConfiguration(this Variant variant) + { + if (variant == null) + { + return default; + } + + if (variant.ConfigurationObject is T typedConfigurationObject) + { + return typedConfigurationObject; + } + + return variant.Configuration != null + ? variant.Configuration.Get() + : default; + } + } +} From 2a758225f958b66b1b9723a29f8a9fa714642d6f Mon Sep 17 00:00:00 2001 From: Stepami Date: Fri, 11 Sep 2026 21:05:21 +0300 Subject: [PATCH 6/6] test(variants): common consumption api --- .../VariantExtensionsTest.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/Tests.FeatureManagement/VariantExtensionsTest.cs diff --git a/tests/Tests.FeatureManagement/VariantExtensionsTest.cs b/tests/Tests.FeatureManagement/VariantExtensionsTest.cs new file mode 100644 index 00000000..a47b5547 --- /dev/null +++ b/tests/Tests.FeatureManagement/VariantExtensionsTest.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.FeatureManagement; +using System.Collections.Generic; +using Xunit; + +namespace Tests.FeatureManagement +{ + public class VariantExtensionsTest + { + [Fact] + public void GetConfigurationReturnsNullForNullVariant() + { + Variant variant = null; + + Assert.Null(variant.GetConfiguration()); + } + + [Fact] + public void GetConfigurationPrefersAssignableObject() + { + var supplied = new List { "supplied" }; + + var provider = new MemoryConfigurationProvider(new MemoryConfigurationSource()); + var configurationSection = new ConfigurationSection( + new ConfigurationRoot(new List { provider }), + "Param"); + provider.Set(configurationSection.Key, "42"); + var variant = new Variant + { + ConfigurationObject = supplied, + Configuration = configurationSection + }; + + Assert.Same(supplied, variant.GetConfiguration>()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetConfigurationFallsBackToSection(bool hasIncompatibleObject) + { + var provider = new MemoryConfigurationProvider(new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["Value:AccountId"] = "1", + ["Value:UserId"] = "2", + ["Value:Groups:0"] = "Chrome", + ["Value:Groups:1"] = "Edge", + } + }); + var configurationSection = new ConfigurationSection( + new ConfigurationRoot(new List { provider }), + "Value"); + var variant = new Variant + { + ConfigurationObject = hasIncompatibleObject + ? "some value" + : null, + Configuration = configurationSection + }; + + Assert.Equivalent(new AppContext + { + AccountId = "1", + UserId = "2", + Groups = new List { "Chrome", "Edge" } + }, variant.GetConfiguration()); + } + + [Fact] + public void GetConfigurationBindsScalar() + { + var provider = new MemoryConfigurationProvider(new MemoryConfigurationSource()); + var configurationSection = new ConfigurationSection( + new ConfigurationRoot(new List { provider }), + "Param"); + provider.Set(configurationSection.Key, "42"); + var variant = new Variant + { + Configuration = configurationSection + }; + + Assert.Equal(42, variant.GetConfiguration()); + } + + [Fact] + public void GetConfigurationReturnsNullWithoutConfiguration() + { + Assert.Null(new Variant().GetConfiguration()); + Assert.Null(new Variant().GetConfiguration()); + } + } +}