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 @@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <typeparam name="T">The filter that will be used instead of this placeholder.</typeparam>
class FeatureGatedAsyncActionFilter<T> : IAsyncActionFilter where T : IAsyncActionFilter
{
public FeatureGatedAsyncActionFilter(string featureName)
/// <summary>
/// Creates a feature gated filter for multiple features with a specified requirement type and ability to negate the evaluation.
/// </summary>
/// <param name="requirementType">Specifies whether all or any of the provided features should be enabled.</param>
/// <param name="negate">Whether to negate the evaluation result.</param>
/// <param name="features">The features that control whether the wrapped filter executes.</param>
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; }
/// <summary>
/// The set of features that gate the wrapped filter.
/// </summary>
public IEnumerable<string> Features { get; }

/// <summary>
/// Controls whether any or all features in <see cref="Features"/> should be enabled to allow the wrapped filter to execute.
/// </summary>
public RequirementType RequirementType { get; }

/// <summary>
/// Negates the evaluation for whether or not the wrapped filter should execute.
/// </summary>
public bool Negate { get; }

public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
IFeatureManager featureManager = context.HttpContext.RequestServices.GetRequiredService<IFeatureManagerSnapshot>();
IFeatureManagerSnapshot featureManager = context.HttpContext.RequestServices.GetRequiredService<IFeatureManagerSnapshot>();

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<T>(context.HttpContext.RequestServices);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,63 @@ public static class FilterCollectionExtensions
/// <typeparam name="TFilterType">The MVC filter to add and use if the feature is enabled.</typeparam>
/// <param name="filters">The filter collection to add to.</param>
/// <param name="feature">The feature that will need to enabled to trigger the execution of the MVC filter.</param>
/// <returns></returns>
/// <returns>The reference to the added filter metadata.</returns>
public static IFilterMetadata AddForFeature<TFilterType>(this FilterCollection filters, string feature) where TFilterType : IAsyncActionFilter
{
IFilterMetadata filterMetadata = null;
IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter<TFilterType>(RequirementType.Any, false, feature);

filters.Add(new FeatureGatedAsyncActionFilter<TFilterType>(feature));
filters.Add(filterMetadata);

return filterMetadata;
}

/// <summary>
/// Adds an MVC filter that will only activate during a request if the specified feature is enabled.
/// </summary>
/// <typeparam name="TFilterType">The MVC filter to add and use if the feature is enabled.</typeparam>
/// <param name="filters">The filter collection to add to.</param>
/// <param name="features">The features that control whether the MVC filter executes.</param>
/// <returns>The reference to the added filter metadata.</returns>
public static IFilterMetadata AddForFeature<TFilterType>(this FilterCollection filters, params string[] features) where TFilterType : IAsyncActionFilter
{
IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter<TFilterType>(RequirementType.Any, false, features);

filters.Add(filterMetadata);

return filterMetadata;
}

/// <summary>
/// Adds an MVC filter that will only activate during a request if the specified features are enabled based on the provided requirement type.
/// </summary>
/// <typeparam name="TFilterType">The MVC filter to add and use if the features condition is satisfied.</typeparam>
/// <param name="filters">The filter collection to add to.</param>
/// <param name="requirementType">Specifies whether all or any of the provided features should be enabled.</param>
/// <param name="features">The features that control whether the MVC filter executes.</param>
/// <returns>The reference to the added filter metadata.</returns>
public static IFilterMetadata AddForFeature<TFilterType>(this FilterCollection filters, RequirementType requirementType, params string[] features) where TFilterType : IAsyncActionFilter
{
IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter<TFilterType>(requirementType, false, features);

filters.Add(filterMetadata);

return filterMetadata;
}

