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/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;