Skip to content

Commit

Permalink
Updated FeatureGateAttribute to be able to be used on Razor Pages. (#166
Browse files Browse the repository at this point in the history
)

* Add PageFeatureGate

* Fix tests.

* Remove test page.

* Added missing license headers.

* Bundle PageFeatureGateAttribute functionality into FeatureGate. Page filters and action filter execution is isolated.

* Use OkResult instead of StatusCode. Updated indenting.

* Remove unnecessary virtual for interface implementation.

* Add back netcoreapp2.1 in tests.

* Remove tabs from csproj
  • Loading branch information
jimmyca15 authored Mar 29, 2022
1 parent e3eb4da commit 6cfe92d
Show file tree
Hide file tree
Showing 21 changed files with 365 additions and 18 deletions.
11 changes: 9 additions & 2 deletions Microsoft.FeatureManagement.sln
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29011.400
# Visual Studio Version 17
VisualStudioVersion = 17.0.31825.309
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeatureFlagDemo", "examples\FeatureFlagDemo\FeatureFlagDemo.csproj", "{E58A64A6-BE10-4D7A-AAB8-C3E2925CB32F}"
EndProject
Expand All @@ -19,6 +19,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "examples\Cons
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TargetingConsoleApp", "examples\TargetingConsoleApp\TargetingConsoleApp.csproj", "{6558C21E-CF20-4278-AA08-EB9D1DF29D66}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPages", "examples\RazorPages\RazorPages.csproj", "{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -49,6 +51,10 @@ Global
{6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Release|Any CPU.Build.0 = Release|Any CPU
{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -58,6 +64,7 @@ Global
{FDBB27BA-C5BA-48A7-BA9B-63159943EA9F} = {8ED6FFEE-4037-49A2-9709-BC519C104A90}
{E50FB931-7A42-440E-AC47-B8DFE5E15394} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72}
{6558C21E-CF20-4278-AA08-EB9D1DF29D66} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72}
{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {84DA6C54-F140-4518-A1B4-E4CF42117FBD}
Expand Down
26 changes: 26 additions & 0 deletions examples/RazorPages/Pages/Error.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}

<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>

@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}

<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
30 changes: 30 additions & 0 deletions examples/RazorPages/Pages/Error.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;

namespace RazorPages.Pages
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public class ErrorModel : PageModel
{
public string RequestId { get; set; }

public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);

private readonly ILogger<ErrorModel> _logger;

public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}

public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
}
22 changes: 22 additions & 0 deletions examples/RazorPages/Pages/Index.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
@page
@model IndexModel
@{
ViewData["Title"] = "Home page";
}

<head>
<style>
.code {
font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, serif;
}
</style>
</head>

<h1>
Razor Page Example
</h1>
<p>
This page uses the <span class="code">'PageGateAttribute'</span> to conditionally display this web page.
Since you are seeing this, that means the 'Home' feature is enabled.
If the 'Home' feature is disabled then the browser will display an HTTP 404 (Not Found) error page.
</p>
16 changes: 16 additions & 0 deletions examples/RazorPages/Pages/Index.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.FeatureManagement.Mvc;

namespace RazorPages.Pages
{
[FeatureGate("Home")]
public class IndexModel : PageModel
{
public void OnGet()
{
}
}
}
3 changes: 3 additions & 0 deletions examples/RazorPages/Pages/_ViewImports.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@using RazorPages
@namespace RazorPages.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
29 changes: 29 additions & 0 deletions examples/RazorPages/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace RazorPages
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}

public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}
11 changes: 11 additions & 0 deletions examples/RazorPages/RazorPages.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.FeatureManagement.AspNetCore\Microsoft.FeatureManagement.AspNetCore.csproj" />
</ItemGroup>

</Project>
58 changes: 58 additions & 0 deletions examples/RazorPages/Startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.FeatureManagement;

namespace RazorPages
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}

public IConfiguration Configuration { get; }

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddFeatureManagement();

services.AddRazorPages();
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}

app.UseHttpsRedirection();

app.UseRouting();

