From 0c3d53f2b9143c1e2c41a0eb31dc2b7acde5d6c5 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 2 Apr 2026 14:01:53 +0800 Subject: [PATCH] parameter object support --- .../FeatureFilterConfiguration.cs | 8 + .../FeatureFilterEvaluationContext.cs | 6 + .../FeatureFilters/PercentageFilter.cs | 6 +- .../FeatureFilters/TimeWindowFilter.cs | 6 +- .../FeatureManager.cs | 1 + .../Targeting/ContextualTargetingFilter.cs | 6 +- .../FeatureManagementTest.cs | 254 ++++++++++++++++++ 7 files changed, 281 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureFilterConfiguration.cs b/src/Microsoft.FeatureManagement/FeatureFilterConfiguration.cs index a9a5f670..c11a0220 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilterConfiguration.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilterConfiguration.cs @@ -20,5 +20,13 @@ public class FeatureFilterConfiguration /// Configurable parameters that can change across instances of a feature filter. /// public IConfiguration Parameters { get; set; } = new ConfigurationRoot(new List()); + + /// + /// A strongly-typed parameter object that can be used as an alternative to . + /// Custom implementations can populate this property directly + /// instead of constructing an instance. + /// When set, feature filters should prefer this over . + /// + public object ParameterObject { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs index 91589852..08b7d8f8 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs @@ -21,6 +21,12 @@ public class FeatureFilterEvaluationContext /// public IConfiguration Parameters { get; set; } + /// + /// A strongly-typed parameter object, if any, provided by a custom . + /// When set, feature filters should prefer this over . + /// + public object ParameterObject { get; set; } + /// /// A settings object, if any, that has been pre-bound from . /// The settings are made available for s that implement . diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs index c986e6d9..1b8a7c9b 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs @@ -50,8 +50,10 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) } // - // Check if prebound settings available, otherwise bind from parameters. - PercentageFilterSettings settings = (PercentageFilterSettings)context.Settings ?? (PercentageFilterSettings)BindParameters(context.Parameters); + // Check if ParameterObject available (takes precedence), then prebound settings, otherwise bind from parameters. + PercentageFilterSettings settings = context.ParameterObject != null + ? (PercentageFilterSettings)context.ParameterObject + : (PercentageFilterSettings)context.Settings ?? (PercentageFilterSettings)BindParameters(context.Parameters); bool result = true; diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 389d129f..e5635c17 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -71,8 +71,10 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) } // - // Check if prebound settings available, otherwise bind from parameters. - TimeWindowFilterSettings settings = (TimeWindowFilterSettings)context.Settings ?? (TimeWindowFilterSettings)BindParameters(context.Parameters); + // Check if ParameterObject available (takes precedence), then prebound settings, otherwise bind from parameters. + TimeWindowFilterSettings settings = context.ParameterObject != null + ? (TimeWindowFilterSettings)context.ParameterObject + : (TimeWindowFilterSettings)context.Settings ?? (TimeWindowFilterSettings)BindParameters(context.Parameters); DateTimeOffset now = SystemClock?.GetUtcNow() ?? DateTimeOffset.UtcNow; diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 3cb35c7a..e366c31f 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -498,6 +498,7 @@ private async ValueTask IsEnabledAsync(FeatureDefinition feature { FeatureName = featureDefinition.Name, Parameters = featureFilterConfiguration.Parameters, + ParameterObject = featureFilterConfiguration.ParameterObject, CancellationToken = cancellationToken }; diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index e0d7fc7b..5790dd45 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -68,8 +68,10 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargeti } // - // Check if prebound settings available, otherwise bind from parameters. - TargetingFilterSettings settings = (TargetingFilterSettings)context.Settings ?? (TargetingFilterSettings)BindParameters(context.Parameters); + // Check if ParameterObject available (takes precedence), then prebound settings, otherwise bind from parameters. + TargetingFilterSettings settings = context.ParameterObject != null + ? (TargetingFilterSettings)context.ParameterObject + : (TargetingFilterSettings)context.Settings ?? (TargetingFilterSettings)BindParameters(context.Parameters); return Task.FromResult(TargetingEvaluator.IsTargeted(targetingContext, settings, _options.IgnoreCase, context.FeatureName)); } diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 12178672..d1736f0f 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -1051,6 +1051,124 @@ public async Task BindsFeatureFlagSettings() Assert.True(called); } + + [Fact] + public async Task UsesParameterObject() + { + var parameterObject = new object(); + + FeatureFilterConfiguration testFilterConfiguration = new FeatureFilterConfiguration + { + Name = "Test", + ParameterObject = parameterObject + }; + + var services = new ServiceCollection(); + + var definitionProvider = new InMemoryFeatureDefinitionProvider( + new FeatureDefinition[] + { + new FeatureDefinition + { + Name = Features.ConditionalFeature, + EnabledFor = new List() + { + testFilterConfiguration + } + } + }); + + services.AddSingleton(definitionProvider) + .AddSingleton(new ConfigurationBuilder().Build()) + .AddFeatureManagement() + .AddFeatureFilter(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + IEnumerable featureFilters = serviceProvider.GetRequiredService>(); + + TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter); + + testFeatureFilter.Callback = (evaluationContext) => + { + // + // When ParameterObject is set, it should be available on the context + // so custom filters can use it with their own precedence logic. + Assert.Same(parameterObject, evaluationContext.ParameterObject); + + return Task.FromResult(true); + }; + + bool result = await featureManager.IsEnabledAsync(Features.ConditionalFeature); + + Assert.True(result); + } + + [Fact] + public async Task ParameterObjectFallsBackToParametersWhenNull() + { + FeatureFilterConfiguration testFilterConfiguration = new FeatureFilterConfiguration + { + Name = "Test", + ParameterObject = null + }; + + var services = new ServiceCollection(); + + var definitionProvider = new InMemoryFeatureDefinitionProvider( + new FeatureDefinition[] + { + new FeatureDefinition + { + Name = Features.ConditionalFeature, + EnabledFor = new List() + { + testFilterConfiguration + } + } + }); + + services.AddSingleton(definitionProvider) + .AddSingleton(new ConfigurationBuilder().Build()) + .AddFeatureManagement() + .AddFeatureFilter(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + IEnumerable featureFilters = serviceProvider.GetRequiredService>(); + + TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter); + + bool binderCalled = false; + + testFeatureFilter.ParametersBinderCallback = (parameters) => + { + binderCalled = true; + + return parameters; + }; + + testFeatureFilter.Callback = (evaluationContext) => + { + // + // When ParameterObject is null, Settings should be populated + // by IFilterParametersBinder as usual. + Assert.Null(evaluationContext.ParameterObject); + Assert.NotNull(evaluationContext.Settings); + + return Task.FromResult(true); + }; + + bool result = await featureManager.IsEnabledAsync(Features.ConditionalFeature); + + Assert.True(result); + + Assert.True(binderCalled); + } } public class FeatureManagementBuiltInFeatureFilterTest @@ -1671,6 +1789,142 @@ public async Task CustomFilterContextualTargetingWithNullSetting() Assert.True(await featureManager.IsEnabledAsync("CustomFilterFeature")); } + + [Fact] + public async Task PercentageFilterUsesParameterObject() + { + var services = new ServiceCollection(); + + var definitionProvider = new InMemoryFeatureDefinitionProvider( + new FeatureDefinition[] + { + new FeatureDefinition + { + Name = "PercentageFeature", + EnabledFor = new List() + { + new FeatureFilterConfiguration + { + Name = "Microsoft.Percentage", + ParameterObject = new PercentageFilterSettings { Value = 100 } + } + } + } + }); + + services.AddSingleton(definitionProvider) + .AddSingleton(new ConfigurationBuilder().Build()) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + Assert.True(await featureManager.IsEnabledAsync("PercentageFeature")); + } + + [Fact] + public async Task TimeWindowFilterUsesParameterObject() + { + var services = new ServiceCollection(); + + var definitionProvider = new InMemoryFeatureDefinitionProvider( + new FeatureDefinition[] + { + new FeatureDefinition + { + Name = "TimeWindowFeature", + EnabledFor = new List() + { + new FeatureFilterConfiguration + { + Name = "Microsoft.TimeWindow", + ParameterObject = new TimeWindowFilterSettings + { + Start = DateTimeOffset.UtcNow.AddDays(-1), + End = DateTimeOffset.UtcNow.AddDays(1) + } + } + } + } + }); + + services.AddSingleton(definitionProvider) + .AddSingleton(new ConfigurationBuilder().Build()) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + Assert.True(await featureManager.IsEnabledAsync("TimeWindowFeature")); + } + + [Fact] + public async Task PercentageFilterThrowsOnInvalidParameterObjectType() + { + var services = new ServiceCollection(); + + var definitionProvider = new InMemoryFeatureDefinitionProvider( + new FeatureDefinition[] + { + new FeatureDefinition + { + Name = "BadFeature", + EnabledFor = new List() + { + new FeatureFilterConfiguration + { + Name = "Microsoft.Percentage", + ParameterObject = "wrong type" + } + } + } + }); + + services.AddSingleton(definitionProvider) + .AddSingleton(new ConfigurationBuilder().Build()) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + await Assert.ThrowsAsync(() => featureManager.IsEnabledAsync("BadFeature")); + } + + [Fact] + public async Task TimeWindowFilterThrowsOnInvalidParameterObjectType() + { + var services = new ServiceCollection(); + + var definitionProvider = new InMemoryFeatureDefinitionProvider( + new FeatureDefinition[] + { + new FeatureDefinition + { + Name = "BadFeature", + EnabledFor = new List() + { + new FeatureFilterConfiguration + { + Name = "Microsoft.TimeWindow", + ParameterObject = 42 + } + } + } + }); + + services.AddSingleton(definitionProvider) + .AddSingleton(new ConfigurationBuilder().Build()) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + await Assert.ThrowsAsync(() => featureManager.IsEnabledAsync("BadFeature")); + } } public class FeatureManagementVariantTest