diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs
index 073f953f..80f540e8 100644
--- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs
+++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs
@@ -4,33 +4,73 @@
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.FeatureManagement
{
///
- /// A place holder MVC filter that is used to dynamically activate a filter based on whether a feature is enabled.
+ /// A place holder MVC filter that is used to dynamically activate a filter based on whether a feature (or set of features) is enabled.
///
/// The filter that will be used instead of this placeholder.
class FeatureGatedAsyncActionFilter : IAsyncActionFilter where T : IAsyncActionFilter
{
- public FeatureGatedAsyncActionFilter(string featureName)
+ ///
+ /// Creates a feature gated filter for multiple features with a specified requirement type and ability to negate the evaluation.
+ ///
+ /// Specifies whether all or any of the provided features should be enabled.
+ /// Whether to negate the evaluation result.
+ /// The features that control whether the wrapped filter executes.
+ public FeatureGatedAsyncActionFilter(RequirementType requirementType, bool negate, params string[] features)
{
- if (string.IsNullOrEmpty(featureName))
+ if (features == null || features.Length == 0)
{
- throw new ArgumentNullException(nameof(featureName));
+ throw new ArgumentNullException(nameof(features));
}
- FeatureName = featureName;
+ Features = features;
+ RequirementType = requirementType;
+ Negate = negate;
}
- public string FeatureName { get; }
+ ///
+ /// The set of features that gate the wrapped filter.
+ ///
+ public IEnumerable Features { get; }
+
+ ///
+ /// Controls whether any or all features in should be enabled to allow the wrapped filter to execute.
+ ///
+ public RequirementType RequirementType { get; }
+
+ ///
+ /// Negates the evaluation for whether or not the wrapped filter should execute.
+ ///
+ public bool Negate { get; }
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
- IFeatureManager featureManager = context.HttpContext.RequestServices.GetRequiredService();
+ IFeatureManagerSnapshot featureManager = context.HttpContext.RequestServices.GetRequiredService();
+
+ bool enabled;
+
+ // Enabled state is determined by either 'any' or 'all' features being enabled.
+ if (RequirementType == RequirementType.All)
+ {
+ enabled = await Features.All(async f => await featureManager.IsEnabledAsync(f).ConfigureAwait(false));
+ }
+ else
+ {
+ enabled = await Features.Any(async f => await featureManager.IsEnabledAsync(f).ConfigureAwait(false));
+ }
+
+ if (Negate)
+ {
+ enabled = !enabled;
+ }
- if (await featureManager.IsEnabledAsync(FeatureName).ConfigureAwait(false))
+ if (enabled)
{
IAsyncActionFilter filter = ActivatorUtilities.CreateInstance(context.HttpContext.RequestServices);
diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FilterCollectionExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FilterCollectionExtensions.cs
index cfd46554..86bece85 100644
--- a/src/Microsoft.FeatureManagement.AspNetCore/FilterCollectionExtensions.cs
+++ b/src/Microsoft.FeatureManagement.AspNetCore/FilterCollectionExtensions.cs
@@ -16,12 +16,63 @@ public static class FilterCollectionExtensions
/// The MVC filter to add and use if the feature is enabled.
/// The filter collection to add to.
/// The feature that will need to enabled to trigger the execution of the MVC filter.
- ///
+ /// The reference to the added filter metadata.
public static IFilterMetadata AddForFeature(this FilterCollection filters, string feature) where TFilterType : IAsyncActionFilter
{
- IFilterMetadata filterMetadata = null;
+ IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter(RequirementType.Any, false, feature);
- filters.Add(new FeatureGatedAsyncActionFilter(feature));
+ filters.Add(filterMetadata);
+
+ return filterMetadata;
+ }
+
+ ///
+ /// Adds an MVC filter that will only activate during a request if the specified feature is enabled.
+ ///
+ /// The MVC filter to add and use if the feature is enabled.
+ /// The filter collection to add to.
+ /// The features that control whether the MVC filter executes.
+ /// The reference to the added filter metadata.
+ public static IFilterMetadata AddForFeature(this FilterCollection filters, params string[] features) where TFilterType : IAsyncActionFilter
+ {
+ IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter(RequirementType.Any, false, features);
+
+ filters.Add(filterMetadata);
+
+ return filterMetadata;
+ }
+
+ ///
+ /// Adds an MVC filter that will only activate during a request if the specified features are enabled based on the provided requirement type.
+ ///
+ /// The MVC filter to add and use if the features condition is satisfied.
+ /// The filter collection to add to.
+ /// Specifies whether all or any of the provided features should be enabled.
+ /// The features that control whether the MVC filter executes.
+ /// The reference to the added filter metadata.
+ public static IFilterMetadata AddForFeature(this FilterCollection filters, RequirementType requirementType, params string[] features) where TFilterType : IAsyncActionFilter
+ {
+ IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter(requirementType, false, features);
+
+ filters.Add(filterMetadata);
+
+ return filterMetadata;
+ }
+
+ ///
+ /// Adds an MVC filter that will only activate during a request if the specified features are enabled based on the provided requirement type and negation flag.
+ ///
+ /// The MVC filter to add and use if the features condition is satisfied.
+ /// The filter collection to add to.
+ /// Specifies whether all or any of the provided features should be enabled.
+ /// Whether to negate the evaluation result for the provided features set.
+ /// The features that control whether the MVC filter executes.
+ /// The reference to the added filter metadata.
+ public static IFilterMetadata AddForFeature(this FilterCollection filters, RequirementType requirementType, bool negate, params string[] features) where TFilterType : IAsyncActionFilter
+ {
+ IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter(requirementType, negate, features);
+
+ filters.Add(filterMetadata);
return filterMetadata;
}
diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj
index 12aa4e1f..42b212cb 100644
--- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj
+++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj
@@ -5,7 +5,7 @@
4
- 3
+ 4
0
diff --git a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj
index d195cfa8..97dbb7ff 100644
--- a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj
+++ b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj
@@ -4,7 +4,7 @@
4
- 3
+ 4
0
diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs
index 62b5f4a5..389d129f 100644
--- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs
+++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs
@@ -37,9 +37,9 @@ public TimeWindowFilter(ILoggerFactory loggerFactory = null)
public IMemoryCache Cache { get; set; }
///
- /// This property allows the time window filter in our test suite to use simulated time.
+ /// This property allows the time window filter to use custom .
///
- internal TimeProvider SystemClock { get; set; }
+ public TimeProvider SystemClock { get; set; }
///
/// Binds configuration representing filter parameters to .
diff --git a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs
index 12004aea..3c043152 100644
--- a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs
+++ b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs
@@ -103,7 +103,7 @@ public async ValueTask GetVariantAsync(string feature, CancellationToke
//
// First, check local cache
- if (_variantCache.ContainsKey(feature))
+ if (_variantCache.ContainsKey(cacheKey))
{
return _variantCache[cacheKey];
}
@@ -121,7 +121,7 @@ public async ValueTask GetVariantAsync(string feature, ITargetingContex
//
// First, check local cache
- if (_variantCache.ContainsKey(feature))
+ if (_variantCache.ContainsKey(cacheKey))
{
return _variantCache[cacheKey];
}
diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj
index 560a6c07..e815b031 100644
--- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj
+++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj
@@ -5,7 +5,7 @@
4
- 3
+ 4
0
diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs
index 806c66fa..db43e8c6 100644
--- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs
+++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs
@@ -175,7 +175,8 @@ private static IFeatureManagementBuilder GetFeatureManagementBuilder(IServiceCol
builder.AddFeatureFilter(sp =>
new TimeWindowFilter()
{
- Cache = sp.GetRequiredService()
+ Cache = sp.GetRequiredService(),
+ SystemClock = sp.GetService() ?? TimeProvider.System,
});
builder.AddFeatureFilter();
diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs
index 68b7efc1..8bfd5ec4 100644
--- a/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs
+++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs
@@ -202,6 +202,47 @@ public async Task GatesRazorPageFeatures()
Assert.Equal(HttpStatusCode.OK, gateAnyNegateResponse.StatusCode);
}
+ [Fact]
+ public async Task GatesActionFilterFeatures()
+ {
+ IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();
+
+ TestServer server = new TestServer(WebHost.CreateDefaultBuilder().ConfigureServices(services =>
+ {
+ services
+ .AddSingleton(config)
+ .AddFeatureManagement()
+ .AddFeatureFilter();
+
+ services.AddMvcCore(o =>
+ {
+ DisableEndpointRouting(o);
+ o.Filters.AddForFeature(RequirementType.All, Features.ConditionalFeature, Features.ConditionalFeature2);
+ });
+ }).Configure(app => app.UseMvc()));
+
+ TestFilter filter = (TestFilter)server.Host.Services.GetRequiredService>().First(f => f is TestFilter);
+ HttpClient client = server.CreateClient();
+
+ //
+ // Enable all features
+ filter.Callback = _ => Task.FromResult(true);
+ HttpResponseMessage res = await client.GetAsync("");
+ Assert.True(res.Headers.Contains(nameof(MvcFilter)));
+
+ //
+ // Enable 1/2 features
+ filter.Callback = ctx => Task.FromResult(ctx.FeatureName == Features.ConditionalFeature);
+ res = await client.GetAsync("");
+ Assert.False(res.Headers.Contains(nameof(MvcFilter)));
+
+ //
+ // Enable no
+ filter.Callback = _ => Task.FromResult(false);
+ res = await client.GetAsync("");
+ Assert.False(res.Headers.Contains(nameof(MvcFilter)));
+ }
+
private static void DisableEndpointRouting(MvcOptions options)
{
options.EnableEndpointRouting = false;
diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs
index 1b8ae34b..10636b84 100644
--- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs
+++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs
@@ -332,6 +332,67 @@ public async Task ThreadSafeSnapshot()
}
}
+ [Fact]
+ public async Task ReturnsCachedResultFromSnapshot()
+ {
+ IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();
+
+ var services = new ServiceCollection();
+
+ services
+ .AddSingleton(config)
+ .AddFeatureManagement()
+ .AddFeatureFilter();
+
+ ServiceProvider serviceProvider = services.BuildServiceProvider();
+
+ IVariantFeatureManager featureManager = serviceProvider.GetRequiredService();
+
+ IVariantFeatureManager featureManagerSnapshot = serviceProvider.GetRequiredService();
+
+ IEnumerable featureFilters = serviceProvider.GetRequiredService>();
+
+ TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter);
+
+ int callCount = 0;
+ bool filterEnabled = true;
+
+ testFeatureFilter.Callback = (evaluationContext) =>
+ {
+ callCount++;
+ return Task.FromResult(filterEnabled);
+ };
+
+ // First evaluation - filter is enabled and should return true
+ bool result1 = await featureManagerSnapshot.IsEnabledAsync(Features.ConditionalFeature);
+ Assert.Equal(1, callCount);
+ Assert.True(result1);
+
+ Variant variant1 = await featureManagerSnapshot.GetVariantAsync(Features.ConditionalFeature);
+ Assert.Equal(2, callCount);
+ Assert.Equal("DefaultWhenEnabled", variant1.Name);
+
+ // "Shut down" the feature filter
+ filterEnabled = false;
+
+ // Second evaluation - should use cached value despite filter being shut down
+ bool result2 = await featureManagerSnapshot.IsEnabledAsync(Features.ConditionalFeature);
+ Assert.Equal(2, callCount);
+ Assert.True(result2);
+
+ Variant variant2 = await featureManagerSnapshot.GetVariantAsync(Features.ConditionalFeature);
+ Assert.Equal(2, callCount);
+ Assert.Equal("DefaultWhenEnabled", variant2.Name);
+
+ bool result3 = await featureManager.IsEnabledAsync(Features.ConditionalFeature);
+ Assert.Equal(3, callCount);
+ Assert.False(result3);
+
+ Variant variant3 = await featureManager.GetVariantAsync(Features.ConditionalFeature);
+ Assert.Equal(4, callCount);
+ Assert.Equal("DefaultWhenDisabled", variant3.Name);
+ }
+
[Fact]
public void AddsScopedFeatureManagement()
{
@@ -523,6 +584,20 @@ public async Task MergesFeatureFlagsFromDifferentConfigurationSources()
Assert.True(await featureManager8.IsEnabledAsync("FeatureC"));
Assert.False(await featureManager8.IsEnabledAsync("Feature1"));
Assert.False(await featureManager8.IsEnabledAsync("Feature2"));
+
+ var configurationManager = new ConfigurationManager();
+ configurationManager
+ .AddJsonFile("appsettings1.json")
+ .AddJsonFile("appsettings2.json");
+
+ var services = new ServiceCollection();
+ services.AddFeatureManagement();
+
+ var featureManager9 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configurationManager, mergeOptions));
+ Assert.True(await featureManager9.IsEnabledAsync("FeatureA"));
+ Assert.True(await featureManager9.IsEnabledAsync("FeatureB"));
+ Assert.True(await featureManager9.IsEnabledAsync("Feature1"));
+ Assert.False(await featureManager9.IsEnabledAsync("Feature2")); // appsettings2 should override appsettings1
}
}
diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json
index e192a5ff..018ef5c4 100644
--- a/tests/Tests.FeatureManagement/appsettings.json
+++ b/tests/Tests.FeatureManagement/appsettings.json
@@ -28,6 +28,18 @@
}
}
]
+ },
+ "variants": [
+ {
+ "name": "DefaultWhenEnabled"
+ },
+ {
+ "name": "DefaultWhenDisabled"
+ }
+ ],
+ "allocation": {
+ "default_when_enabled": "DefaultWhenEnabled",
+ "default_when_disabled": "DefaultWhenDisabled"
}
},
{