diff --git a/AspNetCore.sln b/AspNetCore.sln index 0ae5033b6b5f..d3ef22223bf8 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1806,6 +1806,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenAp EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KeyManagementSimulator", "src\DataProtection\samples\KeyManagementSimulator\KeyManagementSimulator.csproj", "{5B5F86CC-3598-463C-9F9B-F78FBB6642F4}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StaticAssets", "StaticAssets", "{274100A5-5B2D-4EA2-AC42-A62257FC6BDC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.StaticAssets", "src\StaticAssets\src\Microsoft.AspNetCore.StaticAssets.csproj", "{4D8DE54A-4F32-4881-B07B-DDC79619E573}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.StaticAssets.Tests", "src\StaticAssets\test\Microsoft.AspNetCore.StaticAssets.Tests.csproj", "{9536C284-65B4-4884-BB50-06D629095C3E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10903,6 +10909,38 @@ Global {5B5F86CC-3598-463C-9F9B-F78FBB6642F4}.Release|x64.Build.0 = Release|Any CPU {5B5F86CC-3598-463C-9F9B-F78FBB6642F4}.Release|x86.ActiveCfg = Release|Any CPU {5B5F86CC-3598-463C-9F9B-F78FBB6642F4}.Release|x86.Build.0 = Release|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Debug|arm64.ActiveCfg = Debug|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Debug|arm64.Build.0 = Debug|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Debug|x64.Build.0 = Debug|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Debug|x86.Build.0 = Debug|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Release|Any CPU.Build.0 = Release|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Release|arm64.ActiveCfg = Release|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Release|arm64.Build.0 = Release|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Release|x64.ActiveCfg = Release|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Release|x64.Build.0 = Release|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Release|x86.ActiveCfg = Release|Any CPU + {4D8DE54A-4F32-4881-B07B-DDC79619E573}.Release|x86.Build.0 = Release|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Debug|arm64.ActiveCfg = Debug|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Debug|arm64.Build.0 = Debug|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Debug|x64.ActiveCfg = Debug|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Debug|x64.Build.0 = Debug|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Debug|x86.ActiveCfg = Debug|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Debug|x86.Build.0 = Debug|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Release|Any CPU.Build.0 = Release|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Release|arm64.ActiveCfg = Release|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Release|arm64.Build.0 = Release|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Release|x64.ActiveCfg = Release|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Release|x64.Build.0 = Release|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Release|x86.ActiveCfg = Release|Any CPU + {9536C284-65B4-4884-BB50-06D629095C3E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11795,6 +11833,9 @@ Global {9DC6B242-457B-4767-A84B-C3D23B76C642} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} {D53F0EF7-0CDC-49B4-AA2D-229901B0A734} = {9DC6B242-457B-4767-A84B-C3D23B76C642} {5B5F86CC-3598-463C-9F9B-F78FBB6642F4} = {8275510E-0E6C-45A8-99DF-4F106BC7F075} + {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} + {4D8DE54A-4F32-4881-B07B-DDC79619E573} = {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} + {9536C284-65B4-4884-BB50-06D629095C3E} = {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/Build.props b/eng/Build.props index efb0c0439762..7312a0fb224d 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -174,6 +174,7 @@ $(RepoRoot)src\Mvc\**\*.*proj; $(RepoRoot)src\Azure\**\*.*proj; $(RepoRoot)src\SignalR\**\*.csproj; + $(RepoRoot)src\StaticAssets\**\*.csproj; $(RepoRoot)src\Components\**\*.csproj; $(RepoRoot)src\Analyzers\**\*.csproj; $(RepoRoot)src\FileProviders\**\*.csproj; @@ -219,6 +220,7 @@ $(RepoRoot)src\Mvc\**\src\*.csproj; $(RepoRoot)src\Azure\**\src\*.csproj; $(RepoRoot)src\SignalR\**\src\*.csproj; + $(RepoRoot)src\StaticAssets\src\*.csproj; $(RepoRoot)src\Components\**\src\*.csproj; $(RepoRoot)src\FileProviders\**\src\*.csproj; $(RepoRoot)src\Configuration.KeyPerFile\**\src\*.csproj; diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index caac54022a4d..43600612a50b 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -139,6 +139,7 @@ + diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index 46be57d9577b..533709fb9bf4 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -107,6 +107,7 @@ + diff --git a/eng/ShippingAssemblies.props b/eng/ShippingAssemblies.props index d0cae638afbb..94197d5274ae 100644 --- a/eng/ShippingAssemblies.props +++ b/eng/ShippingAssemblies.props @@ -88,6 +88,7 @@ + diff --git a/eng/TrimmableProjects.props b/eng/TrimmableProjects.props index 7d627f8e3d97..aa4b06dc9e6d 100644 --- a/eng/TrimmableProjects.props +++ b/eng/TrimmableProjects.props @@ -88,6 +88,7 @@ + diff --git a/global.json b/global.json index 4ef452cf0c52..5471c30380ce 100644 --- a/global.json +++ b/global.json @@ -1,9 +1,9 @@ { "sdk": { - "version": "9.0.100-preview.5.24229.2" + "version": "9.0.100-preview.5.24253.17" }, "tools": { - "dotnet": "9.0.100-preview.5.24229.2", + "dotnet": "9.0.100-preview.5.24253.17", "runtimes": { "dotnet/x86": [ "$(MicrosoftNETCoreBrowserDebugHostTransportVersion)" diff --git a/src/Components/Components.slnf b/src/Components/Components.slnf index 1f0cfe428c19..8988c4f77c6c 100644 --- a/src/Components/Components.slnf +++ b/src/Components/Components.slnf @@ -25,6 +25,9 @@ "src\\Components\\WebAssembly\\Authentication.Msal\\src\\Microsoft.Authentication.WebAssembly.Msal.csproj", "src\\Components\\WebAssembly\\DevServer\\src\\Microsoft.AspNetCore.Components.WebAssembly.DevServer.csproj", "src\\Components\\WebAssembly\\JSInterop\\src\\Microsoft.JSInterop.WebAssembly.csproj", + "src\\Components\\WebAssembly\\Samples\\HostedBlazorWebassemblyApp\\Client\\HostedBlazorWebassemblyApp.Client.csproj", + "src\\Components\\WebAssembly\\Samples\\HostedBlazorWebassemblyApp\\Server\\HostedBlazorWebassemblyApp.Server.csproj", + "src\\Components\\WebAssembly\\Samples\\HostedBlazorWebassemblyApp\\Shared\\HostedBlazorWebassemblyApp.Shared.csproj", "src\\Components\\WebAssembly\\Server\\src\\Microsoft.AspNetCore.Components.WebAssembly.Server.csproj", "src\\Components\\WebAssembly\\Server\\test\\Microsoft.AspNetCore.Components.WebAssembly.Server.Tests.csproj", "src\\Components\\WebAssembly\\WebAssembly.Authentication\\src\\Microsoft.AspNetCore.Components.WebAssembly.Authentication.csproj", @@ -145,6 +148,8 @@ "src\\SignalR\\common\\SignalR.Common\\src\\Microsoft.AspNetCore.SignalR.Common.csproj", "src\\SignalR\\server\\Core\\src\\Microsoft.AspNetCore.SignalR.Core.csproj", "src\\SignalR\\server\\SignalR\\src\\Microsoft.AspNetCore.SignalR.csproj", + "src\\StaticAssets\\src\\Microsoft.AspNetCore.StaticAssets.csproj", + "src\\StaticAssets\\test\\Microsoft.AspNetCore.StaticAssets.Tests.csproj", "src\\Testing\\src\\Microsoft.AspNetCore.InternalTesting.csproj", "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] diff --git a/src/Components/Endpoints/src/Builder/ComponentEndpointConventionBuilderHelper.cs b/src/Components/Endpoints/src/Builder/ComponentEndpointConventionBuilderHelper.cs index 6b0001f949e0..e718b481060a 100644 --- a/src/Components/Endpoints/src/Builder/ComponentEndpointConventionBuilderHelper.cs +++ b/src/Components/Endpoints/src/Builder/ComponentEndpointConventionBuilderHelper.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Components.Endpoints.Infrastructure; @@ -19,5 +20,11 @@ public static void AddRenderMode(RazorComponentsEndpointConventionBuilder builde { builder.AddRenderMode(renderMode); } + + /// + /// This method is not recommended for use outside of the Blazor framework. + /// + /// + public static IEndpointRouteBuilder GetEndpointRouteBuilder(RazorComponentsEndpointConventionBuilder builder) => builder.EndpointRouteBuilder; } diff --git a/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs b/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs index 06d3113f36e3..d7ce3ca90bc9 100644 --- a/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs +++ b/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs @@ -39,12 +39,12 @@ internal class RazorComponentEndpointDataSource<[DynamicallyAccessedMembers(Comp public RazorComponentEndpointDataSource( ComponentApplicationBuilder builder, IEnumerable renderModeEndpointProviders, - IApplicationBuilder applicationBuilder, + IEndpointRouteBuilder endpointRouteBuilder, RazorComponentEndpointFactory factory, HotReloadService? hotReloadService = null) { _builder = builder; - _applicationBuilder = applicationBuilder; + _applicationBuilder = endpointRouteBuilder.CreateApplicationBuilder(); _renderModeEndpointProviders = renderModeEndpointProviders.ToArray(); _factory = factory; _hotReloadService = hotReloadService; @@ -52,6 +52,7 @@ public RazorComponentEndpointDataSource( DefaultBuilder = new RazorComponentsEndpointConventionBuilder( _lock, builder, + endpointRouteBuilder, _options, _conventions, _finallyConventions); diff --git a/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSourceFactory.cs b/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSourceFactory.cs index 3dcee668151a..462fc50a2268 100644 --- a/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSourceFactory.cs +++ b/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSourceFactory.cs @@ -31,6 +31,6 @@ public RazorComponentEndpointDataSourceFactory( var builder = ComponentApplicationBuilder.GetBuilder() ?? DefaultRazorComponentApplication.Instance.GetBuilder(); - return new RazorComponentEndpointDataSource(builder, _providers, endpoints.CreateApplicationBuilder(), _factory, _hotReloadService); + return new RazorComponentEndpointDataSource(builder, _providers, endpoints, _factory, _hotReloadService); } } diff --git a/src/Components/Endpoints/src/Builder/RazorComponentsEndpointConventionBuilder.cs b/src/Components/Endpoints/src/Builder/RazorComponentsEndpointConventionBuilder.cs index 0f01bf70091a..c37b9d348a9b 100644 --- a/src/Components/Endpoints/src/Builder/RazorComponentsEndpointConventionBuilder.cs +++ b/src/Components/Endpoints/src/Builder/RazorComponentsEndpointConventionBuilder.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Discovery; using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Builder; @@ -14,6 +15,7 @@ public sealed class RazorComponentsEndpointConventionBuilder : IEndpointConventi { private readonly object _lock; private readonly ComponentApplicationBuilder _builder; + private readonly IEndpointRouteBuilder _endpointRouteBuilder; private readonly RazorComponentDataSourceOptions _options; private readonly List> _conventions; private readonly List> _finallyConventions; @@ -21,12 +23,14 @@ public sealed class RazorComponentsEndpointConventionBuilder : IEndpointConventi internal RazorComponentsEndpointConventionBuilder( object @lock, ComponentApplicationBuilder builder, + IEndpointRouteBuilder endpointRouteBuilder, RazorComponentDataSourceOptions options, List> conventions, List> finallyConventions) { _lock = @lock; _builder = builder; + _endpointRouteBuilder = endpointRouteBuilder; _options = options; _conventions = conventions; _finallyConventions = finallyConventions; @@ -37,6 +41,8 @@ internal RazorComponentsEndpointConventionBuilder( /// internal ComponentApplicationBuilder ApplicationBuilder => _builder; + internal IEndpointRouteBuilder EndpointRouteBuilder => _endpointRouteBuilder; + /// public void Add(Action convention) { diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index 8bed29bca3ae..ede23967402c 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ #nullable enable Microsoft.AspNetCore.Components.Routing.RazorComponentsEndpointHttpContextExtensions +static Microsoft.AspNetCore.Components.Endpoints.Infrastructure.ComponentEndpointConventionBuilderHelper.GetEndpointRouteBuilder(Microsoft.AspNetCore.Builder.RazorComponentsEndpointConventionBuilder! builder) -> Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! static Microsoft.AspNetCore.Components.Routing.RazorComponentsEndpointHttpContextExtensions.AcceptsInteractiveRouting(this Microsoft.AspNetCore.Http.HttpContext! context) -> bool diff --git a/src/Components/Endpoints/test/HotReloadServiceTests.cs b/src/Components/Endpoints/test/HotReloadServiceTests.cs index a492c36e39b0..08c83562a581 100644 --- a/src/Components/Endpoints/test/HotReloadServiceTests.cs +++ b/src/Components/Endpoints/test/HotReloadServiceTests.cs @@ -220,7 +220,7 @@ private static RazorComponentEndpointDataSource CreateDataSource( builder, new[] { new MockEndpointProvider() }, - new ApplicationBuilder(services), + new TestEndpointRouteBuilder(services), new RazorComponentEndpointFactory(), new HotReloadService() { MetadataUpdateSupported = true }); @@ -256,4 +256,18 @@ public override IEnumerable GetEndpointBuilders(IComponent public override bool Supports(IComponentRenderMode renderMode) => true; } + + private class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + private IServiceProvider _serviceProvider; + private List _dataSources = new(); + + public TestEndpointRouteBuilder(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider; + + public IServiceProvider ServiceProvider => _serviceProvider; + + public ICollection DataSources => _dataSources; + + public IApplicationBuilder CreateApplicationBuilder() => new ApplicationBuilder(_serviceProvider); + } } diff --git a/src/Components/Endpoints/test/RazorComponentEndpointDataSourceTest.cs b/src/Components/Endpoints/test/RazorComponentEndpointDataSourceTest.cs index 6135cd456ffd..0468425dbc5f 100644 --- a/src/Components/Endpoints/test/RazorComponentEndpointDataSourceTest.cs +++ b/src/Components/Endpoints/test/RazorComponentEndpointDataSourceTest.cs @@ -228,7 +228,7 @@ private RazorComponentEndpointDataSource CreateDataSource( builder ?? DefaultRazorComponentApplication.Instance.GetBuilder(), services?.GetService>() ?? Enumerable.Empty(), - new ApplicationBuilder(services ?? new ServiceCollection().BuildServiceProvider()), + new TestEndpointRouteBuilder(services ?? new ServiceCollection().BuildServiceProvider()), new RazorComponentEndpointFactory(), new HotReloadService() { MetadataUpdateSupported = true }); @@ -277,6 +277,20 @@ public override IEnumerable GetEndpointBuilders(IComponent public override bool Supports(IComponentRenderMode renderMode) => renderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode; } + + private class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + private IServiceProvider _serviceProvider; + private List _dataSources = new(); + + public TestEndpointRouteBuilder(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider; + + public IServiceProvider ServiceProvider => _serviceProvider; + + public ICollection DataSources => _dataSources; + + public IApplicationBuilder CreateApplicationBuilder() => new ApplicationBuilder(_serviceProvider); + } } public class App : IComponent diff --git a/src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj b/src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj index 1c3800d25de4..5f540ab86959 100644 --- a/src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj +++ b/src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 3f3fc0bcbedb..aebb156ed98d 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -27,6 +27,7 @@ app.UseStaticFiles(); app.UseAntiforgery(); +app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); diff --git a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/HostedBlazorWebassemblyApp.Server.csproj b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/HostedBlazorWebassemblyApp.Server.csproj index d6c10f9f3da8..339fea3989f3 100644 --- a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/HostedBlazorWebassemblyApp.Server.csproj +++ b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/HostedBlazorWebassemblyApp.Server.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/Startup.cs b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/Startup.cs index 15fcb4913996..10e942763938 100644 --- a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/Startup.cs +++ b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/Startup.cs @@ -5,6 +5,7 @@ using HostedBlazorWebassemblyApp.Server.Data; using HostedBlazorWebassemblyApp.Shared; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.WebAssembly.Server; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpsPolicy; using Microsoft.Extensions.Configuration; @@ -48,8 +49,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) } app.UseHttpsRedirection(); - app.UseBlazorFrameworkFiles(); - app.UseStaticFiles(); app.UseRouting(); @@ -57,7 +56,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { endpoints.MapRazorPages(); endpoints.MapControllers(); - //endpoints.MapFallbackToFile("index.html"); + endpoints.MapStaticAssets(); endpoints.MapFallbackToPage("/_Host"); }); } diff --git a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs index 331678c72e0b..a3da958d6ff0 100644 --- a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs +++ b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs @@ -26,4 +26,11 @@ public sealed class WebAssemblyComponentsEndpointOptions /// information, see . /// public bool ServeMultithreadingHeaders { get; set; } + + /// + /// Gets or sets the that determines the static assets manifest path mapped to this app. + /// + public string? StaticAssetsManifestPath { get; set; } + + internal bool ConventionsApplied { get; set; } } diff --git a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs index 81f050969d58..dd81603a225f 100644 --- a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs @@ -1,18 +1,23 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Linq; using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.Components.Endpoints.Infrastructure; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Server; -using System.Linq; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticAssets.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Builder; /// /// Web assembly specific endpoint conventions for razor component applications. /// -public static class WebAssemblyRazorComponentsEndpointConventionBuilderExtensions +public static partial class WebAssemblyRazorComponentsEndpointConventionBuilderExtensions { /// /// Configures the application to support the render mode. @@ -45,6 +50,56 @@ public static RazorComponentsEndpointConventionBuilder AddInteractiveWebAssembly } ComponentEndpointConventionBuilderHelper.AddRenderMode(builder, new WebAssemblyRenderModeWithOptions(options)); + + var endpointBuilder = ComponentEndpointConventionBuilderHelper.GetEndpointRouteBuilder(builder); + + // If the static assets data source for the given manifest name is already added, then just wire-up the Blazor WebAssembly conventions. + // MapStaticWebAssetEndpoints is idempotent and will not add the data source if it already exists. + if (HasStaticAssetDataSource(endpointBuilder, options.StaticAssetsManifestPath)) + { + options.ConventionsApplied = true; + endpointBuilder.MapStaticAssets(options.StaticAssetsManifestPath) + .AddBlazorWebAssemblyConventions(); + + return builder; + } + + var environment = endpointBuilder.ServiceProvider.GetRequiredService(); + if (environment.IsDevelopment()) + { + var logger = endpointBuilder.ServiceProvider.GetRequiredService>(); + if (options.StaticAssetsManifestPath is null) + { + Log.StaticAssetsMappingNotFoundForDefaultManifest(logger); + } + else + { + Log.StaticAssetsMappingNotFoundWithManifest(logger, options.StaticAssetsManifestPath); + } + } + return builder; } + + private static bool HasStaticAssetDataSource(IEndpointRouteBuilder endpointRouteBuilder, string? staticAssetsManifestName) + { + foreach (var ds in endpointRouteBuilder.DataSources) + { + if (StaticAssetsEndpointDataSourceHelper.IsStaticAssetsDataSource(ds, staticAssetsManifestName)) + { + return true; + } + } + + return false; + } + + internal static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, $$"""Mapped static asset endpoints not found. Ensure '{{nameof(StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssets)}}' is called before '{{nameof(AddInteractiveWebAssemblyRenderMode)}}'.""")] + internal static partial void StaticAssetsMappingNotFoundForDefaultManifest(ILogger logger); + + [LoggerMessage(2, LogLevel.Warning, $$"""Mapped static asset endpoints not found for manifest '{ManifestPath}'. Ensure '{{nameof(StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssets)}}'(staticAssetsManifestPath) is called before '{{nameof(AddInteractiveWebAssemblyRenderMode)}}' and that both manifest paths are the same.""")] + internal static partial void StaticAssetsMappingNotFoundWithManifest(ILogger logger, string manifestPath); + } } diff --git a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs index e896685e46eb..2fe8c3de1a73 100644 --- a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs @@ -23,7 +23,7 @@ public static class ComponentsWebAssemblyApplicationBuilderExtensions private static readonly string? s_aspnetcoreBrowserTools = GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS"); private static string? GetNonEmptyEnvironmentVariableValue(string name) - => Environment.GetEnvironmentVariable(name) is { Length: >0 } value ? value : null; + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null; /// /// Configures the application to serve Blazor WebAssembly framework files from the path . This path must correspond to a referenced Blazor WebAssembly application project. diff --git a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs new file mode 100644 index 000000000000..841c81a8cef2 --- /dev/null +++ b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticAssets; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Server; + +internal static class ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions +{ + private static readonly string? s_dotnetModifiableAssemblies = GetNonEmptyEnvironmentVariableValue("DOTNET_MODIFIABLE_ASSEMBLIES"); + private static readonly string? s_aspnetcoreBrowserTools = GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS"); + + private static string? GetNonEmptyEnvironmentVariableValue(string name) + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null; + + /// + /// Configures additional static web asset extensions logic for Blazor WebAssembly. + /// + /// + internal static void AddBlazorWebAssemblyConventions(this StaticAssetsEndpointConventionBuilder builder) + { + builder.Add(endpoint => + { + if (endpoint is RouteEndpointBuilder { RoutePattern.RawText: { } pattern } && pattern.Contains("/_framework/", StringComparison.OrdinalIgnoreCase) && + !pattern.Contains("/_framework/blazor.server.js", StringComparison.OrdinalIgnoreCase) && !pattern.Contains("/_framework/blazor.web.js", StringComparison.OrdinalIgnoreCase)) + { + WrapEndpoint(endpoint); + } + }); + } + + private static void WrapEndpoint(EndpointBuilder endpoint) + { + var original = endpoint.RequestDelegate; + if (original == null) + { + return; + } + + for (var i = 0; i < endpoint.Metadata.Count; i++) + { + if (endpoint.Metadata[i] is WebAssemblyConventionsAppliedMetadata) + { + // Already applied + return; + } + } + + endpoint.Metadata.Add(new WebAssemblyConventionsAppliedMetadata()); + + // Note this mimics what UseBlazorFrameworkFiles does. + // The goal is to remove all this logic and push it to the build. For example, we should not have + // "Cache-Control" "no-cache" here as the build itself will add it. + // Similarly, we shouldn't add the `DOTNET-MODIFIABLE-ASSEMBLIES` and `ASPNETCORE-BROWSER-TOOLS` headers here. + // Those should be handled by the tooling, by hooking up on to the OnResponseStarting event and checking that the + // endpoint is for the appropriate web assembly file. (Very likely this is only needed for the blazor.boot.json file) + endpoint.RequestDelegate = (context) => + { + var webHostEnvironment = context.RequestServices.GetRequiredService(); + context.Response.Headers.Add("Blazor-Environment", webHostEnvironment.EnvironmentName); + context.Response.Headers.Add(HeaderNames.CacheControl, "no-cache"); + + // DOTNET_MODIFIABLE_ASSEMBLIES is used by the runtime to initialize hot-reload specific environment variables and is configured + // by the launching process (dotnet-watch / Visual Studio). + // Always add the header if the environment variable is set, regardless of the kind of environment. + if (s_dotnetModifiableAssemblies != null) + { + context.Response.Headers.Add("DOTNET-MODIFIABLE-ASSEMBLIES", s_dotnetModifiableAssemblies); + } + + // See https://github.com/dotnet/aspnetcore/issues/37357#issuecomment-941237000 + // Translate the _ASPNETCORE_BROWSER_TOOLS environment configured by the browser tools agent in to a HTTP response header. + if (s_aspnetcoreBrowserTools != null) + { + context.Response.Headers.Add("ASPNETCORE-BROWSER-TOOLS", s_aspnetcoreBrowserTools); + } + + return original(context); + }; + } + + private sealed class WebAssemblyConventionsAppliedMetadata; +} diff --git a/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj b/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj index 2bd9c32a565a..6bc07e9a288c 100644 --- a/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj +++ b/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt b/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt index d57841913a48..deab402bac7a 100644 --- a/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt +++ b/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.ServeMultithreadingHeaders.get -> bool Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.ServeMultithreadingHeaders.set -> void +Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.StaticAssetsManifestPath.get -> string? +Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.StaticAssetsManifestPath.set -> void diff --git a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs index 4e780879c83b..f390f78cb889 100644 --- a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs @@ -36,33 +36,37 @@ public override IEnumerable GetEndpointBuilders(IComponent { if (renderMode is not WebAssemblyRenderModeWithOptions wasmWithOptions) { - if (renderMode is InteractiveWebAssemblyRenderMode) - { - throw new InvalidOperationException("Invalid render mode. Use AddInteractiveWebAssemblyRenderMode(Action) to configure the WebAssembly render mode."); - } - - return Array.Empty(); + return renderMode is InteractiveWebAssemblyRenderMode + ? throw new InvalidOperationException("Invalid render mode. Use AddInteractiveWebAssemblyRenderMode(Action) to configure the WebAssembly render mode.") + : (IEnumerable)Array.Empty(); } + if (wasmWithOptions is { EndpointOptions.ConventionsApplied: true }) + { + return []; // No need to add additional endpoints to the DS, they are already added + } + else + { + // In case the app didn't call MapStaticAssets, use the 8.0 approach to map the assets. + var endpointRouteBuilder = new EndpointRouteBuilder(services, applicationBuilder); + var pathPrefix = wasmWithOptions.EndpointOptions?.PathPrefix; - var endpointRouteBuilder = new EndpointRouteBuilder(services, applicationBuilder); - var pathPrefix = wasmWithOptions.EndpointOptions?.PathPrefix; - - applicationBuilder.UseBlazorFrameworkFiles(pathPrefix ?? default); - var app = applicationBuilder.Build(); + applicationBuilder.UseBlazorFrameworkFiles(pathPrefix ?? default); + var app = applicationBuilder.Build(); - endpointRouteBuilder.Map($"{pathPrefix}/_framework/{{*path}}", context => - { - // Set endpoint to null so the static files middleware will handle the request. - context.SetEndpoint(null); + endpointRouteBuilder.Map($"{pathPrefix}/_framework/{{*path}}", context => + { + // Set endpoint to null so the static files middleware will handle the request. + context.SetEndpoint(null); - return app(context); - }); + return app(context); + }); - return endpointRouteBuilder.GetEndpoints(); + return endpointRouteBuilder.GetEndpoints(); + } } - public override bool Supports(IComponentRenderMode renderMode) - => renderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode; + public override bool Supports(IComponentRenderMode renderMode) => + renderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode; private class EndpointRouteBuilder : IEndpointRouteBuilder { @@ -76,7 +80,7 @@ public EndpointRouteBuilder(IServiceProvider serviceProvider, IApplicationBuilde public IServiceProvider ServiceProvider { get; } - public ICollection DataSources { get; } = new List() { }; + public ICollection DataSources { get; } = []; public IApplicationBuilder CreateApplicationBuilder() { diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/WebHostServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/WebHostServerFixture.cs index d9d41f9e0437..1b4ce4d6231f 100644 --- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/WebHostServerFixture.cs +++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/WebHostServerFixture.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; -public abstract class WebHostServerFixture : ServerFixture +public abstract class WebHostServerFixture : ServerFixture, IAsyncDisposable, IAsyncLifetime { protected override string StartAndGetRootUri() { @@ -23,12 +23,22 @@ protected override string StartAndGetRootUri() public IHost Host { get; set; } public override void Dispose() + { + DisposeCore().AsTask().Wait(); + } + + protected abstract IHost CreateWebHost(); + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + + Task IAsyncLifetime.DisposeAsync() => DisposeCore().AsTask(); + + ValueTask IAsyncDisposable.DisposeAsync() => DisposeCore(); + + private async ValueTask DisposeCore() { // This can be null if creating the webhost throws, we don't want to throw here and hide // the original exception. Host?.Dispose(); - Host?.StopAsync(); + await Host?.StopAsync(); } - - protected abstract IHost CreateWebHost(); } diff --git a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs index 9ef5c926001a..c35ca3680d8f 100644 --- a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs +++ b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs @@ -1,3 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + using Components.TestServer.RazorComponents; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; diff --git a/src/Components/test/E2ETest/Tests/RemoteAuthenticationTest.cs b/src/Components/test/E2ETest/Tests/RemoteAuthenticationTest.cs index 6915b0915198..2ab1622379b8 100644 --- a/src/Components/test/E2ETest/Tests/RemoteAuthenticationTest.cs +++ b/src/Components/test/E2ETest/Tests/RemoteAuthenticationTest.cs @@ -54,7 +54,7 @@ private static IHost BuildPublishedWebHost(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureLogging((ctx, lb) => { - TestSink sink = new TestSink(); + var sink = new TestSink(); lb.AddProvider(new TestLoggerProvider(sink)); lb.Services.AddSingleton(sink); }) diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 43c5947926af..04d13dbf835f 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -9,6 +9,7 @@ using Components.TestServer.RazorComponents.Pages.Forms; using Components.TestServer.Services; using Microsoft.AspNetCore.Components.Server.Circuits; +using Microsoft.AspNetCore.Components.WebAssembly.Server; using Microsoft.AspNetCore.Mvc; namespace TestServer; @@ -64,7 +65,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseExceptionHandler("/Error", createScopeForErrors: true); } - app.UseStaticFiles(); app.UseRouting(); UseFakeAuthState(app); app.UseAntiforgery(); @@ -80,6 +80,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) _ = app.UseEndpoints(endpoints => { + endpoints.MapStaticAssets(); _ = endpoints.MapRazorComponents() .AddAdditionalAssemblies(Assembly.Load("Components.WasmMinimal")) .AddInteractiveServerRenderMode(options => diff --git a/src/Components/test/testassets/Components.TestServer/RemoteAuthenticationStartup.cs b/src/Components/test/testassets/Components.TestServer/RemoteAuthenticationStartup.cs index 69aca756bb9e..d2e2faa858e7 100644 --- a/src/Components/test/testassets/Components.TestServer/RemoteAuthenticationStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RemoteAuthenticationStartup.cs @@ -23,11 +23,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.Map("/subdir", app => { - app.UseStaticFiles(); app.UseRouting(); + app.UseAntiforgery(); app.UseEndpoints(endpoints => { + endpoints.MapStaticAssets(Path.Combine("trimmed-or-threading", "Components.TestServer", "Components.TestServer.staticwebassets.endpoints.json")); endpoints.MapRazorComponents() .AddAdditionalAssemblies(Assembly.Load("Components.WasmRemoteAuthentication")) .AddInteractiveWebAssemblyRenderMode(options => options.PathPrefix = "/WasmRemoteAuthentication"); diff --git a/src/Components/test/testassets/Components.WasmRemoteAuthentication/Program.cs b/src/Components/test/testassets/Components.WasmRemoteAuthentication/Program.cs index e8f99c23b6e4..712d678c8920 100644 --- a/src/Components/test/testassets/Components.WasmRemoteAuthentication/Program.cs +++ b/src/Components/test/testassets/Components.WasmRemoteAuthentication/Program.cs @@ -1,3 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + using Microsoft.AspNetCore.Components.WebAssembly.Hosting; var builder = WebAssemblyHostBuilder.CreateDefault(args); diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index 7291fc2b5864..e3e11b2f8cef 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -102,6 +102,7 @@ static TestData() "Microsoft.AspNetCore.SignalR.Core", "Microsoft.AspNetCore.SignalR.Protocols.Json", "Microsoft.AspNetCore.StaticFiles", + "Microsoft.AspNetCore.StaticAssets", "Microsoft.AspNetCore.WebSockets", "Microsoft.AspNetCore.WebUtilities", "Microsoft.Extensions.Caching.Abstractions", @@ -252,6 +253,7 @@ static TestData() { "Microsoft.AspNetCore.SignalR.Protocols.Json" }, { "Microsoft.AspNetCore.SignalR" }, { "Microsoft.AspNetCore.StaticFiles" }, + { "Microsoft.AspNetCore.StaticAssets" }, { "Microsoft.AspNetCore.WebSockets" }, { "Microsoft.AspNetCore.WebUtilities" }, { "Microsoft.AspNetCore" }, diff --git a/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs b/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs index dcbcc2e123bc..54034d7d716f 100644 --- a/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs +++ b/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs @@ -105,6 +105,7 @@ public static IServiceCollection AddRoutingCore(this IServiceCollection services services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); // // Misc infrastructure diff --git a/src/Http/Routing/src/Matching/CandidateState.cs b/src/Http/Routing/src/Matching/CandidateState.cs index 8eae3a22c39c..8c83890f7259 100644 --- a/src/Http/Routing/src/Matching/CandidateState.cs +++ b/src/Http/Routing/src/Matching/CandidateState.cs @@ -36,7 +36,7 @@ internal CandidateState(Endpoint endpoint, RouteValueDictionary? values, int sco /// /// /// Candidates within a set are ordered in priority order and then assigned a - /// sequential score value based on that ordering. Candiates with the same + /// sequential score value based on that ordering. Candidates with the same /// score are considered to have equal priority. /// /// diff --git a/src/Http/Routing/src/Matching/ContentEncodingMetadata.cs b/src/Http/Routing/src/Matching/ContentEncodingMetadata.cs new file mode 100644 index 000000000000..aabad8e5dc20 --- /dev/null +++ b/src/Http/Routing/src/Matching/ContentEncodingMetadata.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Routing.Matching; + +namespace Microsoft.AspNetCore.Routing; + +/// +/// Metadata used to negotiate wich endpoint to select based on the value of the Accept-Encoding header. +/// +/// The Accept-Encoding value this metadata represents. +/// The server preference to apply to break ties. +public sealed class ContentEncodingMetadata(string value, double quality) : INegotiateMetadata +{ + /// + /// Gets the Accept-Encoding value this metadata represents. + /// + public string Value { get; } = value; + + /// + /// Gets the server preference to apply to break ties when two or more client options have the same preference. + /// + public double Quality { get; } = quality; +} diff --git a/src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs b/src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs new file mode 100644 index 000000000000..3275c760a1c0 --- /dev/null +++ b/src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Routing.Matching; + +internal sealed class ContentEncodingNegotiationMatcherPolicy : NegotiationMatcherPolicy +{ + internal static string HeaderName => "Accept-Encoding"; + + private protected override bool HasMetadata(Endpoint endpoint) => endpoint.Metadata.GetMetadata() != null; + + private protected override string? GetMetadataValue(Endpoint endpoint) => endpoint.Metadata.GetMetadata()?.Value; + + private protected override StringValues GetNegotiationHeader(HttpContext httpContext) => httpContext.Request.Headers[HeaderName]; + + private protected override bool IsDefaultMetadataValue(ReadOnlySpan candidate) => + MemoryExtensions.Equals("identity".AsSpan(), candidate, StringComparison.OrdinalIgnoreCase) || + MemoryExtensions.Equals("*".AsSpan(), candidate, StringComparison.OrdinalIgnoreCase); + + private protected override double? GetMetadataQuality(Endpoint endpoint) + { + var metadata = endpoint.Metadata.GetMetadata(); + return metadata?.Quality; + } + + private protected override NegotiationPolicyJumpTable CreateTable(int exitDestination, (string negotiationValue, double quality, int destination)[] destinations, int noNegotiationHeaderDestination) => new ContentEncodingPolicyJumpTable(exitDestination, noNegotiationHeaderDestination, new ContentEncodingDestinationsLookUp(destinations)); + + internal sealed class ContentEncodingPolicyJumpTable(int anyContentEncodingDestination, int noContentEncodingDestination, ContentEncodingDestinationsLookUp destinations) : NegotiationPolicyJumpTable("Accept-Encoding", anyContentEncodingDestination, noContentEncodingDestination) + { + private readonly ContentEncodingDestinationsLookUp _destinations = destinations; + + protected override int GetDestination(string? value) => _destinations.GetDestination(value); + + protected override double GetQuality(string? value) => _destinations.GetValueQuality(value); + } + + internal sealed class ContentEncodingDestinationsLookUp + { + private readonly int _brotliDestination = -1; + private readonly double _brotliQuality; + private readonly int _gzipDestination = -1; + private readonly double _gzipQuality; + private readonly int _identityDestination = -1; + private readonly double _identityQuality; + private readonly Dictionary? _extraDestinations; + + public ContentEncodingDestinationsLookUp((string contentEncoding, double quality, int destination)[] destinations) + { + for (var i = 0; i < destinations.Length; i++) + { + var (contentEncoding, quality, destination) = destinations[i]; + switch (contentEncoding.ToLowerInvariant()) + { + case "br": + _brotliDestination = destination; + _brotliQuality = quality; + break; + case "gzip": + _gzipDestination = destination; + _gzipQuality = quality; + break; + case "identity": + _identityDestination = destination; + _identityQuality = quality; + break; + default: + _extraDestinations ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + _extraDestinations.Add(contentEncoding, (destination, quality)); + break; + } + } + } + + public int GetDestination(string? negotiationValue) + { + // Specialcase the lookup based on the length of the negotiation value + // to reduce the number of required comparisons needed to find a match. + // The match will be validated after this selection. + var (matchedEncoding, destination) = negotiationValue?.Length switch + { + 2 => ("br", _brotliDestination), + 4 => ("gzip", _gzipDestination), + 8 => ("identity", _identityDestination), + _ => (null, -1) + }; + + if (matchedEncoding != null && string.Equals(negotiationValue, matchedEncoding, StringComparison.OrdinalIgnoreCase)) + { + return destination; + } + + if (_extraDestinations != null && negotiationValue != null && _extraDestinations.TryGetValue(negotiationValue, out var extraDestination)) + { + return extraDestination.destination; + } + + return -1; + } + + public double GetValueQuality(string? negotiationValue) + { + var (matchedEncoding, quality) = negotiationValue?.Length switch + { + 2 => ("br", _brotliQuality), + 4 => ("gzip", _gzipQuality), + 8 => ("identity", _identityQuality), + _ => (null, -1) + }; + + if (matchedEncoding != null && string.Equals(negotiationValue, matchedEncoding, StringComparison.OrdinalIgnoreCase)) + { + return quality; + } + + if (_extraDestinations != null && negotiationValue != null && _extraDestinations.TryGetValue(negotiationValue, out var extraDestination)) + { + return extraDestination.quality; + } + + return -1; + } + } +} diff --git a/src/Http/Routing/src/Matching/INegotiateMetadata.cs b/src/Http/Routing/src/Matching/INegotiateMetadata.cs new file mode 100644 index 000000000000..c2686afd47ca --- /dev/null +++ b/src/Http/Routing/src/Matching/INegotiateMetadata.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Routing.Matching; + +internal interface INegotiateMetadata +{ + string Value { get; } + double Quality { get; } +} diff --git a/src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs b/src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs new file mode 100644 index 000000000000..f14c9a041c30 --- /dev/null +++ b/src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs @@ -0,0 +1,443 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Routing.Matching; + +internal abstract class NegotiationMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy, INodeBuilderPolicy, IEndpointComparerPolicy + where TNegotiateMetadata : class, INegotiateMetadata +{ + private const string DefaultNegotiationValue = "identity"; + private static Endpoint? Http406Endpoint; + internal const string Http406EndpointDisplayName = "406 HTTP Unsupported Encoding"; + + // This policy runs very late in the pipeline, this ensures that any endpoint that might be potentially invalid + // for other reasons, gets removed before we perform content negotiation. + public override int Order => 10_000; + + public IComparer Comparer => new NegotiationMetadataEndpointComparer(); + + bool INodeBuilderPolicy.AppliesToEndpoints(IReadOnlyList endpoints) => + !ContainsDynamicEndpoints(endpoints) && AppliesToEndpointsCore(endpoints); + + bool IEndpointSelectorPolicy.AppliesToEndpoints(IReadOnlyList endpoints) => + ContainsDynamicEndpoints(endpoints) || AppliesToEndpointsCore(endpoints); + + private bool AppliesToEndpointsCore(IReadOnlyList endpoints) + { + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + if (HasMetadata(endpoint)) + { + return true; + } + } + + return false; + } + + // Returns whether the endpoint has the metadata required for this policy to apply. + private protected abstract bool HasMetadata(Endpoint endpoint); + + private protected abstract string? GetMetadataValue(Endpoint endpoint); + + private protected abstract StringValues GetNegotiationHeader(HttpContext httpContext); + + private protected abstract bool IsDefaultMetadataValue(ReadOnlySpan candidate); + + private protected abstract double? GetMetadataQuality(Endpoint endpoint); + + // We iterate over the list of candidates starting with a quality of 0. + // If we are able to match a candidate with one of the values from the header + // the one with the highest matching quality wins. + // It is ok for multiple candidates to tie, there is an ordering process based on + // endpoint metadata that will break the tie. + // It's also possible that none of the candidates can satisfy the header. Candidates without + // metadata are always valid, but they have less priority that candidates with the metadata. + // (They are considered less specific matches) + // Algorithm mechanics + // We iterate from 0 to N. At each point we try to match each header value with the value + // from the endpoint. + // The first time we find a match, that becomes the initial selection, at that point, we can + // invalidate all the previous candidates, since they either didn't match or were defaults. + // It is important to note that we receive the list of candidates already ordered by their specificity + // which helps us simplify the algorithm. + // From that point on, we continue iterating over the remaining candidates: + // * If a candidate matches with a lower quality we invalidate it (we already have a better match). + // * If a candidate matches with the same quality we keep it (we break the tie later on based on order, + // or another policy might invalidate our current best match or another one). + // * If a candidate matches with higher quality then we mark it as our best match and we invalidate + // all the elements from the current element to the new best match. + // After we've processed all candidates two things can happen: + // * We found a compatible candidate -> We can return, all candidates with lower quality (or defaults) are invalidated. + // * We haven't found a compatible candidate: + // * The default value was explicitly listed in the header -> No compatible candidate. Invalidate all. + // * The default value was not explicitly listed in the header -> Do nothing, we've invalidated already any endpoint with a non-default value for the metadata. + // * In this situation the most likely outcome is that there is a single remaining valid endpoint. For example, the uncompressed asset. Its even possible that there + // is more than one, and that's legitimate. We might be using a different policy to choose which endpoint is preferred. + public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) + { + var header = GetNegotiationHeader(httpContext); + if (StringValues.IsNullOrEmpty(header) || + !StringWithQualityHeaderValue.TryParseList(header, out var values) || values.Count == 0) + { + values = Array.Empty(); + } + + // The candidates are already sorted based on the metadata quality for endpoints that contain the metadata + // and endpoints with metadata are considered before (and preferred to) those without it. + var sawCandidateWithoutMetadata = false; + var sawValidCandidate = false; + var bestMatchIndex = -1; + var bestQualitySoFar = 0.0; + var bestEndpointQualitySoFar = 0.0; + for (var i = 0; i < candidates.Count; i++) + { + if (!candidates.IsValidCandidate(i)) + { + // Skip invalid candidates. + continue; + } + + sawValidCandidate = true; + + ref var candidate = ref candidates[i]; + var metadata = GetMetadataValue(candidate.Endpoint); + if (metadata is null) + { + sawCandidateWithoutMetadata = true; + } + metadata ??= DefaultNegotiationValue; + + var found = false; + for (var j = 0; j < values.Count; j++) + { + var value = values[j]; + if (MemoryExtensions.Equals(metadata.AsSpan(), value.Value.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + found = true; + EvaluateCandidate(candidates, ref bestMatchIndex, ref bestQualitySoFar, ref bestEndpointQualitySoFar, i, value); + break; + } + } + + if (!found && (bestMatchIndex >= 0 || metadata != DefaultNegotiationValue)) + { + // We already have at least a candidate, and the default value was not part of the header, so we won't be considering it + // at a later stage as a fallback. + candidates.SetValidity(i, false); + } + } + + if (bestMatchIndex < 0 && !sawCandidateWithoutMetadata && sawValidCandidate) + { + // We did not see any candidate that matched the header and we did not see an endpoint + // without the metadata. + httpContext.SetEndpoint(CreateRejectionEndpoint()); + httpContext.Request.RouteValues = null!; + } + + return Task.CompletedTask; + } + + private void EvaluateCandidate( + CandidateSet candidates, + ref int bestMatchIndex, + ref double bestQualitySoFar, + ref double bestEndpointQualitySoFar, + int currentIndex, + StringWithQualityHeaderValue value) + { + var quality = value.Quality ?? 1.0; + if ((quality - bestQualitySoFar) > double.Epsilon) + { + // The quality defined for this value is higher. + bestQualitySoFar = quality; + bestMatchIndex = Math.Max(bestMatchIndex, 0); + bestEndpointQualitySoFar = GetMetadataQuality(candidates[currentIndex].Endpoint) ?? 1.0; + + // Since we found a better match, we can invalidate all the candidates from the current position to the new one. + for (var j = bestMatchIndex; j < currentIndex; j++) + { + candidates.SetValidity(j, false); + } + + bestMatchIndex = currentIndex; + } + else if ((bestQualitySoFar - quality) > double.Epsilon) + { + // The quality defined for this value is lower than the quality for the element we've selected so far. + candidates.SetValidity(currentIndex, false); + } + else + { + // Header quality is equal to the best quality so far. + // Evauate the quality of the metadata to break the tie. + var endpointQuality = GetMetadataQuality(candidates[currentIndex].Endpoint) ?? 1.0; + if ((endpointQuality - bestEndpointQualitySoFar) > double.Epsilon) + { + // Since we found a better match, we can invalidate all the candidates from the current position to the new one. + for (var j = bestMatchIndex; j < currentIndex; j++) + { + candidates.SetValidity(j, false); + } + // The quality defined for this value is higher. + bestEndpointQualitySoFar = endpointQuality; + bestMatchIndex = currentIndex; + } + else + { + candidates.SetValidity(currentIndex, false); + } + } + } + + // Explainer: + // This is responsible for building the branches in the DFA that will be used to match a + // concrete endpoint based on the Accept-Encoding header of the request. + // To give you an idea lets explain this through a sample. + // Say we have the following endpoints: + // 1 - Resource.css - [ Accept-Encoding: gzip ] + // 2 - Resource.css - [] + // 3 - Resource.css - [ Accept-Encoding: br ] + // 4 - {**catchall} - [] + // We need to build a tree that looks like this: + // root -> gzip -> [ Resource.css (1), Resource.css (2), CatchAll (4) ] + // -> br -> [ Resource.css (3), Resource.css (2), CatchAll (4) ] + // -> identity -> [ Resource.css (2), CatchAll (4) ] + // -> *, "" -> [ Resource.css (2), CatchAll (4) ] + // The explanation is as follows: + // * Each node in the tree must have a key, and the list of endpoints that can be matched if that key is selected. + // * For example, if the Accept-Encoding header is "gzip" then we should select the gzip node, then the gzip endpoint, the "identity" endpoint and the catchall endpoint + // are the nodes that are still candidates. This is because a policy later on might invalidate the gzip endpoint (and the algorithm never backtracks). + // * If we get to the bottom of the tree, then the priority rules get to apply. Endpoints with the metadata will be preferred over those without it, and in case + // both of them have the metadata, the quality of the metadata will be used to break the tie. + // * Note that the priority of the route applies first, that is, in the last case, for a request to Resource.css with no Accept-Encoding header, the Resource.css (2) + // endpoint will still be selected over the catchall endpoint. + public IReadOnlyList GetEdges(IReadOnlyList endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + // The algorithm here is designed to be preserve the order of the endpoints + // while also being relatively simple. Preserving order is important. + + // First, build a dictionary of all of the content-type patterns that are included + // at this node. + // + // For now we're just building up the set of keys. We don't add any endpoints + // to lists now because we don't want ordering problems. + var edges = new Dictionary>(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + var metadata = GetMetadataValue(endpoint) ?? DefaultNegotiationValue; + if (!edges.TryGetValue(metadata, out var endpointsForType)) + { + edges.Add(metadata, []); + } + } + + // Now in a second loop, add endpoints to these lists. + // We've enumerated all of the states, so we want to see which states each endpoint matches. + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + var metadata = GetMetadataValue(endpoint) ?? DefaultNegotiationValue; + if (string.Equals(metadata, DefaultNegotiationValue, StringComparison.OrdinalIgnoreCase)) + { + // This means that this endpoint does not specify a negotiation value, a default of identity is assumed. + // Which means that this endpoint is always a candidate. + foreach (var edge in edges) + { + edge.Value.Add(endpoint); + } + } + else + { + var endpointsForType = edges[metadata]; + endpointsForType.Add(endpoint); + } + } + + // If after we're done there isn't any endpoint that accepts the default encoding, then we'll synthesize an + // endpoint that always returns a 406. + if (!edges.TryGetValue(DefaultNegotiationValue, out var anyEndpoints)) + { + anyEndpoints = [CreateRejectionEndpoint()]; + edges.Add(DefaultNegotiationValue, anyEndpoints); + + // Add a node to use when there is no negotiation header. + edges.Add(string.Empty, anyEndpoints); + } + else + { + // If there is an endpoint that accepts an then it is also used when there is no content type + edges.Add(string.Empty, anyEndpoints); + } + + var result = new PolicyNodeEdge[edges.Count]; + var index = 0; + foreach (var kvp in edges) + { + result[index] = new PolicyNodeEdge( + // Metadata quality is 0 for the edges that don't have metadata as we prefer serving from the endpoints that have metadata + new NegotiationEdgeKey(kvp.Key, kvp.Value.Select(e => GetMetadataQuality(e) ?? 0).ToArray()), + kvp.Value); + index++; + } + + return result; + } + + internal class NegotiationEdgeKey + { + public NegotiationEdgeKey(string negotiationValue, double[] endpointsQuality) + { + NegotiationValue = negotiationValue; + EndpointsQuality = endpointsQuality; + Array.Sort(EndpointsQuality); + } + + public string NegotiationValue { get; } + public double[] EndpointsQuality { get; } + } + + private static Endpoint CreateRejectionEndpoint() => + Http406Endpoint ??= new Endpoint( + context => + { + context.Response.StatusCode = StatusCodes.Status406NotAcceptable; + return Task.CompletedTask; + }, + EndpointMetadataCollection.Empty, + Http406EndpointDisplayName); + + PolicyJumpTable INodeBuilderPolicy.BuildJumpTable(int exitDestination, IReadOnlyList edges) + { + ArgumentNullException.ThrowIfNull(edges); + + var destinations = new (string negotiationValue, double quality, int destination)[edges.Count]; + for (var i = 0; i < edges.Count; i++) + { + var e = edges[i]; + var key = (NegotiationEdgeKey)e.State; + destinations[i] = (negotiationValue: key.NegotiationValue, quality: key.EndpointsQuality.Max(), destination: e.Destination); + } + + // If any edge matches all negotiation values, then treat that as the 'exit'. This will + // always happen because we insert a 406 endpoint. + var noNegotiationHeaderDestination = -1; + for (var i = 0; i < destinations.Length; i++) + { + if (destinations[i].negotiationValue == DefaultNegotiationValue) + { + exitDestination = destinations[i].destination; + } + if (destinations[i].negotiationValue == "") + { + noNegotiationHeaderDestination = destinations[i].destination; + } + } + + return CreateTable(exitDestination, destinations, noNegotiationHeaderDestination); + } + + private protected abstract NegotiationPolicyJumpTable CreateTable(int exitDestination, (string negotiationValue, double quality, int destination)[] destinations, int noNegotiationHeaderDestination); + + private sealed class NegotiationMetadataEndpointComparer : EndpointMetadataComparer + { + protected override int CompareMetadata(TNegotiateMetadata? x, TNegotiateMetadata? y) => + (x, y) switch + { + (not null, not null) => (1 - x.Quality).CompareTo(1 - y.Quality), + _ => base.CompareMetadata(x, y) + }; + } + + internal abstract class NegotiationPolicyJumpTable : PolicyJumpTable + { + private readonly int _defaultNegotiationValueDestination; + private readonly int _noNegotiationValueDestination; + private readonly string _negotiationHeader; + + public NegotiationPolicyJumpTable(string negotiationHeader, int anyNegotiationValueDestination, int noNegotiationValueDestination) + { + _defaultNegotiationValueDestination = anyNegotiationValueDestination; + _noNegotiationValueDestination = noNegotiationValueDestination; + _negotiationHeader = negotiationHeader; + } + + public override int GetDestination(HttpContext httpContext) + { + var header = httpContext.Request.Headers[_negotiationHeader]; + if (StringValues.IsNullOrEmpty(header) || + !StringWithQualityHeaderValue.TryParseList(header, out var values) || values.Count == 0) + { + return _noNegotiationValueDestination; + } + + var currentQuality = 0.0d; + string? currentSelectedValue = null; + var selectedDestination = _defaultNegotiationValueDestination; + + // Iterate over the list of values to find the best match. First we use the quality on the header value. + // If that quality is equal to the current quality, we use the server quality to break the tie. + for (var i = 0; i < values.Count; i++) + { + var value = values[i]; + var valueQuality = value.Quality ?? 1.0; + if (valueQuality == 0) + { + // 0 means that the client doesn't want this representation. + continue; + } + if (valueQuality < currentQuality) + { + // We've already found a better match. + continue; + } + + var valueToken = value.Value.Value ?? null; + if (valueQuality > currentQuality) + { + var newDestination = GetDestination(valueToken); + if (newDestination != -1) + { + currentSelectedValue = valueToken; + selectedDestination = newDestination; + currentQuality = valueQuality; + continue; + } + } + + if (valueQuality == currentQuality) + { + var currentServerQuality = GetQuality(currentSelectedValue); + var newServerQuality = GetQuality(valueToken); + if (newServerQuality > currentServerQuality) + { + var newDestination = GetDestination(valueToken); + if (newDestination != -1) + { + currentSelectedValue = valueToken; + selectedDestination = newDestination; + currentQuality = valueQuality; + continue; + } + } + } + } + + return selectedDestination; + } + + protected abstract int GetDestination(string? value); + + protected abstract double GetQuality(string? value); + } +} diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 3466658aa78d..1d1131ea95bf 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -1,2 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Routing.ContentEncodingMetadata +Microsoft.AspNetCore.Routing.ContentEncodingMetadata.ContentEncodingMetadata(string! value, double quality) -> void +Microsoft.AspNetCore.Routing.ContentEncodingMetadata.Quality.get -> double +Microsoft.AspNetCore.Routing.ContentEncodingMetadata.Value.get -> string! static Microsoft.AspNetCore.Routing.RouteHandlerServices.Map(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! handler, System.Collections.Generic.IEnumerable? httpMethods, System.Func! populateMetadata, System.Func! createRequestDelegate, System.Reflection.MethodInfo! methodInfo) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! diff --git a/src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs b/src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs new file mode 100644 index 000000000000..9b9954343ebb --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs @@ -0,0 +1,648 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing.Matching; + +// Scenarios: +// - Two endpoints, one no encoding metadata, other encoding metadata, accept header includes encoding metadata -> result endpoint with encoding metadata +// - Two endpoints, one no encoding metadata, other encoding metadata, accept header does not include encoding metadata -> result endpoint without encoding metadata +// - Two endpoints, both with encoding metadata, accept header includes encoding metadata with +// different quality -> result endpoint with encoding metadata with higher accept quality +// - Two endpoints, both with encoding metadata, accept header includes encoding metadata with same quality -> result endpoint with encoding metadata with higher metadata +// quality. +public class ContentEncodingNegotiationMatcherPolicyTest +{ + [Fact] + public void AppliesToEndpoints_ReturnsTrue_IfAnyEndpointHasContentEncodingMetadata() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as IEndpointSelectorPolicy; + var endpoints = new[] + { + CreateEndpoint("gzip"), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding"), + }; + + // Act + var result = policy.AppliesToEndpoints(endpoints); + + // Assert + Assert.True(result); + } + + [Fact] + public void AppliesToEndpoints_ReturnsFalse_IfNoEndpointHasContentEncodingMetadata() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as IEndpointSelectorPolicy; + var endpoints = new[] + { + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding"), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding"), + }; + + // Act + var result = policy.AppliesToEndpoints(endpoints); + + // Assert + Assert.False(result); + } + + [Fact] + public void AppliesToEndpoints_ReturnsTrue_IfAnyEndpointHasDynamicEndpointMetadata() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as IEndpointSelectorPolicy; + var endpoints = new[] + { + CreateEndpoint("gzip"), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(new DynamicMetadata()), "Endpoint -> Dynamic Endpoint Metadata"), + }; + + // Act + var result = policy.AppliesToEndpoints(endpoints); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task ApplyAsync_SelectsEndpointWithContentEncodingMetadata_IfAcceptHeaderIncludesEncodingMetadata() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip"), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip, br"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.True(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + Assert.Null(httpContext.GetEndpoint()); + } + + [Fact] + public async Task ApplyAsync_SelectsEndpointWihtoutEncodingMetadata_IfAcceptHeaderDoesNotIncludeEncodingMetadata() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip"), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "br"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.False(endpoints.IsValidCandidate(0)); + Assert.True(endpoints.IsValidCandidate(1)); + Assert.Null(httpContext.GetEndpoint()); + } + + [Fact] + public async Task ApplyAsync_SelectsEndpointWihtoutEncodingMetadata_IfAcceptHeaderDoesNotIncludeEncodingMetadata_ReverseCandidateOrder() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding"), + CreateEndpoint("gzip")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "br"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.True(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + Assert.Null(httpContext.GetEndpoint()); + } + + [Fact] + public async Task ApplyAsync_SelectsEndpointWithHigherAcceptEncodingQuality_IfHeaderIncludesMultipleEncodingsWithQualityValues() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip", 1.0d), + CreateEndpoint("br", 0.5d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip;q=0.5, br;q=1.0"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.False(endpoints.IsValidCandidate(0)); + Assert.True(endpoints.IsValidCandidate(1)); + Assert.Null(httpContext.GetEndpoint()); + } + + [Fact] + public async Task ApplyAsync_SelectsEndpointWithHigherAcceptEncodingQuality_IfHeaderIncludesMultipleEncodingsWithQualityValues_ReverseCandidateOrder() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("br", 0.5d), + CreateEndpoint("gzip", 1.0d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip;q=0.5, br;q=1.0"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.False(endpoints.IsValidCandidate(1)); + Assert.True(endpoints.IsValidCandidate(0)); + Assert.Null(httpContext.GetEndpoint()); + } + + [Fact] + public async Task ApplyAsync_SelectsEndpointWithHigherContentEncodingMetadataQuality_IfAcceptEncodingQualityIsEqual() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip", 1.0d), + CreateEndpoint("br", 0.5d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip, br"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.True(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + } + + [Fact] + public async Task ApplyAsync_SetsEndpointIfNoResourceCanSupportTheAcceptHeaderValues() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip", 1.0d), + CreateEndpoint("br", 0.5d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "zstd"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.False(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + Assert.NotNull(httpContext.GetEndpoint()); + } + + [Fact] + public async Task ApplyAsync_DoesNotSetEndpointIfNoEndpointCanSupportTheAcceptHeaderValues_ButAnEndpointWithoutMetadataExists() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip", 1.0d), + CreateEndpoint("br", 0.5d), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "zstd"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.False(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + Assert.True(endpoints.IsValidCandidate(2)); + Assert.Null(httpContext.GetEndpoint()); + } + + [Fact] + public async Task ApplyAsync_SelectsFirstValidEndpointWhenContentEncodingMetadataQualityIsTheSame_IfAcceptEncodingQualityIsEqual() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip", 1.0d), + CreateEndpoint("br", 1.0d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip, br"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.True(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + } + + [Fact] + public async Task ApplyAsync_SelectsFirstValidEndpointWhenContentEncodingMetadataQualityIsTheSame_IfAcceptEncodingQualityIsEqual_Reverse() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip", 1.0d), + CreateEndpoint("br", 1.0d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "br, gzip"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.True(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + } + + [Fact] + public async Task ApplyAsync_SetsAllCandidatesToInvalid_IfNoCandidateMatchesAcceptEncoding() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + CreateEndpoint("gzip"), + CreateEndpoint("br")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "identity"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.False(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + } + + [Fact] + public async Task ApplyAsync_SetsEndpointsWithEncodingMetadataToInvalid_IfRequestAsksForIdentity() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Identity"), + CreateEndpoint("br")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "identity"; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.True(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + } + + [Fact] + public async Task ApplyAsync_SetsEndpointsWithEncodingMetadataToInvalid_IfRequestHasEmptyAcceptEncodingHeader() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Identity"), + CreateEndpoint("br")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = ""; + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.True(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + } + + [Fact] + public async Task ApplyAsync_SetsEndpointsWithEncodingMetadataToInvalid_IfRequestHasNoAcceptEncodingHeader() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = CreateCandidateSet( + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Identity"), + CreateEndpoint("br")); + var httpContext = new DefaultHttpContext(); + + // Act + await policy.ApplyAsync(httpContext, endpoints); + + // Assert + Assert.True(endpoints.IsValidCandidate(0)); + Assert.False(endpoints.IsValidCandidate(1)); + } + + [Fact] + public void GetEdges_CreatesEdgePerContentEncoding() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = new[] + { + CreateEndpoint("gzip"), + CreateEndpoint("br"), + }; + + // Act + var edges = policy.GetEdges(endpoints); + + // Assert + Assert.Collection(edges, + e => Assert.Equal("gzip", Assert.IsType.NegotiationEdgeKey>(e.State).NegotiationValue), + e => Assert.Equal("br", Assert.IsType.NegotiationEdgeKey>(e.State).NegotiationValue), + e => + { + Assert.Equal("identity", Assert.IsType.NegotiationEdgeKey>(e.State).NegotiationValue); + var endpoint = Assert.Single(e.Endpoints); + Assert.Equal("406 HTTP Unsupported Encoding", endpoint.DisplayName); + }, + e => + { + Assert.Equal("", Assert.IsType.NegotiationEdgeKey>(e.State).NegotiationValue); + var endpoint = Assert.Single(e.Endpoints); + Assert.Equal("406 HTTP Unsupported Encoding", endpoint.DisplayName); + }); + } + + [Fact] + public void GetEdges_CreatesEdgePerContentEncoding_AndEdgeForAnyEncoding() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy(); + var endpoints = new[] + { + CreateEndpoint("gzip"), + CreateEndpoint("br"), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Identity"), + }; + + // Act + var result = policy.GetEdges(endpoints); + + // Assert + Assert.Collection(result, + e => + { + Assert.Equal("gzip", Assert.IsType.NegotiationEdgeKey>(e.State).NegotiationValue); + Assert.Collection(e.Endpoints, + e => Assert.Equal("Endpoint -> gzip: 1", e.DisplayName), + e => Assert.Equal("Identity", e.DisplayName)); + }, + e => + { + Assert.Equal("br", Assert.IsType.NegotiationEdgeKey>(e.State).NegotiationValue); + Assert.Collection(e.Endpoints, + e => Assert.Equal("Endpoint -> br: 1", e.DisplayName), + e => Assert.Equal("Identity", e.DisplayName)); + }, + e => + { + Assert.Equal("identity", Assert.IsType.NegotiationEdgeKey>(e.State).NegotiationValue); + var endpoint = Assert.Single(e.Endpoints); + Assert.Equal("Identity", endpoint.DisplayName); + }, + e => + { + Assert.Equal("", Assert.IsType.NegotiationEdgeKey>(e.State).NegotiationValue); + var endpoint = Assert.Single(e.Endpoints); + Assert.Equal("Identity", endpoint.DisplayName); + }); + } + + [Fact] + public void BuildJumpTable_CreatesJumpTablePerContentEncoding() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var edges = new PolicyJumpTableEdge[] + { + new(new NegotiationMatcherPolicy.NegotiationEdgeKey("gzip", [0.5, 0.7]),1), + new(new NegotiationMatcherPolicy.NegotiationEdgeKey("br", [0.8, 0.9]),2), + new(new NegotiationMatcherPolicy.NegotiationEdgeKey("identity", [0, 0]),3), + new(new NegotiationMatcherPolicy.NegotiationEdgeKey("", [0]),4), + }; + + // Act + var result = policy.BuildJumpTable(-100, edges); + } + + [Fact] + public void GetDestination_SelectsEndpointWithContentEncodingMetadata_IfAcceptHeaderIncludesEncodingMetadata() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + CreateEndpoint("gzip"), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding")); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip, br"; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(1, destination); + } + + [Fact] + public void GetDestination_SelectsEndpointWihtoutEncodingMetadata_IfAcceptHeaderDoesNotIncludeEncodingMetadata() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + CreateEndpoint("gzip"), + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "br"; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(2, destination); + } + + [Fact] + public void GetDestination_SelectsEndpointWihtoutEncodingMetadata_IfAcceptHeaderDoesNotIncludeEncodingMetadata_ReverseCandidateOrder() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Endpoint -> No Content Encoding"), + CreateEndpoint("gzip")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "br"; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(2, destination); + } + + [Fact] + public void GetDestination_SelectsEndpointWithHigherAcceptEncodingQuality_IfHeaderIncludesMultipleEncodingsWithQualityValues() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + CreateEndpoint("gzip", 1.0d), + CreateEndpoint("br", 0.5d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip;q=0.5, br;q=1.0"; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(2, destination); + } + + [Fact] + public void GetDestination_SelectsEndpointWithHigherAcceptEncodingQuality_IfHeaderIncludesMultipleEncodingsWithQualityValues_ReverseCandidateOrder() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + CreateEndpoint("br", 0.5d), + CreateEndpoint("gzip", 1.0d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip;q=0.5, br;q=1.0"; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(2, destination); + } + + [Fact] + public void GetDestination_SelectsEndpointWithHigherContentEncodingMetadataQuality_IfAcceptEncodingQualityIsEqual() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + CreateEndpoint("gzip", 1.0d), + CreateEndpoint("br", 0.5d)); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "gzip, br"; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(1, destination); + } + + [Fact] + public void GetDestination_SetsAllCandidatesToInvalid_IfNoCandidateMatchesAcceptEncoding() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + CreateEndpoint("gzip"), + CreateEndpoint("br")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "identity"; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(3, destination); + } + + [Fact] + public void GetDestination_SetsEndpointsWithEncodingMetadataToInvalid_IfRequestAsksForIdentity() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Identity"), + CreateEndpoint("br")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = "identity"; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(2, destination); + } + + [Fact] + public void GetDestination_SetsEndpointsWithEncodingMetadataToInvalid_IfRequestHasEmptyAcceptEncodingHeader() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Identity"), + CreateEndpoint("br")); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Accept-Encoding"] = ""; + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(3, destination); + } + + [Fact] + public void GetDestination_SetsEndpointsWithEncodingMetadataToInvalid_IfRequestHasNoAcceptEncodingHeader() + { + // Arrange + var policy = new ContentEncodingNegotiationMatcherPolicy() as INodeBuilderPolicy; + var endpoints = CreateJumpTable(policy, + new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(), "Identity"), + CreateEndpoint("br")); + var httpContext = new DefaultHttpContext(); + + // Act + var destination = endpoints.GetDestination(httpContext); + + // Assert + Assert.Equal(3, destination); + } + + private static ContentEncodingNegotiationMatcherPolicy.NegotiationPolicyJumpTable CreateJumpTable(INodeBuilderPolicy policy, params Endpoint[] endpoints) + { + // We are given the endpoints sorted by precedence, so sort them here for the test. + Array.Sort(endpoints, (policy as IEndpointComparerPolicy).Comparer); + + var edges = policy.GetEdges(endpoints); + var table = policy.BuildJumpTable(-100, edges.Select((e, i) => new PolicyJumpTableEdge(e.State, i + 1)).ToArray()); + return (ContentEncodingNegotiationMatcherPolicy.NegotiationPolicyJumpTable)table; + } + + private static CandidateSet CreateCandidateSet(params Endpoint[] endpoints) => new( + endpoints, + endpoints.Select(e => new RouteValueDictionary()).ToArray(), + endpoints.Select(e => 1).ToArray()); + + private static Endpoint CreateEndpoint(string contentEncoding, double quality = 1.0d) + { + var endpoint = new Endpoint( + _ => Task.CompletedTask, + new EndpointMetadataCollection(new ContentEncodingMetadata(contentEncoding, quality)), + $"Endpoint -> {contentEncoding}: {quality}"); + + return endpoint; + } + + private class DynamicMetadata : IDynamicEndpointMetadata + { + public bool IsDynamic => true; + } +} diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index c3491f03c19e..3bc34d97b99f 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -1,3 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Mvc; diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs index 9ce0e2c541b9..fdd657fda6b0 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs @@ -108,9 +108,9 @@ public static void Main(string[] args) app.UseHttpsRedirection(); #endif - app.UseStaticFiles(); app.UseAntiforgery(); + app.MapStaticAssets(); #if (UseServer && UseWebAssembly) app.MapRazorComponents() .AddInteractiveServerRenderMode() diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs index d65c123d1f21..f655b04cb591 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs @@ -102,9 +102,10 @@ app.UseHttpsRedirection(); #endif -app.UseStaticFiles(); + app.UseAntiforgery(); +app.MapStaticAssets(); #if (UseServer && UseWebAssembly) app.MapRazorComponents() .AddInteractiveServerRenderMode() diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.Main.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.Main.cs index bce4b6039495..3d1056838dc8 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.Main.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.Main.cs @@ -136,12 +136,12 @@ public static void Main(string[] args) #else } #endif - app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); + app.MapStaticAssets(); app.MapRazorPages(); #if (IndividualB2CAuth || OrganizationalAuth) app.MapControllers(); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.cs index 948286a94361..289247647714 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.cs @@ -130,12 +130,12 @@ #else } #endif -app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); +app.MapStaticAssets(); app.MapRazorPages(); #if (IndividualB2CAuth || OrganizationalAuth) app.MapControllers(); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.Main.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.Main.cs index f7d20fa0f2ee..b386e6f6840c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.Main.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.Main.cs @@ -138,12 +138,11 @@ public static void Main(string[] args) #else } #endif - app.UseStaticFiles(); - app.UseRouting(); app.UseAuthorization(); + app.MapStaticAssets(); app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.cs index 46a9fd064067..f8425822e284 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.cs @@ -132,12 +132,12 @@ #else } #endif -app.UseStaticFiles(); - app.UseRouting(); app.UseAuthorization(); +app.MapStaticAssets(); + app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); diff --git a/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs b/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs new file mode 100644 index 000000000000..0863e08ce436 --- /dev/null +++ b/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.AspNetCore.StaticAssets; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Builder; + +internal static class StaticAssetDescriptorExtensions +{ + internal static long GetContentLength(this StaticAssetDescriptor descriptor) + { + foreach (var header in descriptor.ResponseHeaders) + { + if (string.Equals(header.Name, HeaderNames.ContentLength, StringComparison.OrdinalIgnoreCase)) + { + return long.Parse(header.Value, CultureInfo.InvariantCulture); + } + } + + throw new InvalidOperationException("Content-Length header not found."); + } + + internal static DateTimeOffset GetLastModified(this StaticAssetDescriptor descriptor) + { + foreach (var header in descriptor.ResponseHeaders) + { + if (string.Equals(header.Name, HeaderNames.LastModified, StringComparison.OrdinalIgnoreCase)) + { + return DateTimeOffset.Parse(header.Value, CultureInfo.InvariantCulture); + } + } + + throw new InvalidOperationException("Last-Modified header not found."); + } + + internal static EntityTagHeaderValue GetWeakETag(this StaticAssetDescriptor descriptor) + { + foreach (var header in descriptor.ResponseHeaders) + { + if (string.Equals(header.Name, HeaderNames.ETag, StringComparison.OrdinalIgnoreCase)) + { + var eTag = EntityTagHeaderValue.Parse(header.Value); + if (eTag.IsWeak) + { + return eTag; + } + } + } + + throw new InvalidOperationException("ETag header not found."); + } + + internal static bool HasContentEncoding(this StaticAssetDescriptor descriptor) + { + foreach (var selector in descriptor.Selectors) + { + if (string.Equals(selector.Name, HeaderNames.ContentEncoding, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + internal static bool HasETag(this StaticAssetDescriptor descriptor, string tag) + { + foreach (var header in descriptor.ResponseHeaders) + { + if (string.Equals(header.Name, HeaderNames.ETag, StringComparison.OrdinalIgnoreCase)) + { + var eTag = EntityTagHeaderValue.Parse(header.Value); + if (!eTag.IsWeak && eTag.Tag == tag) + { + return true; + } + } + } + + return false; + } +} diff --git a/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs b/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs new file mode 100644 index 000000000000..95d59b6437e3 --- /dev/null +++ b/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs @@ -0,0 +1,253 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.IO.Compression; +using System.IO.Pipelines; +using System.Linq; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.StaticAssets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Builder; + +// Handles changes during development to support common scenarios where for example, a developer changes a file in the wwwroot folder. +internal sealed partial class StaticAssetDevelopmentRuntimeHandler(List descriptors) +{ + internal const string ReloadStaticAssetsAtRuntimeKey = "ReloadStaticAssetsAtRuntime"; + + public void AttachRuntimePatching(EndpointBuilder builder) + { + var original = builder.RequestDelegate!; + var asset = builder.Metadata.OfType().Single(); + if (asset.HasContentEncoding()) + { + // This is a compressed asset, which might get out of "sync" with the original uncompressed version. + // We are going to find the original by using the weak etag from this compressed asset and locating an asset with the same etag. + var eTag = asset.GetWeakETag(); + asset = FindOriginalAsset(eTag.Tag.Value!, descriptors); + } + + builder.RequestDelegate = async context => + { + var originalFeature = context.Features.GetRequiredFeature(); + var fileInfo = context.RequestServices.GetRequiredService().WebRootFileProvider.GetFileInfo(asset.AssetFile); + if (fileInfo.Length != asset.GetContentLength() || fileInfo.LastModified != asset.GetLastModified()) + { + // At this point, we know that the file has changed from what was generated at build time. + // This is for example, when someone changes something in the WWWRoot folder. + + // In case we were dealing with a compressed asset, we are going to wrap the response body feature to re-compress the asset on the fly. + // and write that to the response instead. + context.Features.Set(new RuntimeStaticAssetResponseBodyFeature(originalFeature, context, asset)); + } + + await original(context); + context.Features.Set(originalFeature); + }; + } + + internal static string GetETag(IFileInfo fileInfo) + { + using var stream = fileInfo.CreateReadStream(); + return $"\"{Convert.ToBase64String(SHA256.HashData(stream))}\""; + } + + internal sealed class RuntimeStaticAssetResponseBodyFeature : IHttpResponseBodyFeature + { + private readonly IHttpResponseBodyFeature _original; + private readonly HttpContext _context; + private readonly StaticAssetDescriptor _asset; + + public RuntimeStaticAssetResponseBodyFeature(IHttpResponseBodyFeature original, HttpContext context, StaticAssetDescriptor asset) + { + _original = original; + _context = context; + _asset = asset; + } + + public Stream Stream => _original.Stream; + + public PipeWriter Writer => _original.Writer; + + public Task CompleteAsync() + { + return _original.CompleteAsync(); + } + + public void DisableBuffering() + { + _original.DisableBuffering(); + } + + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) + { + var fileInfo = _context.RequestServices.GetRequiredService().WebRootFileProvider.GetFileInfo(_asset.AssetFile); + var endpoint = _context.GetEndpoint()!; + var assetDescriptor = endpoint.Metadata.OfType().Single(); + _context.Response.Headers.ETag = ""; + + if (assetDescriptor.AssetFile != _asset.AssetFile) + { + // This was a compressed asset, asset contains the path to the original file, we'll re-compress the asset on the fly and replace the body + // and the content length. + using var stream = new MemoryStream(); + using (var fileStream = fileInfo.CreateReadStream()) + { + using var gzipStream = new GZipStream(stream, CompressionLevel.NoCompression, leaveOpen: true); + fileStream.CopyTo(gzipStream); + gzipStream.Flush(); + } + stream.Flush(); + stream.Seek(0, SeekOrigin.Begin); + + _context.Response.Headers.ContentLength = stream.Length; + + var eTag = Convert.ToBase64String(SHA256.HashData(stream)); + var weakETag = $"W/{GetETag(fileInfo)}"; + + // Here we add the ETag for the Gzip stream as well as the weak ETag for the original asset. + _context.Response.Headers.ETag = new StringValues([$"\"{eTag}\"", weakETag]); + + stream.Seek(0, SeekOrigin.Begin); + return stream.CopyToAsync(_context.Response.Body, cancellationToken); + } + else + { + // Clear all the ETag headers, as they'll be replaced with the new ones. + _context.Response.Headers.ETag = ""; + // Compute the new ETag, if this is a compressed asset, RuntimeStaticAssetResponseBodyFeature will update it. + _context.Response.Headers.ETag = GetETag(fileInfo); + _context.Response.Headers.ContentLength = fileInfo.Length; + _context.Response.Headers.LastModified = fileInfo.LastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture); + + // Send the modified asset as is. + return _original.SendFileAsync(fileInfo.PhysicalPath!, 0, fileInfo.Length, cancellationToken); + } + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } + + private static StaticAssetDescriptor FindOriginalAsset(string tag, List descriptors) + { + for (var i = 0; i < descriptors.Count; i++) + { + if (descriptors[i].HasETag(tag)) + { + return descriptors[i]; + } + } + + throw new InvalidOperationException("The original asset was not found."); + } + + internal static bool IsEnabled(IServiceProvider serviceProvider, IWebHostEnvironment environment) + { + var config = serviceProvider.GetRequiredService(); + var explicitlyConfigured = bool.TryParse(config[ReloadStaticAssetsAtRuntimeKey], out var hotReload); + return (!explicitlyConfigured && environment.IsDevelopment()) || (explicitlyConfigured && hotReload); + } + + internal static void EnableSupport( + IEndpointRouteBuilder endpoints, + StaticAssetsEndpointConventionBuilder builder, + IWebHostEnvironment environment, + List descriptors) + { + var config = endpoints.ServiceProvider.GetRequiredService(); + var hotReloadHandler = new StaticAssetDevelopmentRuntimeHandler(descriptors); + builder.Add(hotReloadHandler.AttachRuntimePatching); + var disableFallback = bool.TryParse(config["DisableStaticAssetNotFoundRuntimeFallback"], out var disableFallbackValue) && disableFallbackValue; + + if (!disableFallback) + { + var logger = endpoints.ServiceProvider.GetRequiredService>(); + + // Add a fallback static file handler to serve any file that might have been added after the initial startup. + var fallback = endpoints.MapFallback( + "{**path:file}", + endpoints.CreateApplicationBuilder() + .Use((ctx, nxt) => + { + Log.StaticAssetNotFoundInManifest(logger, ctx.Request.Path); + + ctx.SetEndpoint(null); + ctx.Response.OnStarting((context) => + { + var ctx = (HttpContext)context; + if (ctx.Response.StatusCode == StatusCodes.Status200OK) + { + var fileInfo = environment.WebRootFileProvider.GetFileInfo(ctx.Request.Path); + if (fileInfo.Exists) + { + // Apply the ETag header to the response. + ctx.Response.GetTypedHeaders().ETag = new EntityTagHeaderValue(GetETag(fileInfo)); + } + } + return Task.CompletedTask; + }, ctx); + return nxt(); + }) + .UseStaticFiles() + .Build()); + + // Set up a custom constraint to only match existing files. + fallback + .Add(endpoint => + { + if (endpoint is not RouteEndpointBuilder routeEndpoint || routeEndpoint is not { RoutePattern.RawText: { } pattern }) + { + return; + } + + // Add a custom constraint (not inline) to check if the file exists as part of the route matching + routeEndpoint.RoutePattern = RoutePatternFactory.Parse( + pattern, + null, + new RouteValueDictionary { ["path"] = new FileExistsConstraint(environment) }); + }); + + // Limit matching to supported methods. + fallback.Add(b => b.Metadata.Add(new HttpMethodMetadata(["GET", "HEAD"]))); + } + } + + private static partial class Log + { + private const string StaticAssetNotFoundInManifestMessage = """The static asset '{Path}' was not found in the built time manifest. This file will not be available at runtime if it is not available at compile time during the publish process. If the file was not added to the project during development, and is created at runtime, use the StaticFiles middleware to serve it instead."""; + + [LoggerMessage(1, LogLevel.Warning, StaticAssetNotFoundInManifestMessage)] + public static partial void StaticAssetNotFoundInManifest(ILogger logger, string path); + } + + private sealed class FileExistsConstraint(IWebHostEnvironment environment) : IRouteConstraint + { + private readonly IWebHostEnvironment _environment = environment; + + public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + { + if (values[routeKey] is not string path) + { + return false; + } + + var fileInfo = _environment.WebRootFileProvider.GetFileInfo(path); + return fileInfo.Exists; + } + } +} diff --git a/src/StaticAssets/src/EndpointProperty.cs b/src/StaticAssets/src/EndpointProperty.cs new file mode 100644 index 000000000000..944dcd61ca6c --- /dev/null +++ b/src/StaticAssets/src/EndpointProperty.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.StaticAssets; + +// Represents a property of an endpoint. +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +internal sealed class EndpointProperty(string name, string value) +{ + public string Name { get; } = name; + public string Value { get; } = value; + + private string GetDebuggerDisplay() => $"Name: {Name} Value:{Value}"; +} diff --git a/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs b/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs new file mode 100644 index 000000000000..a89d556ac496 --- /dev/null +++ b/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.StaticAssets.Infrastructure; + +/// +/// For internal framework use only. +/// +public static class StaticAssetsEndpointDataSourceHelper +{ + /// + /// For internal framework use only. + /// + public static bool IsStaticAssetsDataSource(EndpointDataSource dataSource, string? staticAssetsManifestPath = null) + { + if (dataSource is StaticAssetsEndpointDataSource staticAssetsDataSource) + { + if (staticAssetsManifestPath is null) + { + var serviceProvider = staticAssetsDataSource.ServiceProvider; + var environment = serviceProvider.GetRequiredService(); + staticAssetsManifestPath = Path.Combine(AppContext.BaseDirectory, $"{environment.ApplicationName}.staticwebassets.endpoints.json"); + } + + staticAssetsManifestPath = Path.IsPathRooted(staticAssetsManifestPath) ? staticAssetsManifestPath : Path.Combine(AppContext.BaseDirectory, staticAssetsManifestPath); + + return string.Equals(staticAssetsDataSource.ManifestPath, staticAssetsManifestPath, StringComparison.Ordinal); + } + + return false; + } +} diff --git a/src/StaticAssets/src/LoggerExtensions.cs b/src/StaticAssets/src/LoggerExtensions.cs new file mode 100644 index 000000000000..ec5a2f47a86a --- /dev/null +++ b/src/StaticAssets/src/LoggerExtensions.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.StaticAssets; + +internal static partial class LoggerExtensions +{ + [LoggerMessage(1, LogLevel.Debug, "{Method} requests are not supported", EventName = "MethodNotSupported")] + public static partial void RequestMethodNotSupported(this ILogger logger, string method); + + [LoggerMessage(2, LogLevel.Information, "Sending file. Request path: '{VirtualPath}'. Physical path: '{PhysicalPath}'", EventName = "FileServed")] + private static partial void FileServedCore(this ILogger logger, string virtualPath, string physicalPath); + + public static void FileServed(this ILogger logger, string virtualPath, string physicalPath) + { + if (string.IsNullOrEmpty(physicalPath)) + { + physicalPath = "N/A"; + } + FileServedCore(logger, virtualPath, physicalPath); + } + + [LoggerMessage(15, LogLevel.Debug, "Static files was skipped as the request already matched an endpoint.", EventName = "EndpointMatched")] + public static partial void EndpointMatched(this ILogger logger); + + [LoggerMessage(3, LogLevel.Debug, "The request path {Path} does not match the path filter", EventName = "PathMismatch")] + public static partial void PathMismatch(this ILogger logger, string path); + + [LoggerMessage(4, LogLevel.Debug, "The request path {Path} does not match a supported file type", EventName = "FileTypeNotSupported")] + public static partial void FileTypeNotSupported(this ILogger logger, string path); + + [LoggerMessage(5, LogLevel.Debug, "The request path {Path} does not match an existing file", EventName = "FileNotFound")] + public static partial void FileNotFound(this ILogger logger, string path); + + [LoggerMessage(6, LogLevel.Information, "The file {Path} was not modified", EventName = "FileNotModified")] + public static partial void FileNotModified(this ILogger logger, string path); + + [LoggerMessage(7, LogLevel.Information, "Precondition for {Path} failed", EventName = "PreconditionFailed")] + public static partial void PreconditionFailed(this ILogger logger, string path); + + [LoggerMessage(8, LogLevel.Debug, "Handled. Status code: {StatusCode} File: {Path}", EventName = "Handled")] + public static partial void Handled(this ILogger logger, int statusCode, string path); + + [LoggerMessage(9, LogLevel.Warning, "Range not satisfiable for {Path}", EventName = "RangeNotSatisfiable")] + public static partial void RangeNotSatisfiable(this ILogger logger, string path); + + [LoggerMessage(10, LogLevel.Information, "Sending {Range} of file {Path}", EventName = "SendingFileRange")] + public static partial void SendingFileRange(this ILogger logger, StringValues range, string path); + + [LoggerMessage(11, LogLevel.Debug, "Copying {Range} of file {Path} to the response body", EventName = "CopyingFileRange")] + public static partial void CopyingFileRange(this ILogger logger, StringValues range, string path); + + [LoggerMessage(14, LogLevel.Debug, "The file transmission was cancelled", EventName = "WriteCancelled")] + public static partial void WriteCancelled(this ILogger logger, Exception ex); + + [LoggerMessage(16, LogLevel.Warning, + "The WebRootPath was not found: {WebRootPath}. Static files may be unavailable.", EventName = "WebRootPathNotFound")] + public static partial void WebRootPathNotFound(this ILogger logger, string webRootPath); +} + diff --git a/src/StaticAssets/src/Microsoft.AspNetCore.StaticAssets.csproj b/src/StaticAssets/src/Microsoft.AspNetCore.StaticAssets.csproj new file mode 100644 index 000000000000..c385473e7147 --- /dev/null +++ b/src/StaticAssets/src/Microsoft.AspNetCore.StaticAssets.csproj @@ -0,0 +1,27 @@ + + + + ASP.NET Core static asset endpoints. Maps static assets in the wwwroot folder at build/publish time as endpoints. + $(DefaultNetCoreTargetFramework) + true + false + true + aspnetcore;staticassets + true + + + + + + + + + + + + + + + + + diff --git a/src/StaticAssets/src/PublicAPI.Shipped.txt b/src/StaticAssets/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..ab058de62d44 --- /dev/null +++ b/src/StaticAssets/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/StaticAssets/src/PublicAPI.Unshipped.txt b/src/StaticAssets/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..8cf8331aa2ad --- /dev/null +++ b/src/StaticAssets/src/PublicAPI.Unshipped.txt @@ -0,0 +1,7 @@ +Microsoft.AspNetCore.Builder.StaticAssetsEndpointRouteBuilderExtensions +Microsoft.AspNetCore.StaticAssets.Infrastructure.StaticAssetsEndpointDataSourceHelper +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder.Add(System.Action! convention) -> void +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder.Finally(System.Action! convention) -> void +static Microsoft.AspNetCore.Builder.StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssets(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string? staticAssetsManifestPath = null) -> Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder! +static Microsoft.AspNetCore.StaticAssets.Infrastructure.StaticAssetsEndpointDataSourceHelper.IsStaticAssetsDataSource(Microsoft.AspNetCore.Routing.EndpointDataSource! dataSource, string? staticAssetsManifestPath = null) -> bool diff --git a/src/StaticAssets/src/ResponseHeader.cs b/src/StaticAssets/src/ResponseHeader.cs new file mode 100644 index 000000000000..0c57a463331c --- /dev/null +++ b/src/StaticAssets/src/ResponseHeader.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.StaticAssets; + +// Represents a response header for a static resource. +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +internal sealed class ResponseHeader(string name, string value) +{ + public string Name { get; } = name; + public string Value { get; } = value; + + private string GetDebuggerDisplay() => $"Name: {Name} Value: {Value}"; +} diff --git a/src/StaticAssets/src/StaticAssetDescriptor.cs b/src/StaticAssets/src/StaticAssetDescriptor.cs new file mode 100644 index 000000000000..c34150117c82 --- /dev/null +++ b/src/StaticAssets/src/StaticAssetDescriptor.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.StaticAssets; + +// Represents a static resource. +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +internal sealed class StaticAssetDescriptor( + string route, + string assetFile, + StaticAssetSelector[] selectors, + EndpointProperty[] endpointProperties, + ResponseHeader[] responseHeaders) +{ + public string Route { get; } = route; + public string AssetFile { get; } = assetFile; + public StaticAssetSelector[] Selectors { get; } = selectors; + public EndpointProperty[] EndpointProperties { get; } = endpointProperties; + public ResponseHeader[] ResponseHeaders { get; } = responseHeaders; + + private string GetDebuggerDisplay() + { + return $"Route: {Route} Path: {AssetFile}"; + } +} diff --git a/src/StaticAssets/src/StaticAssetEndpointDataSource.cs b/src/StaticAssets/src/StaticAssetEndpointDataSource.cs new file mode 100644 index 000000000000..8c8968dc19ed --- /dev/null +++ b/src/StaticAssets/src/StaticAssetEndpointDataSource.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.StaticAssets; + +/// +/// An for static assets. +/// +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +internal class StaticAssetsEndpointDataSource : EndpointDataSource +{ + private readonly object _lock = new(); + private readonly StaticAssetsManifest _manifest; + private readonly StaticAssetEndpointFactory _endpointFactory; + private readonly List> _conventions = []; + private readonly List> _finallyConventions = []; + private List _endpoints = null!; + private CancellationTokenSource _cancellationTokenSource; + private CancellationChangeToken _changeToken; + + internal StaticAssetsEndpointDataSource(IServiceProvider serviceProvider, StaticAssetsManifest manifest, StaticAssetEndpointFactory endpointFactory, string manifestName, List descriptors) + { + ServiceProvider = serviceProvider; + _manifest = manifest; + ManifestPath = manifestName; + _endpointFactory = endpointFactory; + _cancellationTokenSource = new CancellationTokenSource(); + _changeToken = new CancellationChangeToken(_cancellationTokenSource.Token); + + DefaultBuilder = new StaticAssetsEndpointConventionBuilder( + _lock, + descriptors, + _conventions, + _finallyConventions); + } + + /// + /// Gets the manifest name associated with this static asset endpoint data source. + /// + public string ManifestPath { get; } + + /// + internal StaticAssetsEndpointConventionBuilder DefaultBuilder { get; set; } + + /// + public override IReadOnlyList Endpoints + { + get + { + // Note it is important that this is lazy, since we only want to create the endpoints after the user had a chance to populate + // the list of conventions. + // The order is as follows: + // * MapRazorComponents gets called and the data source gets created. + // * The RazorComponentEndpointConventionBuilder is returned and the user gets a chance to call on it to add conventions. + // * The first request arrives and the DfaMatcherBuilder accesses the data sources to get the endpoints. + // * The endpoints get created and the conventions get applied. + Initialize(); + Debug.Assert(_changeToken != null); + Debug.Assert(_endpoints != null); + return _endpoints; + } + } + + internal IServiceProvider ServiceProvider { get; } + + private void Initialize() + { + if (_endpoints == null) + { + lock (_lock) + { + if (_endpoints == null) + { + UpdateEndpoints(); + } + } + } + } + + private void UpdateEndpoints() + { + lock (_lock) + { + var endpoints = new List(); + + foreach (var resource in _manifest.Endpoints) + { + endpoints.Add(_endpointFactory.Create(resource, _conventions, _finallyConventions)); + } + + var oldCancellationTokenSource = _cancellationTokenSource; + _endpoints = endpoints; + _cancellationTokenSource = new CancellationTokenSource(); + _changeToken = new CancellationChangeToken(_cancellationTokenSource.Token); + oldCancellationTokenSource?.Cancel(); + oldCancellationTokenSource?.Dispose(); + } + } + + /// + public override IChangeToken GetChangeToken() + { + Initialize(); + Debug.Assert(_changeToken != null); + Debug.Assert(_endpoints != null); + return _changeToken; + } + + private string GetDebuggerDisplay() + { + return ManifestPath; + } +} diff --git a/src/StaticAssets/src/StaticAssetEndpointFactory.cs b/src/StaticAssets/src/StaticAssetEndpointFactory.cs new file mode 100644 index 000000000000..9340fe9cf33a --- /dev/null +++ b/src/StaticAssets/src/StaticAssetEndpointFactory.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.StaticAssets; + +internal class StaticAssetEndpointFactory(IServiceProvider serviceProvider) +{ + private static readonly HttpMethodMetadata _supportedMethods = new([HttpMethods.Get, HttpMethods.Head]); + + public Endpoint Create(StaticAssetDescriptor resource, List> conventions, List> finallyConventions) + { + var routeEndpointBuilder = new RouteEndpointBuilder( + null, + RoutePatternFactory.Parse(resource.Route), + // Static resources always take precedence over default routes to mimic the behavior of UseStaticFiles. + // We give a -100 order to ensure that they are selected under normal circumstances, but leave a small lee-way + // for the user to override this if they want to. + -100); + + foreach (var selector in resource.Selectors) + { + switch (selector.Name) + { + case "Content-Encoding": + routeEndpointBuilder.Metadata.Add(new ContentEncodingMetadata(selector.Value, double.Parse(selector.Quality, CultureInfo.InvariantCulture))); + break; + default: + break; + } + } + + var logger = serviceProvider.GetRequiredService>(); + var fileInfo = serviceProvider.GetRequiredService().WebRootFileProvider.GetFileInfo(resource.AssetFile) ?? + throw new InvalidOperationException($"The file '{resource.AssetFile}' could not be found."); + + var invoker = new StaticAssetsInvoker(resource, fileInfo, logger); + + routeEndpointBuilder.RequestDelegate = invoker.Invoke; + + routeEndpointBuilder.Metadata.Add(resource); + routeEndpointBuilder.Metadata.Add(_supportedMethods); + + foreach (var convention in conventions) + { + convention(routeEndpointBuilder); + } + + foreach (var finallyConvention in finallyConventions) + { + finallyConvention(routeEndpointBuilder); + } + + return routeEndpointBuilder.Build(); + } +} diff --git a/src/StaticAssets/src/StaticAssetSelector.cs b/src/StaticAssets/src/StaticAssetSelector.cs new file mode 100644 index 000000000000..e155e387fbf9 --- /dev/null +++ b/src/StaticAssets/src/StaticAssetSelector.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.StaticAssets; + +// Represents a selector for a static resource. +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +internal sealed class StaticAssetSelector(string name, string value, string quality) +{ + public string Name { get; } = name; + public string Value { get; } = value; + public string Quality { get; } = quality; + + private string GetDebuggerDisplay() => $"Name: {Name} Value: {Value} Quality: {Quality}"; +} diff --git a/src/StaticAssets/src/StaticAssetsEndpointConventionBuilder.cs b/src/StaticAssets/src/StaticAssetsEndpointConventionBuilder.cs new file mode 100644 index 000000000000..cd5275aff9db --- /dev/null +++ b/src/StaticAssets/src/StaticAssetsEndpointConventionBuilder.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; + +namespace Microsoft.AspNetCore.StaticAssets; + +/// +/// A builder for configuring conventions for static assets. +/// +public sealed class StaticAssetsEndpointConventionBuilder : IEndpointConventionBuilder +{ + private readonly object _lck; + private readonly List _descriptors; + private readonly List> _conventions; + private readonly List> _finallyConventions; + + internal StaticAssetsEndpointConventionBuilder(object lck, List descriptors, List> conventions, List> finallyConventions) + { + _lck = lck; + _descriptors = descriptors; + _conventions = conventions; + _finallyConventions = finallyConventions; + } + + internal List Descriptors => _descriptors; + + /// + public void Add(Action convention) + { + ArgumentNullException.ThrowIfNull(convention); + lock (_lck) + { + _conventions.Add(convention); + } + } + + /// + public void Finally(Action convention) + { + ArgumentNullException.ThrowIfNull(convention); + lock (_lck) + { + _finallyConventions.Add(convention); + } + } +} diff --git a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000000..3ede03b92212 --- /dev/null +++ b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticAssets; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Contains methods to integrate static assets with endpoints. +/// +public static class StaticAssetsEndpointRouteBuilderExtensions +{ + /// + /// Maps static files produced during the build as endpoints. + /// + /// The . + /// The path to the static assets manifest file. + /// + /// The can be null to use the to locate the manifes. + /// As an alternative, a full path can be specified to the manifest file. If a relative path is used, we'll search for the file in the ." /> + /// + public static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndpointRouteBuilder endpoints, string? staticAssetsManifestPath = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var environment = endpoints.ServiceProvider.GetRequiredService(); + staticAssetsManifestPath ??= $"{environment.ApplicationName}.staticwebassets.endpoints.json"; + + staticAssetsManifestPath = !Path.IsPathRooted(staticAssetsManifestPath) ? + Path.Combine(AppContext.BaseDirectory, staticAssetsManifestPath) : + staticAssetsManifestPath; + + var result = MapStaticAssetsCore(endpoints, staticAssetsManifestPath); + + if (StaticAssetDevelopmentRuntimeHandler.IsEnabled(endpoints.ServiceProvider, environment)) + { + StaticAssetDevelopmentRuntimeHandler.EnableSupport(endpoints, result, environment, result.Descriptors); + } + + return result; + } + + private static StaticAssetsEndpointConventionBuilder MapStaticAssetsCore( + IEndpointRouteBuilder endpoints, + string manifestPath) + { + var builder = GetExistingBuilder(endpoints, manifestPath); + if (builder != null) + { + return builder; + } + + var manifest = ResolveManifest(manifestPath); + + var dataSource = manifest.CreateDataSource(endpoints, manifestPath, manifest.Endpoints); + return dataSource.DefaultBuilder; + } + + private static StaticAssetsManifest ResolveManifest(string manifestPath) + { + if (!File.Exists(manifestPath)) + { + throw new InvalidOperationException($"The static resources manifest file '{manifestPath}' was not found."); + } + + return StaticAssetsManifest.Parse(manifestPath); + } + + private static StaticAssetsEndpointConventionBuilder? GetExistingBuilder(IEndpointRouteBuilder endpoints, string manifestPath) + { + foreach (var ds in endpoints.DataSources) + { + if (ds is StaticAssetsEndpointDataSource sads && sads.ManifestPath.Equals(manifestPath, StringComparison.Ordinal)) + { + return sads.DefaultBuilder; + } + } + + return null; + } + + // For testing purposes + internal static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndpointRouteBuilder endpoints, StaticAssetsManifest manifest) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var environment = endpoints.ServiceProvider.GetRequiredService(); + var result = manifest.CreateDataSource(endpoints, "", manifest.Endpoints).DefaultBuilder; + + if (StaticAssetDevelopmentRuntimeHandler.IsEnabled(endpoints.ServiceProvider, environment)) + { + StaticAssetDevelopmentRuntimeHandler.EnableSupport(endpoints, result, environment, result.Descriptors); + } + + return result; + } +} diff --git a/src/StaticAssets/src/StaticAssetsInvoker.cs b/src/StaticAssets/src/StaticAssetsInvoker.cs new file mode 100644 index 000000000000..e43ed498c6c4 --- /dev/null +++ b/src/StaticAssets/src/StaticAssetsInvoker.cs @@ -0,0 +1,431 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Globalization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Headers; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.StaticAssets; + +internal class StaticAssetsInvoker +{ + private readonly StaticAssetDescriptor _resource; + + private readonly ILogger _logger; + private readonly string? _contentType; + + private readonly IFileInfo _fileInfo; + + private readonly EntityTagHeaderValue _etag; + private readonly long _length; + private readonly DateTimeOffset _lastModified; + private readonly List _remainingHeaders; + + public StaticAssetsInvoker(StaticAssetDescriptor resource, IFileInfo fileInfo, ILogger logger) + { + _resource = resource; + _fileInfo = fileInfo; + _logger = logger; + _fileInfo = fileInfo; + _remainingHeaders ??= []; + + foreach (var responseHeader in resource.ResponseHeaders) + { + switch (responseHeader) + { + case { Name: "Content-Type", Value: var contentType }: + _contentType = contentType; + break; + case { Name: "ETag", Value: var etag }: + if (_etag == null || _etag.IsWeak) + { + if (_etag != null) + { + _remainingHeaders.Add(new ResponseHeader("ETag", _etag.ToString())); + } + + _etag = EntityTagHeaderValue.Parse(etag); + break; + } + else + { + goto default; + } + case { Name: "Last-Modified", Value: var lastModified }: + _lastModified = DateTimeOffset.Parse(lastModified, CultureInfo.InvariantCulture); + break; + case { Name: "Content-Length", Value: var length }: + _length = long.Parse(length, CultureInfo.InvariantCulture); + break; + default: + _remainingHeaders ??= []; + _remainingHeaders.Add(responseHeader); + break; + } + } + + if (_etag == null) + { + throw new InvalidOperationException("The ETag header is required."); + } + } + + public string Route => _resource.Route; + + public string PhysicalPath => _fileInfo.PhysicalPath ?? string.Empty; + + private Task ApplyResponseHeadersAsync(StaticAssetInvocationContext context, int statusCode) + { + if (statusCode < 400) + { + // these headers are returned for 200, 206, and 304 + // they are not returned for 412 and 416 + if (!string.IsNullOrEmpty(_contentType)) + { + context.Response.ContentType = _contentType; + } + + var responseHeaders = context.ResponseHeaders; + responseHeaders.LastModified = _lastModified; + responseHeaders.ETag = _etag; + responseHeaders.Headers.AcceptRanges = "bytes"; + + foreach (var header in _remainingHeaders ?? []) + { + responseHeaders.Append(header.Name, header.Value); + } + } + + return Task.CompletedTask; + } + + private Task SendStatusAsync(StaticAssetInvocationContext context, int statusCode) + { + _logger.Handled(statusCode, Route); + + // Only clobber the default status (e.g. in cases this a status code pages retry) + if (context.Response.StatusCode == StatusCodes.Status200OK) + { + context.Response.StatusCode = statusCode; + } + + return ApplyResponseHeadersAsync(context, statusCode); + } + + public async Task Invoke(HttpContext context) + { + var requestContext = new StaticAssetInvocationContext( + context, + _etag, + _lastModified, + _length, + _logger); + + var (preconditionState, isRange, range) = requestContext.ComprehendRequestHeaders(); + switch (preconditionState) + { + case PreconditionState.Unspecified: + case PreconditionState.ShouldProcess: + if (HttpMethods.IsHead(context.Request.Method)) + { + await SendStatusAsync(requestContext, StatusCodes.Status200OK); + return; + } + + try + { + if (isRange) + { + await SendRangeAsync(requestContext, range); + return; + } + + context.Response.ContentLength = _length; + + await SendAsync(requestContext); + _logger.FileServed(Route, PhysicalPath); + return; + } + catch (FileNotFoundException) + { + context.Response.Clear(); + } + return; + case PreconditionState.NotModified: + _logger.FileNotModified(Route); + await SendStatusAsync(requestContext, StatusCodes.Status304NotModified); + return; + case PreconditionState.PreconditionFailed: + _logger.PreconditionFailed(Route); + await SendStatusAsync(requestContext, StatusCodes.Status412PreconditionFailed); + return; + default: + var exception = new NotImplementedException(preconditionState.ToString()); + Debug.Fail(exception.ToString()); + throw exception; + } + } + + private async Task SendAsync(StaticAssetInvocationContext context) + { + await ApplyResponseHeadersAsync(context, StatusCodes.Status200OK); + try + { + await context.Response.SendFileAsync(_fileInfo, 0, _length, context.CancellationToken); + } + catch (OperationCanceledException ex) + { + // Don't throw this exception, it's most likely caused by the client disconnecting. + _logger.WriteCancelled(ex); + } + } + + // When there is only a single range the bytes are sent directly in the body. + private async Task SendRangeAsync(StaticAssetInvocationContext requestContext, RangeItemHeaderValue? range) + { + if (range == null) + { + // 14.16 Content-Range - A server sending a response with status code 416 (Requested range not satisfiable) + // SHOULD include a Content-Range field with a byte-range-resp-spec of "*". The instance-length specifies + // the current length of the selected resource. e.g. */length + requestContext.ResponseHeaders.ContentRange = new ContentRangeHeaderValue(_length); + if (requestContext.Response.StatusCode == StatusCodes.Status200OK) + { + requestContext.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + } + + _logger.RangeNotSatisfiable(Route); + return; + } + + requestContext.ResponseHeaders.ContentRange = ComputeContentRange(range, out var start, out var length); + requestContext.Response.ContentLength = length; + + if (requestContext.Response.StatusCode == StatusCodes.Status200OK) + { + requestContext.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + } + await ApplyResponseHeadersAsync(requestContext, StatusCodes.Status206PartialContent); + + try + { + var logPath = !string.IsNullOrEmpty(_fileInfo.PhysicalPath) ? _fileInfo.PhysicalPath : Route; + _logger.SendingFileRange(requestContext.Response.Headers.ContentRange, logPath); + await requestContext.Response.SendFileAsync(_fileInfo, start, length, requestContext.CancellationToken); + } + catch (OperationCanceledException ex) + { + // Don't throw this exception, it's most likely caused by the client disconnecting. + _logger.WriteCancelled(ex); + } + } + + // Note: This assumes ranges have been normalized to absolute byte offsets. + private ContentRangeHeaderValue ComputeContentRange(RangeItemHeaderValue range, out long start, out long length) + { + start = range.From!.Value; + var end = range.To!.Value; + length = end - start + 1; + return new ContentRangeHeaderValue(start, end, _length); + } + + private readonly struct StaticAssetInvocationContext + { + private readonly HttpContext _context = null!; + private readonly HttpRequest _request = null!; + private readonly HttpResponse _response = null!; + private readonly EntityTagHeaderValue _etag; + private readonly DateTimeOffset _lastModified; + private readonly long _length; + private readonly ILogger _logger; + private readonly RequestHeaders _requestHeaders; + private readonly ResponseHeaders _responseHeaders; + + public StaticAssetInvocationContext( + HttpContext context, + EntityTagHeaderValue entityTag, + DateTimeOffset lastModified, + long length, + ILogger logger) + { + _context = context; + _request = context.Request; + _responseHeaders = context.Response.GetTypedHeaders(); + _requestHeaders = _request.GetTypedHeaders(); + _response = context.Response; + _etag = entityTag; + _lastModified = lastModified; + _length = length; + _logger = logger; + } + + public CancellationToken CancellationToken => _context.RequestAborted; + + public ResponseHeaders ResponseHeaders => _responseHeaders; + + public HttpResponse Response => _response; + + public (PreconditionState, bool isRange, RangeItemHeaderValue? range) ComprehendRequestHeaders() + { + var (ifMatch, ifNoneMatch) = ComputeIfMatch(); + var (ifModifiedSince, ifUnmodifiedSince) = ComputeIfModifiedSince(); + + var (isRange, range) = ComputeRange(); + + isRange = ComputeIfRange(isRange); + + return (GetPreconditionState(ifMatch, ifNoneMatch, ifModifiedSince, ifUnmodifiedSince), isRange, range); + } + + private (PreconditionState ifMatch, PreconditionState ifNoneMatch) ComputeIfMatch() + { + var requestHeaders = _requestHeaders; + var ifMatchResult = PreconditionState.Unspecified; + + // 14.24 If-Match + var ifMatch = requestHeaders.IfMatch; + if (ifMatch?.Count > 0) + { + ifMatchResult = PreconditionState.PreconditionFailed; + foreach (var etag in ifMatch) + { + if (etag.Equals(EntityTagHeaderValue.Any) || etag.Compare(_etag, useStrongComparison: false)) + { + ifMatchResult = PreconditionState.ShouldProcess; + break; + } + } + } + + // 14.26 If-None-Match + var ifNoneMatchResult = PreconditionState.Unspecified; + var ifNoneMatch = requestHeaders.IfNoneMatch; + if (ifNoneMatch?.Count > 0) + { + ifNoneMatchResult = PreconditionState.ShouldProcess; + foreach (var etag in ifNoneMatch) + { + if (etag.Equals(EntityTagHeaderValue.Any) || etag.Compare(_etag, useStrongComparison: false)) + { + ifNoneMatchResult = PreconditionState.NotModified; + break; + } + } + } + + return (ifMatchResult, ifNoneMatchResult); + } + + private (PreconditionState ifModifiedSince, PreconditionState ifUnmodifiedSince) ComputeIfModifiedSince() + { + var requestHeaders = _requestHeaders; + var now = DateTimeOffset.UtcNow; + + // 14.25 If-Modified-Since + var ifModifiedSinceResult = PreconditionState.Unspecified; + var ifModifiedSince = requestHeaders.IfModifiedSince; + if (ifModifiedSince.HasValue && ifModifiedSince <= now) + { + var modified = ifModifiedSince < _lastModified; + ifModifiedSinceResult = modified ? PreconditionState.ShouldProcess : PreconditionState.NotModified; + } + + // 14.28 If-Unmodified-Since + var ifUnmodifiedSinceResult = PreconditionState.Unspecified; + var ifUnmodifiedSince = requestHeaders.IfUnmodifiedSince; + if (ifUnmodifiedSince.HasValue && ifUnmodifiedSince <= now) + { + var unmodified = ifUnmodifiedSince >= _lastModified; + ifUnmodifiedSinceResult = unmodified ? PreconditionState.ShouldProcess : PreconditionState.PreconditionFailed; + } + + return (ifModifiedSinceResult, ifUnmodifiedSinceResult); + } + + private bool ComputeIfRange(bool isRange) + { + // 14.27 If-Range + var ifRangeHeader = _requestHeaders.IfRange; + if (ifRangeHeader != null) + { + // If the validator given in the If-Range header field matches the + // current validator for the selected representation of the target + // resource, then the server SHOULD process the Range header field as + // requested. If the validator does not match, the server MUST ignore + // the Range header field. + if (ifRangeHeader.LastModified.HasValue) + { + if (_lastModified > ifRangeHeader.LastModified) + { + isRange = false; + } + } + else if (_etag != null && ifRangeHeader.EntityTag != null && !ifRangeHeader.EntityTag.Compare(_etag, useStrongComparison: true)) + { + isRange = false; + } + } + + return isRange; + } + + private (bool isRangeRequest, RangeItemHeaderValue? range) ComputeRange() + { + // 14.35 Range + // http://tools.ietf.org/html/draft-ietf-httpbis-p5-range-24 + + // A server MUST ignore a Range header field received with a request method other + // than GET. + if (!HttpMethods.IsGet(_request.Method)) + { + return default; + } + + (var isRangeRequest, var range) = RangeHelper.ParseRange(_context, _requestHeaders, _length, _logger); + + return (isRangeRequest, range); + } + + public static PreconditionState GetPreconditionState( + PreconditionState ifMatchState, + PreconditionState ifNoneMatchState, + PreconditionState ifModifiedSinceState, + PreconditionState ifUnmodifiedSinceState) => + GetMaxPreconditionState(ifMatchState, ifNoneMatchState, ifModifiedSinceState, ifUnmodifiedSinceState); + + private static PreconditionState GetMaxPreconditionState(params Span states) + { + var max = PreconditionState.Unspecified; + for (var i = 0; i < states.Length; i++) + { + if (states[i] > max) + { + max = states[i]; + } + } + return max; + } + } + + internal enum PreconditionState : byte + { + Unspecified, + NotModified, + ShouldProcess, + PreconditionFailed + } + + [Flags] + private enum RequestType : byte + { + Unspecified = 0b_000, + IsHead = 0b_001, + IsGet = 0b_010, + IsRange = 0b_100, + } +} diff --git a/src/StaticAssets/src/StaticAssetsManifest.cs b/src/StaticAssets/src/StaticAssetsManifest.cs new file mode 100644 index 000000000000..0bb70b10246e --- /dev/null +++ b/src/StaticAssets/src/StaticAssetsManifest.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.StaticAssets; + +// Represents a manifest of static resources. +// The manifes is a JSON file that contains a list of static resources and their associated metadata. +// There is a top level property "resources" that contains an array of objects, each of which represents a static resource. +// Each static resource is defined by the following properties: +// * "path": The path of the static resource. +// * "selectors": An array of request headers that act as selectors for the resource. Each selector is defined by an object with the following properties: +// * "name": The name of the request header. +// * "value": The value of the request header. +// * "preference": The preference of the selector. The preference is a number between 0 and 1.0 and it matches the semantics of the quality parameter in +// the Accept-* headers. This preference is used as a last resource to break ties in content negotiation when the client indicates an equal preference +// for multiple resources. +// * "responseHeaders": A list of headers to apply to the response when a given resource is served. This is useful to apply headers to the response that are +// specific to the resource, such as Cache-Control headers or ETag headers that are computed at build time. +internal class StaticAssetsManifest +{ + internal static StaticAssetsManifest Parse(string manifestPath) + { + ArgumentNullException.ThrowIfNull(manifestPath); + + using var stream = File.OpenRead(manifestPath); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + var result = JsonSerializer.Deserialize(content, StaticAssetsManifestJsonContext.Default.StaticAssetsManifest) ?? + throw new InvalidOperationException($"The static resources manifest file '{manifestPath}' could not be deserialized."); + + return result; + } + + internal StaticAssetsEndpointDataSource CreateDataSource(IEndpointRouteBuilder endpoints, string manifestName, List descriptors) + { + var dataSource = new StaticAssetsEndpointDataSource(endpoints.ServiceProvider, this, new StaticAssetEndpointFactory(endpoints.ServiceProvider), manifestName, descriptors); + endpoints.DataSources.Add(dataSource); + return dataSource; + } + + public int Version { get; set; } + public List Endpoints { get; set; } = []; +} diff --git a/src/StaticAssets/src/StaticAssetsManifestJsonContext.cs b/src/StaticAssets/src/StaticAssetsManifestJsonContext.cs new file mode 100644 index 000000000000..5d9b6139e407 --- /dev/null +++ b/src/StaticAssets/src/StaticAssetsManifestJsonContext.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.StaticAssets; + +[JsonSerializable(typeof(StaticAssetsManifest))] +internal partial class StaticAssetsManifestJsonContext : JsonSerializerContext +{ +} diff --git a/src/StaticAssets/test/Microsoft.AspNetCore.StaticAssets.Tests.csproj b/src/StaticAssets/test/Microsoft.AspNetCore.StaticAssets.Tests.csproj new file mode 100644 index 000000000000..ce765d832381 --- /dev/null +++ b/src/StaticAssets/test/Microsoft.AspNetCore.StaticAssets.Tests.csproj @@ -0,0 +1,13 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + diff --git a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs new file mode 100644 index 000000000000..9314865bd764 --- /dev/null +++ b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs @@ -0,0 +1,854 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.StaticAssets.Tests; + +public class StaticAssetsIntegrationTests +{ + [Fact] + public async Task CanServeAssetsFromManifestAsync() + { + // Arrange + var appName = nameof(CanServeAssetsFromManifestAsync); + var (contentRoot, webRoot) = ConfigureAppPaths(appName); + + CreateTestManifest( + appName, + webRoot, + [ + new TestResource("sample.txt", "Hello, World!", false), + ]); + + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + ApplicationName = appName, + ContentRootPath = contentRoot, + EnvironmentName = "Development", + WebRootPath = webRoot + }); + builder.WebHost.ConfigureServices(services => + { + services.AddRouting(); + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapStaticAssets(); + }); + + await app.StartAsync(); + + var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync("/sample.txt"); + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal($"\"{GetEtag("Hello, World!")}\"", response.Headers.ETag.Tag); + Assert.Equal(13, response.Content.Headers.ContentLength); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Hello, World!", await response.Content.ReadAsStringAsync()); + + Directory.Delete(webRoot, true); + } + + [Fact] + public async Task CanServeNewFilesAddedAfterBuildDuringDevelopment() + { + // Arrange + var appName = nameof(CanServeNewFilesAddedAfterBuildDuringDevelopment); + var (contentRoot, webRoot) = ConfigureAppPaths(appName); + + CreateTestManifest( + appName, + webRoot, + []); + + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + ApplicationName = appName, + ContentRootPath = contentRoot, + EnvironmentName = "Development", + WebRootPath = webRoot + }); + + builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true"); + builder.WebHost.ConfigureServices(services => + { + services.AddRouting(); + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapStaticAssets(); + }); + + await app.StartAsync(); + + var filePath = Path.Combine(webRoot, "sample.txt"); + var lastModified = DateTimeOffset.UtcNow; + File.WriteAllText(filePath, "Hello, World!"); + + var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync("/sample.txt"); + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal($"\"3/1gIbsr1bCvZ2KQgJ7DpTGR3YHH9wpLKGiKNiGCmG8=\"", response.Headers.ETag.Tag); + Assert.Equal(13, response.Content.Headers.ContentLength); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Hello, World!", await response.Content.ReadAsStringAsync()); + + Directory.Delete(webRoot, true); + } + + [Fact] + public async Task CanModifyAssetsOnTheFlyInDevelopment() + { + // Arrange + var appName = nameof(CanModifyAssetsOnTheFlyInDevelopment); + var (contentRoot, webRoot) = ConfigureAppPaths(appName); + + CreateTestManifest( + appName, + webRoot, + [ + new TestResource("sample.txt", "Hello, World!", false), + ]); + + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + ApplicationName = appName, + ContentRootPath = contentRoot, + EnvironmentName = "Development", + WebRootPath = webRoot + }); + builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true"); + builder.WebHost.ConfigureServices(services => + { + services.AddRouting(); + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapStaticAssets(); + }); + + await app.StartAsync(); + + var client = app.GetTestClient(); + + File.WriteAllText(Path.Combine(webRoot, "sample.txt"), "Hello, World! Modified"); + + // Act + var response = await client.GetAsync("/sample.txt"); + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal($"\"+fvSyRQcr4/t/rcA0u1KfZ8c3CpXxBDxsxDhnAftNqg=\"", response.Headers.ETag.Tag); + Assert.Equal(22, response.Content.Headers.ContentLength); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Hello, World! Modified", await response.Content.ReadAsStringAsync()); + + Directory.Delete(webRoot, true); + } + + [Fact] + public async Task CanModifyAssetsWithCompressedVersionsOnTheFlyInDevelopment() + { + // Arrange + var appName = nameof(CanModifyAssetsWithCompressedVersionsOnTheFlyInDevelopment); + var (contentRoot, webRoot) = ConfigureAppPaths(appName); + + CreateTestManifest( + appName, + webRoot, + [ + new TestResource("sample.txt", "Hello, World!", true), + ]); + + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + ApplicationName = appName, + ContentRootPath = contentRoot, + EnvironmentName = "Development", + WebRootPath = webRoot + }); + builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true"); + builder.WebHost.ConfigureServices(services => + { + services.AddRouting(); + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapStaticAssets(); + }); + + await app.StartAsync(); + + var client = app.GetTestClient(); + + File.WriteAllText(Path.Combine(webRoot, "sample.txt"), "Hello, World! Modified"); + + // Act + var message = new HttpRequestMessage(HttpMethod.Get, "/sample.txt"); + message.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + var response = await client.SendAsync(message); + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(GetGzipEtag(Path.Combine(webRoot, "sample.txt")), response.Headers.ETag.Tag); + Assert.Equal(55, response.Content.Headers.ContentLength); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var gzipContent = await response.Content.ReadAsStreamAsync(); + using var gzipStream = new GZipStream(gzipContent, CompressionMode.Decompress); + using var reader = new StreamReader(gzipStream); + var content = reader.ReadToEnd(); + Assert.Equal("Hello, World! Modified", content); + + Directory.Delete(webRoot, true); + } + + private string GetGzipEtag(string filePath) + { + using var stream = new MemoryStream(); + using (var fileStream = File.OpenRead(filePath)) + { + using var gzipStream = new GZipStream(stream, CompressionLevel.NoCompression, leaveOpen: true); + fileStream.CopyTo(gzipStream); + gzipStream.Flush(); + } + stream.Flush(); + stream.Seek(0, SeekOrigin.Begin); + + return $"\"{Convert.ToBase64String(SHA256.HashData(stream))}\""; + } + + private static (string contentRoot, string webRoot) ConfigureAppPaths(string appName) + { + var contentRoot = Path.Combine(AppContext.BaseDirectory, appName); + var webRoot = Path.Combine(contentRoot, "wwwroot"); + + return (contentRoot, webRoot); + } + + private static void CreateTestManifest(string appName, string webRoot, params Span resources) + { + Directory.CreateDirectory(webRoot); + var manifestPath = Path.Combine(AppContext.BaseDirectory, $"{appName}.staticwebassets.endpoints.json"); + var manifest = new StaticAssetsManifest() + { + Version = 1 + }; + + for (var i = 0; i < resources.Length; i++) + { + var resource = resources[i]; + var filePath = Path.Combine(webRoot, resource.Path); + var lastModified = DateTimeOffset.UtcNow; + File.WriteAllText(filePath, resource.Content); + + manifest.Endpoints.Add(new StaticAssetDescriptor( + resource.Path, + resource.Path, + [], + [], + [ + new ("Accept-Ranges", "bytes"), + new("Content-Length", resource.Content.Length.ToString(CultureInfo.InvariantCulture)), + new("Content-Type", GetContentType(filePath)), + new ("ETag", $"\"{GetEtag(resource.Content)}\""), + new("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)) + ] + )); + + if (resource.IncludeCompressedVersion) + { + var compressedFilePath = Path.Combine(webRoot, resource.Path + ".gz"); + var length = CreateCompressedFile(compressedFilePath, resource); + + manifest.Endpoints.Add(new StaticAssetDescriptor( + resource.Path, + $"{resource.Path}.gz", + [new StaticAssetSelector("Content-Encoding", "gzip", "1.0")], + [], + [ + new ("Accept-Ranges", "bytes"), + new ("Content-Type", GetContentType(filePath)), + + new ("Content-Length", length.ToString(CultureInfo.InvariantCulture)), + new ("ETag", $"W/\"{GetEtag(resource.Content)}\""), + new ("ETag", $"\"{GetEtagForFile(compressedFilePath)}\""), + new ("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)), + + new ("Content-Encoding", "gzip"), + new ("Vary", "Accept-Encoding"), + ] + )); + } + } + using var stream = File.Create(manifestPath); + using var writer = new Utf8JsonWriter(stream); + JsonSerializer.Serialize(writer, manifest); + } + + private static long CreateCompressedFile(string filePath, TestResource resource) + { + using var fileStream = File.Create(filePath); + using var gzipStream = new GZipStream(fileStream, CompressionLevel.Fastest); + using var compressedWriter = new StreamWriter(gzipStream); + compressedWriter.Write(resource.Content); + compressedWriter.Flush(); + return fileStream.Length; + } + + private static string GetEtagForFile(string compressedFilePath) + { + using var stream = File.OpenRead(compressedFilePath); + return Convert.ToBase64String(SHA256.HashData(stream)); + } + + private static string GetEtag(string content) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content)); + return Convert.ToBase64String(hash); + } + + private static async Task CreateClient() + { + // Arrange + // These aren't used as we are replacing the file provider with a test one + var (contentRoot, webRoot) = (AppContext.BaseDirectory, AppContext.BaseDirectory); + + var manifest = new StaticAssetsManifest() + { + Version = 1 + }; + manifest.Endpoints.Add(new StaticAssetDescriptor( + "sample.txt", + "sample.txt", + [], + [], + [ + new ("Accept-Ranges", "bytes"), + new("Content-Length", "Hello, World!".Length.ToString(CultureInfo.InvariantCulture)), + new("Content-Type", GetContentType("sample.txt")), + new ("ETag", $"\"{GetEtag("Hello, World!")}\""), + new("Last-Modified", new DateTimeOffset(2023,03,03,0,0,0,TimeSpan.Zero).ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)) + ] + )); + + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + ApplicationName = "InMemory", + ContentRootPath = contentRoot, + EnvironmentName = "Development", + WebRootPath = webRoot + }); + builder.Environment.WebRootFileProvider = new TestFileProvider(new TestResource[] + { + new("sample.txt", "Hello, World!", false), + }); + builder.WebHost.ConfigureServices(services => + { + services.AddRouting(); + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapStaticAssets(manifest); + }); + + await app.StartAsync(); + + return app.GetTestClient(); + } + + [Fact] + public async Task ServerShouldReturnETag() + { + var client = await CreateClient(); + var response = await client.GetAsync("http://localhost/sample.txt"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Headers.ETag); + Assert.NotNull(response.Headers.ETag.Tag); + } + + [Fact] + public async Task SameETagShouldBeReturnedAgain() + { + var client = await CreateClient(); + var response1 = await client.GetAsync("http://localhost/sample.txt"); + var response2 = await client.GetAsync("http://localhost/sample.txt"); + Assert.Equal(response2.Headers.ETag, response1.Headers.ETag); + } + + //// 14.24 If-Match + //// If none of the entity tags match, or if "*" is given and no current + //// entity exists, the server MUST NOT perform the requested method, and + //// MUST return a 412 (Precondition Failed) response. This behavior is + //// most useful when the client wants to prevent an updating method, such + //// as PUT, from modifying a resource that has changed since the client + //// last retrieved it. + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task IfMatchShouldReturn412WhenNotListed(HttpMethod method) + { + var client = await CreateClient(); + var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req.Headers.Add("If-Match", "\"fake\""); + var resp = await client.SendAsync(req); + Assert.Equal(HttpStatusCode.PreconditionFailed, resp.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task IfMatchShouldBeServedWhenListed(HttpMethod method) + { + var client = await CreateClient(); + var original = await client.GetAsync("http://localhost/sample.txt"); + + var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req.Headers.Add("If-Match", original.Headers.ETag.ToString()); + var resp = await client.SendAsync(req); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task IfMatchShouldBeServedForAsterisk(HttpMethod method) + { + var client = await CreateClient(); + var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req.Headers.Add("If-Match", "*"); + var resp = await client.SendAsync(req); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } + + [Theory] + [MemberData(nameof(UnsupportedMethods))] + public async Task IfMatchShouldBeIgnoredForUnsupportedMethods(HttpMethod method) + { + var client = await CreateClient(); + var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req.Headers.Add("If-Match", "*"); + var resp = await client.SendAsync(req); + Assert.Equal(HttpStatusCode.MethodNotAllowed, resp.StatusCode); + } + + // 14.26 If-None-Match + // If any of the entity tags match the entity tag of the entity that + // would have been returned in the response to a similar GET request + // (without the If-None-Match header) on that resource, or if "*" is + // given and any current entity exists for that resource, then the + // server MUST NOT perform the requested method, unless required to do + // so because the resource's modification date fails to match that + // supplied in an If-Modified-Since header field in the request. + // Instead, if the request method was GET or HEAD, the server SHOULD + // respond with a 304 (Not Modified) response, including the cache- + // related header fields (particularly ETag) of one of the entities that + // matched. For all other request methods, the server MUST respond with + // a status of 412 (Precondition Failed). + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task IfNoneMatchShouldReturn304ForMatching(HttpMethod method) + { + var client = await CreateClient(); + var resp1 = await client.GetAsync("http://localhost/sample.txt"); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.Add("If-None-Match", resp1.Headers.ETag.ToString()); + var resp2 = await client.SendAsync(req2); + Assert.Equal(HttpStatusCode.NotModified, resp2.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task IfNoneMatchAllShouldReturn304ForMatching(HttpMethod method) + { + var client = await CreateClient(); + var resp1 = await client.GetAsync("http://localhost/sample.txt"); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.Add("If-None-Match", "*"); + var resp2 = await client.SendAsync(req2); + Assert.Equal(HttpStatusCode.NotModified, resp2.StatusCode); + } + + [Theory] + [MemberData(nameof(UnsupportedMethods))] + public async Task IfNoneMatchShouldBeIgnoredForNonTwoHundredAnd304Responses(HttpMethod method) + { + var client = await CreateClient(); + var resp1 = await client.GetAsync("http://localhost/sample.txt"); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.Add("If-None-Match", resp1.Headers.ETag.ToString()); + var resp2 = await client.SendAsync(req2); + Assert.Equal(HttpStatusCode.MethodNotAllowed, resp2.StatusCode); + } + + // 14.26 If-None-Match + // If none of the entity tags match, then the server MAY perform the + // requested method as if the If-None-Match header field did not exist, + // but MUST also ignore any If-Modified-Since header field(s) in the + // request. That is, if no entity tags match, then the server MUST NOT + // return a 304 (Not Modified) response. + + // A server MUST use the strong comparison function (see section 13.3.3) + // to compare the entity tags in If-Match. + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task ServerShouldReturnLastModified(HttpMethod method) + { + var client = await CreateClient(); + + var response = await client.SendAsync( + new HttpRequestMessage(method, "http://localhost/sample.txt")); + + Assert.NotNull(response.Content.Headers.LastModified); + // Verify that DateTimeOffset is UTC + Assert.Equal(response.Content.Headers.LastModified.Value.Offset, TimeSpan.Zero); + } + + // 13.3.4 + // An HTTP/1.1 origin server, upon receiving a conditional request that + // includes both a Last-Modified date (e.g., in an If-Modified-Since or + // If-Unmodified-Since header field) and one or more entity tags (e.g., + // in an If-Match, If-None-Match, or If-Range header field) as cache + // validators, MUST NOT return a response status of 304 (Not Modified) + // unless doing so is consistent with all of the conditional header + // fields in the request. + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task MatchingBothConditionsReturnsNotModified(HttpMethod method) + { + var client = await CreateClient(); + var req1 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + var resp1 = await client.SendAsync(req1); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.Add("If-None-Match", resp1.Headers.ETag.ToString()); + req2.Headers.IfModifiedSince = resp1.Content.Headers.LastModified; + var resp2 = await client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.NotModified, resp2.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task MatchingAtLeastOneETagReturnsNotModified(HttpMethod method) + { + var client = await CreateClient(); + var req1 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + var resp1 = await client.SendAsync(req1); + var etag = resp1.Headers.ETag.ToString(); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.Add("If-Match", etag + ", " + etag); + + var resp2 = await client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.OK, resp2.StatusCode); + + var req3 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req3.Headers.Add("If-Match", etag + ", \"badetag\""); + var resp3 = await client.SendAsync(req3); + + Assert.Equal(HttpStatusCode.OK, resp3.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task MissingEitherOrBothConditionsReturnsNormally(HttpMethod method) + { + var client = await CreateClient(); + var req1 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + var resp1 = await client.SendAsync(req1); + + var lastModified = resp1.Content.Headers.LastModified.Value; + var pastDate = lastModified.AddHours(-1); + var futureDate = lastModified.AddHours(1); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.IfNoneMatch.Add(new EntityTagHeaderValue("\"fake\"")); + req2.Headers.IfModifiedSince = lastModified; + var resp2 = await client.SendAsync(req2); + + var req3 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req3.Headers.IfNoneMatch.Add(resp1.Headers.ETag); + req3.Headers.IfModifiedSince = pastDate; + var resp3 = await client.SendAsync(req3); + + var req4 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req4.Headers.IfNoneMatch.Add(new EntityTagHeaderValue("\"fake\"")); + req4.Headers.IfModifiedSince = futureDate; + var resp4 = await client.SendAsync(req4); + + Assert.Equal(HttpStatusCode.OK, resp2.StatusCode); + Assert.Equal(HttpStatusCode.OK, resp3.StatusCode); + Assert.Equal(HttpStatusCode.OK, resp4.StatusCode); + } + + // 14.25 If-Modified-Since + // The If-Modified-Since request-header field is used with a method to + // make it conditional: if the requested variant has not been modified + // since the time specified in this field, an entity will not be + // returned from the server; instead, a 304 (not modified) response will + // be returned without any message-body. + + // a) If the request would normally result in anything other than a + // 200 (OK) status, or if the passed If-Modified-Since date is + // invalid, the response is exactly the same as for a normal GET. + // A date which is later than the server's current time is + // invalid. + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task InvalidIfModifiedSinceDateFormatGivesNormalGet(HttpMethod method) + { + var client = await CreateClient(); + + var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req.Headers.TryAddWithoutValidation("If-Modified-Since", "bad-date"); + var res = await client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task FutureIfModifiedSinceDateFormatGivesNormalGet(HttpMethod method) + { + var client = await CreateClient(); + + var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req.Headers.IfModifiedSince = DateTimeOffset.Now.AddYears(1); + var res = await client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + + // b) If the variant has been modified since the If-Modified-Since + // date, the response is exactly the same as for a normal GET. + + // c) If the variant has not been modified since a valid If- + // Modified-Since date, the server SHOULD return a 304 (Not + // Modified) response. + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task IfModifiedSinceDateGreaterThanLastModifiedShouldReturn304(HttpMethod method) + { + var client = await CreateClient(); + + var res1 = await client.SendAsync( + new HttpRequestMessage(method, "http://localhost/sample.txt")); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.IfModifiedSince = DateTimeOffset.Now; + var res2 = await client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.NotModified, res2.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task SupportsIfModifiedDateFormats(HttpMethod method) + { + var client = await CreateClient(); + var res1 = await client.SendAsync( + new HttpRequestMessage(method, "http://localhost/sample.txt")); + + var formats = new[] + { + "ddd, dd MMM yyyy HH:mm:ss 'GMT'", + "dddd, dd-MMM-yy HH:mm:ss 'GMT'", + "ddd MMM d HH:mm:ss yyyy" + }; + + foreach (var format in formats) + { + var req2 = new HttpRequestMessage(method, "sample.txt"); + req2.Headers.TryAddWithoutValidation("If-Modified-Since", DateTimeOffset.UtcNow.ToString(format, CultureInfo.InvariantCulture)); + var res2 = await client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.NotModified, res2.StatusCode); + } + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task IfModifiedSinceDateLessThanLastModifiedShouldReturn200(HttpMethod method) + { + var client = await CreateClient(); + + var res1 = await client.SendAsync( + new HttpRequestMessage(method, "http://localhost/sample.txt")); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.IfModifiedSince = DateTimeOffset.MinValue; + var res2 = await client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.OK, res2.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task InvalidIfUnmodifiedSinceDateFormatGivesNormalGet(HttpMethod method) + { + var client = await CreateClient(); + + var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req.Headers.TryAddWithoutValidation("If-Unmodified-Since", "bad-date"); + var res = await client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task FutureIfUnmodifiedSinceDateFormatGivesNormalGet(HttpMethod method) + { + var client = await CreateClient(); + var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req.Headers.IfUnmodifiedSince = DateTimeOffset.Now.AddYears(1); + var res = await client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + + [Theory] + [MemberData(nameof(SupportedMethods))] + public async Task IfUnmodifiedSinceDateLessThanLastModifiedShouldReturn412(HttpMethod method) + { + var client = await CreateClient(); + + var res1 = await client.SendAsync( + new HttpRequestMessage(method, "http://localhost/sample.txt")); + + var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt"); + req2.Headers.IfUnmodifiedSince = DateTimeOffset.MinValue; + var res2 = await client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.PreconditionFailed, res2.StatusCode); + } + + public static IEnumerable SupportedMethods => new[] + { + new [] { HttpMethod.Get }, + new [] { HttpMethod.Head } + }; + + public static IEnumerable UnsupportedMethods => new[] + { + new [] { HttpMethod.Post }, + new [] { HttpMethod.Put }, + new [] { HttpMethod.Options }, + new [] { HttpMethod.Trace }, + new [] { new HttpMethod("VERB") } + }; + + private static string GetContentType(string filePath) + { + return Path.GetExtension(filePath) switch + { + ".txt" => "text/plain", + ".html" => "text/html", + ".css" => "text/css", + ".js" => "application/javascript", + ".json" => "application/json", + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + _ => "application/octet-stream" + }; + } + + private record TestResource(string Path, string Content, bool IncludeCompressedVersion); + + private class TestFileProvider(StaticAssetsIntegrationTests.TestResource[] testResources) : IFileProvider + { + public IDirectoryContents GetDirectoryContents(string subpath) + { + return NotFoundDirectoryContents.Singleton; + } + + public IFileInfo GetFileInfo(string subpath) + { + for (var i = 0; i < testResources.Length; i++) + { + if (testResources[i].Path == subpath) + { + return new TestFileInfo(testResources[i]); + } + } + + return new NotFoundFileInfo(subpath); + } + + public IChangeToken Watch(string filter) + { + return NullChangeToken.Singleton; + } + + private class TestFileInfo(TestResource testResource) : IFileInfo + { + public bool Exists => true; + + public long Length => testResource.Content.Length; + + public string PhysicalPath => null; + + public string Name => Path.GetFileName(testResource.Path); + + public DateTimeOffset LastModified => new(2023, 03, 03, 0, 0, 0, TimeSpan.Zero); + + public bool IsDirectory => false; + + public Stream CreateReadStream() + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(testResource.Content); + writer.Flush(); + stream.Position = 0; + return stream; + } + } + } +}