app.UseStaticFiles();

app.UseAuthorization();

app.UseEndpoints(o =>
{
o.MapRazorPages();
});
}
}
}
9 changes: 9 additions & 0 deletions examples/RazorPages/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
13 changes: 13 additions & 0 deletions examples/RazorPages/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*",
"FeatureManagement": {
"Home": true
}
}
Binary file added examples/RazorPages/wwwroot/favicon.ico
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using System;
Expand All @@ -14,7 +15,7 @@ namespace Microsoft.FeatureManagement.Mvc
/// An attribute that can be placed on MVC actions to require all or any of a set of features to be enabled. If none of the feature are enabled the registered <see cref="IDisabledFeaturesHandler"/> will be invoked.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
public class FeatureGateAttribute : ActionFilterAttribute
public class FeatureGateAttribute : ActionFilterAttribute, IAsyncPageFilter
{
/// <summary>
/// Creates an attribute that will gate actions unless all the provided feature(s) are enabled.
Expand Down Expand Up @@ -120,5 +121,38 @@ await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAw
await disabledFeaturesHandler.HandleDisabledFeatures(Features, context).ConfigureAwait(false);
}
}

/// <summary>
/// Called asynchronously before the handler method is invoked, after model binding is complete.
/// </summary>
/// <param name="context">The <see cref="PageHandlerExecutingContext"/>.</param>
/// <param name="next">The <see cref="PageHandlerExecutionDelegate"/>. Invoked to execute the next page filter or the handler method itself.</param>
/// <returns>A <see cref="Task"/> that on completion indicates the filter has executed.</returns>
public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{
IFeatureManagerSnapshot fm = context.HttpContext.RequestServices.GetRequiredService<IFeatureManagerSnapshot>();

//
// Enabled state is determined by either 'any' or 'all' features being enabled.
bool enabled = RequirementType == RequirementType.All ?
await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) :
await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false));

if (enabled)
{
await next.Invoke().ConfigureAwait(false);
}
else
{
context.Result = new NotFoundResult();
}
}

/// <summary>
/// Called asynchronously after the handler method has been selected, but before model binding occurs.
/// </summary>
/// <param name="context">The <see cref="PageHandlerSelectedContext"/>.</param>
/// <returns>A <see cref="Task"/> that on completion indicates the filter has executed.</returns>
public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context) => Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@
<ProjectReference Include="..\Microsoft.FeatureManagement\Microsoft.FeatureManagement.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'" >
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.RazorPages" Version="2.1.11" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.TagHelpers" Version="2.1.3" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' != 'netstandard2.0'" >
<ItemGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

Expand Down
54 changes: 54 additions & 0 deletions tests/Tests.FeatureManagement/FeatureManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,60 @@ public async Task GatesFeatures()
Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, gateAnyResponse.StatusCode);
}

[Fact]
public async Task GatesRazorPageFeatures()
{
IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();

TestServer testServer = new TestServer(WebHost.CreateDefaultBuilder().ConfigureServices(services =>
{
services
.AddSingleton(config)
.AddFeatureManagement()
.AddFeatureFilter<TestFilter>();
services.AddMvc(o => DisableEndpointRouting(o));
})
.Configure(app =>
{
app.UseMvc();
}));

IEnumerable<IFeatureFilterMetadata> featureFilters = testServer.Host.Services.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>();

TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter);

//
// Enable all features
testFeatureFilter.Callback = ctx => Task.FromResult(true);

HttpResponseMessage gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll");
HttpResponseMessage gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny");

Assert.Equal(HttpStatusCode.OK, gateAllResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode);

//
// Enable 1/2 features
testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureName == Enum.GetName(typeof(Features), Features.ConditionalFeature));

gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll");
gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny");

Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode);

//
// Enable no
testFeatureFilter.Callback = ctx => Task.FromResult(false);

gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll");
gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny");

Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, gateAnyResponse.StatusCode);
}

[Fact]
public async Task TimeWindow()
Expand Down
Loading

0 comments on commit 6cfe92d

Please sign in to comment.