/// <summary>
/// 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.
/// </summary>
/// <typeparam name="TFilterType">The MVC filter to add and use if the features condition is satisfied.</typeparam>
/// <param name="filters">The filter collection to add to.</param>
/// <param name="requirementType">Specifies whether all or any of the provided features should be enabled.</param>
/// <param name="negate">Whether to negate the evaluation result for the provided features set.</param>
/// <param name="features">The features that control whether the MVC filter executes.</param>
/// <returns>The reference to the added filter metadata.</returns>
public static IFilterMetadata AddForFeature<TFilterType>(this FilterCollection filters, RequirementType requirementType, bool negate, params string[] features) where TFilterType : IAsyncActionFilter
{
IFilterMetadata filterMetadata = new FeatureGatedAsyncActionFilter<TFilterType>(requirementType, negate, features);

filters.Add(filterMetadata);

return filterMetadata;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<!-- Official Version -->
<PropertyGroup>
<MajorVersion>4</MajorVersion>
<MinorVersion>3</MinorVersion>
<MinorVersion>4</MinorVersion>
<PatchVersion>0</PatchVersion>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<!-- Official Version -->
<PropertyGroup>
<MajorVersion>4</MajorVersion>
<MinorVersion>3</MinorVersion>
<MinorVersion>4</MinorVersion>
<PatchVersion>0</PatchVersion>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ public TimeWindowFilter(ILoggerFactory loggerFactory = null)
public IMemoryCache Cache { get; set; }

/// <summary>
/// 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 <see cref="TimeProvider"/>.
/// </summary>
internal TimeProvider SystemClock { get; set; }
public TimeProvider SystemClock { get; set; }

/// <summary>
/// Binds configuration representing filter parameters to <see cref="TimeWindowFilterSettings"/>.
Expand Down
4 changes: 2 additions & 2 deletions src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public async ValueTask<Variant> GetVariantAsync(string feature, CancellationToke

//
// First, check local cache
if (_variantCache.ContainsKey(feature))
if (_variantCache.ContainsKey(cacheKey))
{
return _variantCache[cacheKey];
}
Expand All @@ -121,7 +121,7 @@ public async ValueTask<Variant> GetVariantAsync(string feature, ITargetingContex

//
// First, check local cache
if (_variantCache.ContainsKey(feature))
if (_variantCache.ContainsKey(cacheKey))
{
return _variantCache[cacheKey];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<!-- Official Version -->
<PropertyGroup>
<MajorVersion>4</MajorVersion>
<MinorVersion>3</MinorVersion>
<MinorVersion>4</MinorVersion>
<PatchVersion>0</PatchVersion>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ private static IFeatureManagementBuilder GetFeatureManagementBuilder(IServiceCol
builder.AddFeatureFilter<TimeWindowFilter>(sp =>
new TimeWindowFilter()
{
Cache = sp.GetRequiredService<IMemoryCache>()
Cache = sp.GetRequiredService<IMemoryCache>(),
SystemClock = sp.GetService<TimeProvider>() ?? TimeProvider.System,
});

builder.AddFeatureFilter<ContextualTargetingFilter>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestFilter>();

services.AddMvcCore(o =>
{
DisableEndpointRouting(o);
o.Filters.AddForFeature<MvcFilter>(RequirementType.All, Features.ConditionalFeature, Features.ConditionalFeature2);
});
}).Configure(app => app.UseMvc()));

TestFilter filter = (TestFilter)server.Host.Services.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>().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;
Expand Down
75 changes: 75 additions & 0 deletions tests/Tests.FeatureManagement/FeatureManagementTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestFilter>();

ServiceProvider serviceProvider = services.BuildServiceProvider();

IVariantFeatureManager featureManager = serviceProvider.GetRequiredService<IVariantFeatureManager>();

IVariantFeatureManager featureManagerSnapshot = serviceProvider.GetRequiredService<IVariantFeatureManagerSnapshot>();

IEnumerable<IFeatureFilterMetadata> featureFilters = serviceProvider.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>();

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()
{
Expand Down Expand Up @@ -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
}
}

Expand Down
12 changes: 12 additions & 0 deletions tests/Tests.FeatureManagement/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@
}
}
]
},
"variants": [
{
"name": "DefaultWhenEnabled"
},
{
"name": "DefaultWhenDisabled"
}
],
"allocation": {
"default_when_enabled": "DefaultWhenEnabled",
"default_when_disabled": "DefaultWhenDisabled"
}
},
{
Expand Down
Loading