Skip to content

Commit 11fc95b

Browse files
Merge pull request #573 from microsoft/main
Merge main to release/v4
2 parents 206a208 + 804dbd8 commit 11fc95b

File tree

11 files changed

+239
-19
lines changed

11 files changed

+239
-19
lines changed

src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,73 @@
44
using Microsoft.AspNetCore.Mvc.Filters;
55
using Microsoft.Extensions.DependencyInjection;
66
using System;
7+
using System.Collections.Generic;
8+
using System.Linq;
79
using System.Threading.Tasks;
810

911
namespace Microsoft.FeatureManagement
1012
{
1113
/// <summary>
12-
/// A place holder MVC filter that is used to dynamically activate a filter based on whether a feature is enabled.
14+
/// A place holder MVC filter that is used to dynamically activate a filter based on whether a feature (or set of features) is enabled.
1315
/// </summary>
1416
/// <typeparam name="T">The filter that will be used instead of this placeholder.</typeparam>
1517
class FeatureGatedAsyncActionFilter<T> : IAsyncActionFilter where T : IAsyncActionFilter
1618
{
17-
public FeatureGatedAsyncActionFilter(string featureName)
19+
/// <summary>
20+
/// Creates a feature gated filter for multiple features with a specified requirement type and ability to negate the evaluation.
21+
/// </summary>
22+
/// <param name="requirementType">Specifies whether all or any of the provided features should be enabled.</param>
23+
/// <param name="negate">Whether to negate the evaluation result.</param>
24+
/// <param name="features">The features that control whether the wrapped filter executes.</param>
25+
public FeatureGatedAsyncActionFilter(RequirementType requirementType, bool negate, params string[] features)
1826
{
19-
if (string.IsNullOrEmpty(featureName))
27+
if (features == null || features.Length == 0)
2028
{
21-
throw new ArgumentNullException(nameof(featureName));
29+
throw new ArgumentNullException(nameof(features));
2230
}
2331

24-
FeatureName = featureName;
32+
Features = features;
33+
RequirementType = requirementType;
34+
Negate = negate;
2535
}
2636

27-
public string FeatureName { get; }
37+
/// <summary>
38+
/// The set of features that gate the wrapped filter.
39+
/// </summary>
40+
public IEnumerable<string> Features { get; }
41+
42+
/// <summary>
43+
/// Controls whether any or all features in <see cref="Features"/> should be enabled to allow the wrapped filter to execute.
44+
/// </summary>
45+
public RequirementType RequirementType { get; }
46+
47+
/// <summary>
48+
/// Negates the evaluation for whether or not the wrapped filter should execute.
49+
/// </summary>
50+
public bool Negate { get; }
2851

2952
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
3053
{
31-
IFeatureManager featureManager = context.HttpContext.RequestServices.GetRequiredService<IFeatureManagerSnapshot>();
54+
IFeatureManagerSnapshot featureManager = context.HttpContext.RequestServices.GetRequiredService<IFeatureManagerSnapshot>();
55+
56+
bool enabled;
57+
58+
// Enabled state is determined by either 'any' or 'all' features being enabled.
59+
if (RequirementType == RequirementType.All)
60+
{
61+
enabled = await Features.All(async f => await featureManager.IsEnabledAsync(f).ConfigureAwait(false));
62+
}
63+
else
64+
{
65+
enabled = await Features.Any(async f => await featureManager.IsEnabledAsync(f).ConfigureAwait(false));
66+
}
67+
68+
if (Negate)
69+
{
70+
enabled = !enabled;
71+
}
3272

33-
if (await featureManager.IsEnabledAsync(FeatureName).ConfigureAwait(false))
73+
if (enabled)
3474
{
3575
IAsyncActionFilter filter = ActivatorUtilities.CreateInstance<T>(context.HttpContext.RequestServices);
3676

src/Microsoft.FeatureManagement.AspNetCore/FilterCollectionExtensions.cs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,63 @@ public static class FilterCollectionExtensions
1616
/// <typeparam name="TFilterType">The MVC filter to add and use if the feature is enabled.</typeparam>
1717
/// <param name="filters">The filter collection to add to.</param>
1818
/// <param name="feature">The feature that will need to enabled to trigger the execution of the MVC filter.</param>
19-
/// <returns></returns>
19+
/// <returns>The reference to the added filter metadata.</returns>
2020
public static IFilterMetadata AddForFeature<TFilterType>(this FilterCollection filters, string feature) where TFilterType : IAsyncActionFilter
2121
{
22-
IFilterMetadata filterMetadata = null;
22+
IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter<TFilterType>(RequirementType.Any, false, feature);
2323

24-
filters.Add(new FeatureGatedAsyncActionFilter<TFilterType>(feature));
24+
filters.Add(filterMetadata);
25+
26+
return filterMetadata;
27+
}
28+
29+
/// <summary>
30+
/// Adds an MVC filter that will only activate during a request if the specified feature is enabled.
31+
/// </summary>
32+
/// <typeparam name="TFilterType">The MVC filter to add and use if the feature is enabled.</typeparam>
33+
/// <param name="filters">The filter collection to add to.</param>
34+
/// <param name="features">The features that control whether the MVC filter executes.</param>
35+
/// <returns>The reference to the added filter metadata.</returns>
36+
public static IFilterMetadata AddForFeature<TFilterType>(this FilterCollection filters, params string[] features) where TFilterType : IAsyncActionFilter
37+
{
38+
IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter<TFilterType>(RequirementType.Any, false, features);
39+
40+
filters.Add(filterMetadata);
41+
42+
return filterMetadata;
43+
}
44+
45+
/// <summary>
46+
/// Adds an MVC filter that will only activate during a request if the specified features are enabled based on the provided requirement type.
47+
/// </summary>
48+
/// <typeparam name="TFilterType">The MVC filter to add and use if the features condition is satisfied.</typeparam>
49+
/// <param name="filters">The filter collection to add to.</param>
50+
/// <param name="requirementType">Specifies whether all or any of the provided features should be enabled.</param>
51+
/// <param name="features">The features that control whether the MVC filter executes.</param>
52+
/// <returns>The reference to the added filter metadata.</returns>
53+
public static IFilterMetadata AddForFeature<TFilterType>(this FilterCollection filters, RequirementType requirementType, params string[] features) where TFilterType : IAsyncActionFilter
54+
{
55+
IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter<TFilterType>(requirementType, false, features);
56+
57+
filters.Add(filterMetadata);
58+
59+
return filterMetadata;
60+
}
61+
62+
/// <summary>
63+
/// 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.
64+
/// </summary>
65+
/// <typeparam name="TFilterType">The MVC filter to add and use if the features condition is satisfied.</typeparam>
66+
/// <param name="filters">The filter collection to add to.</param>
67+
/// <param name="requirementType">Specifies whether all or any of the provided features should be enabled.</param>
68+
/// <param name="negate">Whether to negate the evaluation result for the provided features set.</param>
69+
/// <param name="features">The features that control whether the MVC filter executes.</param>
70+
/// <returns>The reference to the added filter metadata.</returns>
71+
public static IFilterMetadata AddForFeature<TFilterType>(this FilterCollection filters, RequirementType requirementType, bool negate, params string[] features) where TFilterType : IAsyncActionFilter
72+
{
73+
IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter<TFilterType>(requirementType, negate, features);
74+
75+
filters.Add(filterMetadata);
2576

2677
return filterMetadata;
2778
}

src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<!-- Official Version -->
66
<PropertyGroup>
77
<MajorVersion>4</MajorVersion>
8-
<MinorVersion>3</MinorVersion>
8+
<MinorVersion>4</MinorVersion>
99
<PatchVersion>0</PatchVersion>
1010
</PropertyGroup>
1111

src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<!-- Official Version -->
55
<PropertyGroup>
66
<MajorVersion>4</MajorVersion>
7-
<MinorVersion>3</MinorVersion>
7+
<MinorVersion>4</MinorVersion>
88
<PatchVersion>0</PatchVersion>
99
</PropertyGroup>
1010

src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ public TimeWindowFilter(ILoggerFactory loggerFactory = null)
3737
public IMemoryCache Cache { get; set; }
3838

3939
/// <summary>
40-
/// This property allows the time window filter in our test suite to use simulated time.
40+
/// This property allows the time window filter to use custom <see cref="TimeProvider"/>.
4141
/// </summary>
42-
internal TimeProvider SystemClock { get; set; }
42+
public TimeProvider SystemClock { get; set; }
4343

4444
/// <summary>
4545
/// Binds configuration representing filter parameters to <see cref="TimeWindowFilterSettings"/>.

src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public async ValueTask<Variant> GetVariantAsync(string feature, CancellationToke
103103

104104
//
105105
// First, check local cache
106-
if (_variantCache.ContainsKey(feature))
106+
if (_variantCache.ContainsKey(cacheKey))
107107
{
108108
return _variantCache[cacheKey];
109109
}
@@ -121,7 +121,7 @@ public async ValueTask<Variant> GetVariantAsync(string feature, ITargetingContex
121121

122122
//
123123
// First, check local cache
124-
if (_variantCache.ContainsKey(feature))
124+
if (_variantCache.ContainsKey(cacheKey))
125125
{
126126
return _variantCache[cacheKey];
127127
}

src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<!-- Official Version -->
66
<PropertyGroup>
77
<MajorVersion>4</MajorVersion>
8-
<MinorVersion>3</MinorVersion>
8+
<MinorVersion>4</MinorVersion>
99
<PatchVersion>0</PatchVersion>
1010
</PropertyGroup>
1111

src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ private static IFeatureManagementBuilder GetFeatureManagementBuilder(IServiceCol
175175
builder.AddFeatureFilter<TimeWindowFilter>(sp =>
176176
new TimeWindowFilter()
177177
{
178-
Cache = sp.GetRequiredService<IMemoryCache>()
178+
Cache = sp.GetRequiredService<IMemoryCache>(),
179+
SystemClock = sp.GetService<TimeProvider>() ?? TimeProvider.System,
179180
});
180181

181182
builder.AddFeatureFilter<ContextualTargetingFilter>();

tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,47 @@ public async Task GatesRazorPageFeatures()
202202
Assert.Equal(HttpStatusCode.OK, gateAnyNegateResponse.StatusCode);
203203
}
204204

205+
[Fact]
206+
public async Task GatesActionFilterFeatures()
207+
{
208+
IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();
209+
210+
TestServer server = new TestServer(WebHost.CreateDefaultBuilder().ConfigureServices(services =>
211+
{
212+
services
213+
.AddSingleton(config)
214+
.AddFeatureManagement()
215+
.AddFeatureFilter<TestFilter>();
216+
217+
services.AddMvcCore(o =>
218+
{
219+
DisableEndpointRouting(o);
220+
o.Filters.AddForFeature<MvcFilter>(RequirementType.All, Features.ConditionalFeature, Features.ConditionalFeature2);
221+
});
222+
}).Configure(app => app.UseMvc()));
223+
224+
TestFilter filter = (TestFilter)server.Host.Services.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>().First(f => f is TestFilter);
225+
HttpClient client = server.CreateClient();
226+
227+
//
228+
// Enable all features
229+
filter.Callback = _ => Task.FromResult(true);
230+
HttpResponseMessage res = await client.GetAsync("");
231+
Assert.True(res.Headers.Contains(nameof(MvcFilter)));
232+
233+
//
234+
// Enable 1/2 features
235+
filter.Callback = ctx => Task.FromResult(ctx.FeatureName == Features.ConditionalFeature);
236+
res = await client.GetAsync("");
237+
Assert.False(res.Headers.Contains(nameof(MvcFilter)));
238+
239+
//
240+
// Enable no
241+
filter.Callback = _ => Task.FromResult(false);
242+
res = await client.GetAsync("");
243+
Assert.False(res.Headers.Contains(nameof(MvcFilter)));
244+
}
245+
205246
private static void DisableEndpointRouting(MvcOptions options)
206247
{
207248
options.EnableEndpointRouting = false;

tests/Tests.FeatureManagement/FeatureManagementTest.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,67 @@ public async Task ThreadSafeSnapshot()
332332
}
333333
}
334334

335+
[Fact]
336+
public async Task ReturnsCachedResultFromSnapshot()
337+
{
338+
IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();
339+
340+
var services = new ServiceCollection();
341+
342+
services
343+
.AddSingleton(config)
344+
.AddFeatureManagement()
345+
.AddFeatureFilter<TestFilter>();
346+
347+
ServiceProvider serviceProvider = services.BuildServiceProvider();
348+
349+
IVariantFeatureManager featureManager = serviceProvider.GetRequiredService<IVariantFeatureManager>();
350+
351+
IVariantFeatureManager featureManagerSnapshot = serviceProvider.GetRequiredService<IVariantFeatureManagerSnapshot>();
352+
353+
IEnumerable<IFeatureFilterMetadata> featureFilters = serviceProvider.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>();
354+
355+
TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter);
356+
357+
int callCount = 0;
358+
bool filterEnabled = true;
359+
360+
testFeatureFilter.Callback = (evaluationContext) =>
361+
{
362+
callCount++;
363+
return Task.FromResult(filterEnabled);
364+
};
365+
366+
// First evaluation - filter is enabled and should return true
367+
bool result1 = await featureManagerSnapshot.IsEnabledAsync(Features.ConditionalFeature);
368+
Assert.Equal(1, callCount);
369+
Assert.True(result1);
370+
371+
Variant variant1 = await featureManagerSnapshot.GetVariantAsync(Features.ConditionalFeature);
372+
Assert.Equal(2, callCount);
373+
Assert.Equal("DefaultWhenEnabled", variant1.Name);
374+
375+
// "Shut down" the feature filter
376+
filterEnabled = false;
377+
378+
// Second evaluation - should use cached value despite filter being shut down
379+
bool result2 = await featureManagerSnapshot.IsEnabledAsync(Features.ConditionalFeature);
380+
Assert.Equal(2, callCount);
381+
Assert.True(result2);
382+
383+
Variant variant2 = await featureManagerSnapshot.GetVariantAsync(Features.ConditionalFeature);
384+
Assert.Equal(2, callCount);
385+
Assert.Equal("DefaultWhenEnabled", variant2.Name);
386+
387+
bool result3 = await featureManager.IsEnabledAsync(Features.ConditionalFeature);
388+
Assert.Equal(3, callCount);
389+
Assert.False(result3);
390+
391+
Variant variant3 = await featureManager.GetVariantAsync(Features.ConditionalFeature);
392+
Assert.Equal(4, callCount);
393+
Assert.Equal("DefaultWhenDisabled", variant3.Name);
394+
}
395+
335396
[Fact]
336397
public void AddsScopedFeatureManagement()
337398
{
@@ -523,6 +584,20 @@ public async Task MergesFeatureFlagsFromDifferentConfigurationSources()
523584
Assert.True(await featureManager8.IsEnabledAsync("FeatureC"));
524585
Assert.False(await featureManager8.IsEnabledAsync("Feature1"));
525586
Assert.False(await featureManager8.IsEnabledAsync("Feature2"));
587+
588+
var configurationManager = new ConfigurationManager();
589+
configurationManager
590+
.AddJsonFile("appsettings1.json")
591+
.AddJsonFile("appsettings2.json");
592+
593+
var services = new ServiceCollection();
594+
services.AddFeatureManagement();
595+
596+
var featureManager9 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configurationManager, mergeOptions));
597+
Assert.True(await featureManager9.IsEnabledAsync("FeatureA"));
598+
Assert.True(await featureManager9.IsEnabledAsync("FeatureB"));
599+
Assert.True(await featureManager9.IsEnabledAsync("Feature1"));
600+
Assert.False(await featureManager9.IsEnabledAsync("Feature2")); // appsettings2 should override appsettings1
526601
}
527602
}
528603

0 commit comments

Comments
 (0)