From 84d5d6f8b635d57a2e1b0968100941246489667f Mon Sep 17 00:00:00 2001 From: jacalvar Date: Thu, 9 May 2024 20:44:43 +0200 Subject: [PATCH 01/13] Update SDK --- global.json | 4 ++-- .../Endpoints/test/HotReloadServiceTests.cs | 16 +++++++++++++++- .../E2ETest/Tests/GlobalInteractivityTest.cs | 3 +++ src/OpenApi/sample/Controllers/TestController.cs | 3 +++ 4 files changed, 23 insertions(+), 3 deletions(-) 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/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/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/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; From 192048ff6adc8ce3e8871cde8d607ce36814c6e5 Mon Sep 17 00:00:00 2001 From: jacalvar Date: Thu, 9 May 2024 20:45:59 +0200 Subject: [PATCH 02/13] Add Content encoding negotiation to routing --- .../RoutingServiceCollectionExtensions.cs | 1 + .../Routing/src/Matching/CandidateState.cs | 2 +- .../src/Matching/ContentEncodingMetadata.cs | 22 + ...ContentEncodingNegotiationMatcherPolicy.cs | 123 ++++ .../src/Matching/INegotiateMetadata.cs | 10 + .../src/Matching/NegotiationMatcherPolicy.cs | 443 ++++++++++++++ src/Http/Routing/src/PublicAPI.Unshipped.txt | 4 + ...entEncodingNegotiationMatcherPolicyTest.cs | 563 ++++++++++++++++++ 8 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 src/Http/Routing/src/Matching/ContentEncodingMetadata.cs create mode 100644 src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs create mode 100644 src/Http/Routing/src/Matching/INegotiateMetadata.cs create mode 100644 src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs create mode 100644 src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs 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..b88c906e774a --- /dev/null +++ b/src/Http/Routing/src/Matching/ContentEncodingMetadata.cs @@ -0,0 +1,22 @@ +// 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; + +/// +/// 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 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..bc902fce47f8 --- /dev/null +++ b/src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs @@ -0,0 +1,123 @@ +// 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 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 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 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) + { + 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..862b513d513e --- /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 the + // 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..01ff7683dd50 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.Matching.ContentEncodingMetadata +Microsoft.AspNetCore.Routing.Matching.ContentEncodingMetadata.ContentEncodingMetadata(string! value, double quality) -> void +Microsoft.AspNetCore.Routing.Matching.ContentEncodingMetadata.Quality.get -> double +Microsoft.AspNetCore.Routing.Matching.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..5d866880a6ee --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs @@ -0,0 +1,563 @@ +// 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)); + } + + [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)); + } + + [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)); + } + + [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)); + } + + [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)); + } + + [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_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; + } +} From eb362cca6c797bf0c876cfb11dc14f20d7bfab2f Mon Sep 17 00:00:00 2001 From: jacalvar Date: Thu, 9 May 2024 20:47:51 +0200 Subject: [PATCH 03/13] Add Static Asset Endpoints --- AspNetCore.sln | 41 + eng/Build.props | 2 + eng/ProjectReferences.props | 1 + eng/SharedFramework.Local.props | 1 + eng/ShippingAssemblies.props | 1 + eng/TrimmableProjects.props | 1 + src/Framework/test/TestData.cs | 2 + src/StaticAssets/src/EndpointProperty.cs | 16 + .../StaticAssetDevelopmentRuntimeHandler.cs | 279 ++++++ src/StaticAssets/src/LoggerExtensions.cs | 63 ++ .../Microsoft.AspNetCore.StaticAssets.csproj | 27 + src/StaticAssets/src/PublicAPI.Shipped.txt | 1 + src/StaticAssets/src/PublicAPI.Unshipped.txt | 10 + src/StaticAssets/src/ResponseHeader.cs | 16 + src/StaticAssets/src/StaticAssetDescriptor.cs | 27 + .../src/StaticAssetEndpointDataSource.cs | 114 +++ .../src/StaticAssetEndpointFactory.cs | 65 ++ src/StaticAssets/src/StaticAssetSelector.cs | 17 + .../StaticAssetsEndpointConventionBuilder.cs | 47 + ...ticAssetsEndpointRouteBuilderExtensions.cs | 87 ++ src/StaticAssets/src/StaticAssetsInvoker.cs | 433 +++++++++ src/StaticAssets/src/StaticAssetsManifest.cs | 47 + .../src/StaticAssetsManifestJsonContext.cs | 11 + ...osoft.AspNetCore.StaticAssets.Tests.csproj | 13 + .../test/StaticAssetsIntegrationTests.cs | 854 ++++++++++++++++++ 25 files changed, 2176 insertions(+) create mode 100644 src/StaticAssets/src/EndpointProperty.cs create mode 100644 src/StaticAssets/src/HotReload/StaticAssetDevelopmentRuntimeHandler.cs create mode 100644 src/StaticAssets/src/LoggerExtensions.cs create mode 100644 src/StaticAssets/src/Microsoft.AspNetCore.StaticAssets.csproj create mode 100644 src/StaticAssets/src/PublicAPI.Shipped.txt create mode 100644 src/StaticAssets/src/PublicAPI.Unshipped.txt create mode 100644 src/StaticAssets/src/ResponseHeader.cs create mode 100644 src/StaticAssets/src/StaticAssetDescriptor.cs create mode 100644 src/StaticAssets/src/StaticAssetEndpointDataSource.cs create mode 100644 src/StaticAssets/src/StaticAssetEndpointFactory.cs create mode 100644 src/StaticAssets/src/StaticAssetSelector.cs create mode 100644 src/StaticAssets/src/StaticAssetsEndpointConventionBuilder.cs create mode 100644 src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs create mode 100644 src/StaticAssets/src/StaticAssetsInvoker.cs create mode 100644 src/StaticAssets/src/StaticAssetsManifest.cs create mode 100644 src/StaticAssets/src/StaticAssetsManifestJsonContext.cs create mode 100644 src/StaticAssets/test/Microsoft.AspNetCore.StaticAssets.Tests.csproj create mode 100644 src/StaticAssets/test/StaticAssetsIntegrationTests.cs 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/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/StaticAssets/src/EndpointProperty.cs b/src/StaticAssets/src/EndpointProperty.cs new file mode 100644 index 000000000000..579c34a0e04e --- /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 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/HotReload/StaticAssetDevelopmentRuntimeHandler.cs b/src/StaticAssets/src/HotReload/StaticAssetDevelopmentRuntimeHandler.cs new file mode 100644 index 000000000000..f7a0fb956416 --- /dev/null +++ b/src/StaticAssets/src/HotReload/StaticAssetDevelopmentRuntimeHandler.cs @@ -0,0 +1,279 @@ +// 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.StaticAssets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +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 class StaticAssetDevelopmentRuntimeHandler(List descriptors) +{ + 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 HotReloadStaticAsset(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 class HotReloadStaticAsset : IHttpResponseBodyFeature + { + private readonly IHttpResponseBodyFeature _original; + private readonly HttpContext _context; + private readonly StaticAssetDescriptor _asset; + + public HotReloadStaticAsset(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, HotReloadStaticAsset 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["HotReloadStaticAssets"], 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["DisableStaticAssetFallback"], out var disableFallbackValue) && disableFallbackValue; + + if (!disableFallback) + { + // Add a fallback static file handler to serve any file that might have been added after the initial startup. + endpoints.MapFallback( + "{**path:file}", + endpoints.CreateApplicationBuilder() + .Use((ctx, nxt) => + { + 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()); + } + + } +} + +internal static class StaticAssetDescriptorExtensions +{ + internal static long GetContentLength(this StaticAssetDescriptor descriptor) + { + foreach (var header in descriptor.ResponseHeaders) + { + if (header.Name == "Content-Length") + { + 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 (header.Name == "Last-Modified") + { + 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 (header.Name == "ETag") + { + 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 (selector.Name == "Content-Encoding") + { + return true; + } + } + + return false; + } + + internal static bool HasETag(this StaticAssetDescriptor descriptor, string tag) + { + foreach (var header in descriptor.ResponseHeaders) + { + if (header.Name == "ETag") + { + var eTag = EntityTagHeaderValue.Parse(header.Value); + if (!eTag.IsWeak && eTag.Tag == tag) + { + return true; + } + } + } + + 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..4da6216519b5 --- /dev/null +++ b/src/StaticAssets/src/PublicAPI.Unshipped.txt @@ -0,0 +1,10 @@ +Microsoft.AspNetCore.Builder.StaticAssetsEndpointRouteBuilderExtensions +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder.Add(System.Action! convention) -> void +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder.Finally(System.Action! convention) -> void +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.DefaultBuilder.get -> Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder! +Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.ManifestName.get -> string! +override Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.Endpoints.get -> System.Collections.Generic.IReadOnlyList! +override Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.GetChangeToken() -> Microsoft.Extensions.Primitives.IChangeToken! +static Microsoft.AspNetCore.Builder.StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssetEndpoints(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string? staticAssetsManifestPath = null) -> Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder! \ No newline at end of file diff --git a/src/StaticAssets/src/ResponseHeader.cs b/src/StaticAssets/src/ResponseHeader.cs new file mode 100644 index 000000000000..c21ae72dba90 --- /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 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..c6b9e1647594 --- /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 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..f8f6391f0f0b --- /dev/null +++ b/src/StaticAssets/src/StaticAssetEndpointDataSource.cs @@ -0,0 +1,114 @@ +// 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}}")] +public class StaticAssetsEndpointDataSource : EndpointDataSource +{ + private readonly object _lock = new(); + private readonly StaticAssetsManifest _manifest; + private readonly string _manifestName; + private readonly StaticAssetEndpointFactory _endpointFactory; + private readonly List> _conventions = []; + private readonly List> _finallyConventions = []; + private List _endpoints = null!; + private CancellationTokenSource _cancellationTokenSource; + private CancellationChangeToken _changeToken; + + internal StaticAssetsEndpointDataSource(StaticAssetsManifest manifest, StaticAssetEndpointFactory endpointFactory, string manifestName, List descriptors) + { + _manifest = manifest; + _manifestName = 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 ManifestName => _manifestName; + + /// + public StaticAssetsEndpointConventionBuilder DefaultBuilder { get; internal 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; + } + } + + 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() => _manifestName; +} diff --git a/src/StaticAssets/src/StaticAssetEndpointFactory.cs b/src/StaticAssets/src/StaticAssetEndpointFactory.cs new file mode 100644 index 000000000000..a637deae7417 --- /dev/null +++ b/src/StaticAssets/src/StaticAssetEndpointFactory.cs @@ -0,0 +1,65 @@ +// 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.Matching; +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..dc69f321e738 --- /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 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..7f4a0583afb1 --- /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 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..33edd9e0d8e3 --- /dev/null +++ b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs @@ -0,0 +1,87 @@ +// 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 MapStaticAssetEndpoints(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 = MapStaticAssetEndpointsCore(endpoints, staticAssetsManifestPath); + + if (StaticAssetDevelopmentRuntimeHandler.IsEnabled(endpoints.ServiceProvider, environment)) + { + StaticAssetDevelopmentRuntimeHandler.EnableSupport(endpoints, result, environment, result.Descriptors); + } + + return result; + } + + private static StaticAssetsEndpointConventionBuilder MapStaticAssetEndpointsCore( + IEndpointRouteBuilder endpoints, + string manifestPath, + StaticAssetsManifest? manifest = null) + { + foreach (var ds in endpoints.DataSources) + { + if (ds is StaticAssetsEndpointDataSource sads && sads.ManifestName.Equals(manifestPath, StringComparison.Ordinal)) + { + return sads.DefaultBuilder; + } + } + + if (manifest == null && !File.Exists(manifestPath)) + { + throw new InvalidOperationException($"The static resources manifest file '{manifestPath}' was not found."); + } + + manifest ??= StaticAssetsManifest.Parse(manifestPath); + + var dataSource = manifest.CreateDataSource(endpoints, manifestPath, manifest.Endpoints); + return dataSource.DefaultBuilder; + } + + // For testing purposes + internal static StaticAssetsEndpointConventionBuilder MapStaticAssetEndpoints(this IEndpointRouteBuilder endpoints, StaticAssetsManifest manifest) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var environment = endpoints.ServiceProvider.GetRequiredService(); + var result = MapStaticAssetEndpointsCore(endpoints, "unused", manifest); + + 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..9e94663044de --- /dev/null +++ b/src/StaticAssets/src/StaticAssetsInvoker.cs @@ -0,0 +1,433 @@ +// 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; + } + else + { + 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..668659917629 --- /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(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..f8a49185fe5d --- /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.MapStaticAssetEndpoints(); + }); + + 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("HotReloadStaticAssets", "true"); + builder.WebHost.ConfigureServices(services => + { + services.AddRouting(); + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapStaticAssetEndpoints(); + }); + + 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("HotReloadStaticAssets", "true"); + builder.WebHost.ConfigureServices(services => + { + services.AddRouting(); + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapStaticAssetEndpoints(); + }); + + 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("HotReloadStaticAssets", "true"); + builder.WebHost.ConfigureServices(services => + { + services.AddRouting(); + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapStaticAssetEndpoints(); + }); + + 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.MapStaticAssetEndpoints(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.NotFound, 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.NotFound, 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; + } + } + } +} From 2098b694378da6838479b7f1bcb58aafb48991d2 Mon Sep 17 00:00:00 2001 From: jacalvar Date: Thu, 9 May 2024 20:50:52 +0200 Subject: [PATCH 04/13] Integrate components and update templates --- src/Components/Components.slnf | 2 + ...omponentEndpointConventionBuilderHelper.cs | 7 ++ .../RazorComponentEndpointDataSource.cs | 5 +- ...RazorComponentEndpointDataSourceFactory.cs | 2 +- ...azorComponentsEndpointConventionBuilder.cs | 6 ++ .../Endpoints/src/PublicAPI.Unshipped.txt | 1 + .../RazorComponentEndpointDataSourceTest.cs | 16 +++- .../BlazorUnitedApp/BlazorUnitedApp.csproj | 1 + .../Samples/BlazorUnitedApp/Program.cs | 1 + .../HostedBlazorWebassemblyApp.Server.csproj | 1 + .../Server/Startup.cs | 5 +- .../Server/appsettings.Development.json | 1 + .../WebAssemblyComponentsEndpointOptions.cs | 7 ++ ...entsEndpointConventionBuilderExtensions.cs | 35 ++++++++ ...WebAssemblyApplicationBuilderExtensions.cs | 2 +- ...setsEndpointConventionBuilderExtensions.cs | 90 +++++++++++++++++++ ...tCore.Components.WebAssembly.Server.csproj | 1 + .../Server/src/PublicAPI.Unshipped.txt | 3 + ...ssemblyRazorComponentsBuilderExtensions.cs | 46 +++++----- .../ServerFixtures/WebHostServerFixture.cs | 18 +++- .../E2ETest/Tests/RemoteAuthenticationTest.cs | 2 +- .../RazorComponentEndpointsStartup.cs | 3 +- .../RemoteAuthenticationStartup.cs | 3 +- .../Program.cs | 3 + .../BlazorWeb-CSharp/Program.Main.cs | 2 +- .../BlazorWeb-CSharp/Program.cs | 3 +- .../RazorPagesWeb-CSharp/Program.Main.cs | 2 +- .../content/RazorPagesWeb-CSharp/Program.cs | 2 +- .../content/StarterWeb-CSharp/Program.Main.cs | 3 +- .../content/StarterWeb-CSharp/Program.cs | 4 +- 30 files changed, 233 insertions(+), 44 deletions(-) create mode 100644 src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs diff --git a/src/Components/Components.slnf b/src/Components/Components.slnf index 1f0cfe428c19..42e825f732d1 100644 --- a/src/Components/Components.slnf +++ b/src/Components/Components.slnf @@ -145,6 +145,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/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..76c3801e54a3 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -27,6 +27,7 @@ app.UseStaticFiles(); app.UseAntiforgery(); +app.MapStaticAssetEndpoints(); 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..8af3e63b6622 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.MapStaticAssetEndpoints(); endpoints.MapFallbackToPage("/_Host"); }); } diff --git a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/appsettings.Development.json b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/appsettings.Development.json index 8983e0fc1c5e..ab87f61c05f0 100644 --- a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/appsettings.Development.json +++ b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/appsettings.Development.json @@ -1,4 +1,5 @@ { + "HotReloadStaticAssets": true, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs index 331678c72e0b..bc48dcbf3c4a 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? AssetsManifestPath { 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..58ed53702330 100644 --- a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs @@ -5,6 +5,10 @@ using Microsoft.AspNetCore.Components.Endpoints.Infrastructure; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Server; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticAssets; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using System.Linq; namespace Microsoft.AspNetCore.Builder; @@ -45,6 +49,37 @@ public static RazorComponentsEndpointConventionBuilder AddInteractiveWebAssembly } ComponentEndpointConventionBuilderHelper.AddRenderMode(builder, new WebAssemblyRenderModeWithOptions(options)); + + var endpointBuilder = ComponentEndpointConventionBuilderHelper.GetEndpointRouteBuilder(builder); + var environment = endpointBuilder.ServiceProvider.GetRequiredService(); + + // 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. + var staticAssetsManifestPath = options.AssetsManifestPath ?? Path.Combine(AppContext.BaseDirectory, $"{environment.ApplicationName}.staticwebassets.endpoints.json"); + staticAssetsManifestPath = Path.IsPathRooted(staticAssetsManifestPath) ? staticAssetsManifestPath : Path.Combine(AppContext.BaseDirectory, staticAssetsManifestPath); + if (HasStaticAssetDataSource(endpointBuilder, staticAssetsManifestPath)) + { + options.ConventionsApplied = true; + endpointBuilder.MapStaticAssetEndpoints(staticAssetsManifestPath) + .AddBlazorWebAssemblyConventions(); + + return builder; + } + return builder; } + + private static bool HasStaticAssetDataSource(IEndpointRouteBuilder endpointRouteBuilder, string? staticAssetsManifestName) + { + foreach (var ds in endpointRouteBuilder.DataSources) + { + if (ds is StaticAssetsEndpointDataSource staticAssetsDataSource && + string.Equals(Path.GetFileName(staticAssetsDataSource.ManifestName), staticAssetsManifestName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } } 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..edff67f2c6c3 --- /dev/null +++ b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs @@ -0,0 +1,90 @@ +// 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; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Server; + +/// +/// Extension methods for . +/// +public 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("Cache-Control", "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..e6d927cdabb0 100644 --- a/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt +++ b/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt @@ -1,3 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions +Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.AssetsManifestPath.get -> string? +Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.AssetsManifestPath.set -> void Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.ServeMultithreadingHeaders.get -> bool Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.ServeMultithreadingHeaders.set -> void diff --git a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs index 4e780879c83b..af4b1af86b00 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 MapStaticAssetEndpoints, 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/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..81191b2aa116 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.MapStaticAssetEndpoints(); _ = 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..d28f1b6b7b2a 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.MapStaticAssetEndpoints(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/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..eafd61ee0a3a 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.MapStaticAssetEndpoints(); #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..1ab25ff9f0ee 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.MapStaticAssetEndpoints(); #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..6fc59d5c8404 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.MapStaticAssetEndpoints(); 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..a425bacd8d23 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.MapStaticAssetEndpoints(); 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..5510c310c091 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.MapStaticAssetEndpoints(); 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..e144f103821e 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.MapStaticAssetEndpoints(); + app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); From c81ce75d3f58eb7675fcd260b5e354bd79ca87a7 Mon Sep 17 00:00:00 2001 From: jacalvar Date: Thu, 9 May 2024 21:55:49 +0200 Subject: [PATCH 05/13] Remove public API --- ...ssemblyStaticAssetsEndpointConventionBuilderExtensions.cs | 5 +---- .../WebAssembly/Server/src/PublicAPI.Unshipped.txt | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs index edff67f2c6c3..3a2515d2ff6c 100644 --- a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs @@ -9,10 +9,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Server; -/// -/// Extension methods for . -/// -public static class ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions +internal static class ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions { private static readonly string? s_dotnetModifiableAssemblies = GetNonEmptyEnvironmentVariableValue("DOTNET_MODIFIABLE_ASSEMBLIES"); private static readonly string? s_aspnetcoreBrowserTools = GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS"); diff --git a/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt b/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt index e6d927cdabb0..5c81392b8133 100644 --- a/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt +++ b/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt @@ -1,5 +1,4 @@ #nullable enable -Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.AssetsManifestPath.get -> string? Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.AssetsManifestPath.set -> void Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.ServeMultithreadingHeaders.get -> bool From b5fe12bf5b00420f969c3bc0b381dc00ad14d654 Mon Sep 17 00:00:00 2001 From: jacalvar Date: Fri, 10 May 2024 11:55:26 +0200 Subject: [PATCH 06/13] Apply API review feedback --- .../Samples/BlazorUnitedApp/Program.cs | 2 +- .../WebAssemblyComponentsEndpointOptions.cs | 2 +- ...entsEndpointConventionBuilderExtensions.cs | 4 +- .../Server/src/PublicAPI.Unshipped.txt | 4 +- .../RazorComponentEndpointsStartup.cs | 2 +- .../RemoteAuthenticationStartup.cs | 2 +- .../src/Matching/ContentEncodingMetadata.cs | 6 +- src/Http/Routing/src/PublicAPI.Unshipped.txt | 4 + .../StaticAssetDescriptorExtensions.cs | 84 ++++++++++++++ .../StaticAssetDevelopmentRuntimeHandler.cs | 108 +++++++----------- src/StaticAssets/src/PublicAPI.Unshipped.txt | 2 +- .../src/StaticAssetEndpointDataSource.cs | 2 +- .../StaticAssetsEndpointConventionBuilder.cs | 2 +- ...ticAssetsEndpointRouteBuilderExtensions.cs | 2 +- .../test/StaticAssetsIntegrationTests.cs | 8 +- 15 files changed, 148 insertions(+), 86 deletions(-) create mode 100644 src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs rename src/StaticAssets/src/{HotReload => Development}/StaticAssetDevelopmentRuntimeHandler.cs (76%) diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 76c3801e54a3..aebb156ed98d 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -27,7 +27,7 @@ app.UseStaticFiles(); app.UseAntiforgery(); -app.MapStaticAssetEndpoints(); +app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); diff --git a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs index bc48dcbf3c4a..a3da958d6ff0 100644 --- a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs +++ b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs @@ -30,7 +30,7 @@ public sealed class WebAssemblyComponentsEndpointOptions /// /// Gets or sets the that determines the static assets manifest path mapped to this app. /// - public string? AssetsManifestPath { get; set; } + 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 58ed53702330..9aefce5b1612 100644 --- a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs @@ -55,12 +55,12 @@ public static RazorComponentsEndpointConventionBuilder AddInteractiveWebAssembly // 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. - var staticAssetsManifestPath = options.AssetsManifestPath ?? Path.Combine(AppContext.BaseDirectory, $"{environment.ApplicationName}.staticwebassets.endpoints.json"); + var staticAssetsManifestPath = options.StaticAssetsManifestPath ?? Path.Combine(AppContext.BaseDirectory, $"{environment.ApplicationName}.staticwebassets.endpoints.json"); staticAssetsManifestPath = Path.IsPathRooted(staticAssetsManifestPath) ? staticAssetsManifestPath : Path.Combine(AppContext.BaseDirectory, staticAssetsManifestPath); if (HasStaticAssetDataSource(endpointBuilder, staticAssetsManifestPath)) { options.ConventionsApplied = true; - endpointBuilder.MapStaticAssetEndpoints(staticAssetsManifestPath) + endpointBuilder.MapStaticAssets(staticAssetsManifestPath) .AddBlazorWebAssemblyConventions(); return builder; diff --git a/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt b/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt index 5c81392b8133..deab402bac7a 100644 --- a/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt +++ b/src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt @@ -1,5 +1,5 @@ #nullable enable -Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.AssetsManifestPath.get -> string? -Microsoft.AspNetCore.Components.WebAssembly.Server.WebAssemblyComponentsEndpointOptions.AssetsManifestPath.set -> void 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/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 81191b2aa116..04d13dbf835f 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -80,7 +80,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) _ = app.UseEndpoints(endpoints => { - endpoints.MapStaticAssetEndpoints(); + 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 d28f1b6b7b2a..d2e2faa858e7 100644 --- a/src/Components/test/testassets/Components.TestServer/RemoteAuthenticationStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RemoteAuthenticationStartup.cs @@ -28,7 +28,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseAntiforgery(); app.UseEndpoints(endpoints => { - endpoints.MapStaticAssetEndpoints(Path.Combine("trimmed-or-threading", "Components.TestServer", "Components.TestServer.staticwebassets.endpoints.json")); + 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/Http/Routing/src/Matching/ContentEncodingMetadata.cs b/src/Http/Routing/src/Matching/ContentEncodingMetadata.cs index b88c906e774a..aabad8e5dc20 100644 --- a/src/Http/Routing/src/Matching/ContentEncodingMetadata.cs +++ b/src/Http/Routing/src/Matching/ContentEncodingMetadata.cs @@ -1,14 +1,16 @@ // 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; +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 class ContentEncodingMetadata(string value, double quality) : INegotiateMetadata +public sealed class ContentEncodingMetadata(string value, double quality) : INegotiateMetadata { /// /// Gets the Accept-Encoding value this metadata represents. diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 01ff7683dd50..387c21e32872 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -1,4 +1,8 @@ #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! Microsoft.AspNetCore.Routing.Matching.ContentEncodingMetadata Microsoft.AspNetCore.Routing.Matching.ContentEncodingMetadata.ContentEncodingMetadata(string! value, double quality) -> void Microsoft.AspNetCore.Routing.Matching.ContentEncodingMetadata.Quality.get -> double diff --git a/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs b/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs new file mode 100644 index 000000000000..2085f026afe8 --- /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 (header.Name == "Content-Length") + { + 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 (header.Name == "Last-Modified") + { + 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 (header.Name == "ETag") + { + 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 (selector.Name == "Content-Encoding") + { + return true; + } + } + + return false; + } + + internal static bool HasETag(this StaticAssetDescriptor descriptor, string tag) + { + foreach (var header in descriptor.ResponseHeaders) + { + if (header.Name == "ETag") + { + var eTag = EntityTagHeaderValue.Parse(header.Value); + if (!eTag.IsWeak && eTag.Tag == tag) + { + return true; + } + } + } + + return false; + } +} diff --git a/src/StaticAssets/src/HotReload/StaticAssetDevelopmentRuntimeHandler.cs b/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs similarity index 76% rename from src/StaticAssets/src/HotReload/StaticAssetDevelopmentRuntimeHandler.cs rename to src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs index f7a0fb956416..a114801a4fe0 100644 --- a/src/StaticAssets/src/HotReload/StaticAssetDevelopmentRuntimeHandler.cs +++ b/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs @@ -10,18 +10,20 @@ 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 class StaticAssetDevelopmentRuntimeHandler(List descriptors) +internal sealed partial class StaticAssetDevelopmentRuntimeHandler(List descriptors) { public void AttachRuntimePatching(EndpointBuilder builder) { @@ -46,7 +48,7 @@ public void AttachRuntimePatching(EndpointBuilder builder) // 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 HotReloadStaticAsset(originalFeature, context, asset)); + context.Features.Set(new RuntimeStaticAssetResponseBodyFeature(originalFeature, context, asset)); } await original(context); @@ -60,13 +62,13 @@ internal static string GetETag(IFileInfo fileInfo) return $"\"{Convert.ToBase64String(SHA256.HashData(stream))}\""; } - internal class HotReloadStaticAsset : IHttpResponseBodyFeature + internal sealed class RuntimeStaticAssetResponseBodyFeature : IHttpResponseBodyFeature { private readonly IHttpResponseBodyFeature _original; private readonly HttpContext _context; private readonly StaticAssetDescriptor _asset; - public HotReloadStaticAsset(IHttpResponseBodyFeature original, HttpContext context, StaticAssetDescriptor asset) + public RuntimeStaticAssetResponseBodyFeature(IHttpResponseBodyFeature original, HttpContext context, StaticAssetDescriptor asset) { _original = original; _context = context; @@ -172,12 +174,16 @@ internal static void EnableSupport( 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. - endpoints.MapFallback( + var fallback = endpoints.MapFallback( "{**path:file}", endpoints.CreateApplicationBuilder() .Use((ctx, nxt) => { + Log.StaticAssetNotFoundInManifest(logger, ctx.Request.Path); + ctx.SetEndpoint(null); ctx.Response.OnStarting((context) => { @@ -197,83 +203,49 @@ internal static void EnableSupport( }) .UseStaticFiles() .Build()); - } - - } -} -internal static class StaticAssetDescriptorExtensions -{ - internal static long GetContentLength(this StaticAssetDescriptor descriptor) - { - foreach (var header in descriptor.ResponseHeaders) - { - if (header.Name == "Content-Length") - { - return long.Parse(header.Value, CultureInfo.InvariantCulture); - } + // 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"]))); } - - throw new InvalidOperationException("Content-Length header not found."); } - internal static DateTimeOffset GetLastModified(this StaticAssetDescriptor descriptor) + private static partial class Log { - foreach (var header in descriptor.ResponseHeaders) - { - if (header.Name == "Last-Modified") - { - return DateTimeOffset.Parse(header.Value, CultureInfo.InvariantCulture); - } - } + 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."""; - throw new InvalidOperationException("Last-Modified header not found."); + [LoggerMessage(1, LogLevel.Warning, StaticAssetNotFoundInManifestMessage)] + public static partial void StaticAssetNotFoundInManifest(ILogger logger, string path); } - internal static EntityTagHeaderValue GetWeakETag(this StaticAssetDescriptor descriptor) + private sealed class FileExistsConstraint(IWebHostEnvironment environment) : IRouteConstraint { - foreach (var header in descriptor.ResponseHeaders) - { - if (header.Name == "ETag") - { - var eTag = EntityTagHeaderValue.Parse(header.Value); - if (eTag.IsWeak) - { - return eTag; - } - } - } + private readonly IWebHostEnvironment _environment = environment; - throw new InvalidOperationException("ETag header not found."); - } - - internal static bool HasContentEncoding(this StaticAssetDescriptor descriptor) - { - foreach (var selector in descriptor.Selectors) + public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { - if (selector.Name == "Content-Encoding") + if (values[routeKey] is not string path) { - return true; + return false; } - } - return false; - } - - internal static bool HasETag(this StaticAssetDescriptor descriptor, string tag) - { - foreach (var header in descriptor.ResponseHeaders) - { - if (header.Name == "ETag") - { - var eTag = EntityTagHeaderValue.Parse(header.Value); - if (!eTag.IsWeak && eTag.Tag == tag) - { - return true; - } - } + var fileInfo = _environment.WebRootFileProvider.GetFileInfo(path); + return fileInfo.Exists; } - - return false; } } diff --git a/src/StaticAssets/src/PublicAPI.Unshipped.txt b/src/StaticAssets/src/PublicAPI.Unshipped.txt index 4da6216519b5..695ee73ed65d 100644 --- a/src/StaticAssets/src/PublicAPI.Unshipped.txt +++ b/src/StaticAssets/src/PublicAPI.Unshipped.txt @@ -7,4 +7,4 @@ Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.DefaultBuilder. Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.ManifestName.get -> string! override Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.Endpoints.get -> System.Collections.Generic.IReadOnlyList! override Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.GetChangeToken() -> Microsoft.Extensions.Primitives.IChangeToken! -static Microsoft.AspNetCore.Builder.StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssetEndpoints(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string? staticAssetsManifestPath = null) -> Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder! \ No newline at end of file +static Microsoft.AspNetCore.Builder.StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssets(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string? staticAssetsManifestPath = null) -> Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder! \ No newline at end of file diff --git a/src/StaticAssets/src/StaticAssetEndpointDataSource.cs b/src/StaticAssets/src/StaticAssetEndpointDataSource.cs index f8f6391f0f0b..f9bec8607779 100644 --- a/src/StaticAssets/src/StaticAssetEndpointDataSource.cs +++ b/src/StaticAssets/src/StaticAssetEndpointDataSource.cs @@ -46,7 +46,7 @@ internal StaticAssetsEndpointDataSource(StaticAssetsManifest manifest, StaticAss public string ManifestName => _manifestName; /// - public StaticAssetsEndpointConventionBuilder DefaultBuilder { get; internal set; } + internal StaticAssetsEndpointConventionBuilder DefaultBuilder { get; set; } /// public override IReadOnlyList Endpoints diff --git a/src/StaticAssets/src/StaticAssetsEndpointConventionBuilder.cs b/src/StaticAssets/src/StaticAssetsEndpointConventionBuilder.cs index 7f4a0583afb1..cd5275aff9db 100644 --- a/src/StaticAssets/src/StaticAssetsEndpointConventionBuilder.cs +++ b/src/StaticAssets/src/StaticAssetsEndpointConventionBuilder.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.StaticAssets; /// /// A builder for configuring conventions for static assets. /// -public class StaticAssetsEndpointConventionBuilder : IEndpointConventionBuilder +public sealed class StaticAssetsEndpointConventionBuilder : IEndpointConventionBuilder { private readonly object _lck; private readonly List _descriptors; diff --git a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs index 33edd9e0d8e3..d0e423c88203 100644 --- a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs +++ b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs @@ -23,7 +23,7 @@ public static class StaticAssetsEndpointRouteBuilderExtensions /// 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 MapStaticAssetEndpoints(this IEndpointRouteBuilder endpoints, string? staticAssetsManifestPath = null) + public static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndpointRouteBuilder endpoints, string? staticAssetsManifestPath = null) { ArgumentNullException.ThrowIfNull(endpoints); diff --git a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs index f8a49185fe5d..55bd6b142492 100644 --- a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs +++ b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs @@ -51,7 +51,7 @@ public async Task CanServeAssetsFromManifestAsync() app.UseRouting(); app.UseEndpoints(endpoints => { - endpoints.MapStaticAssetEndpoints(); + endpoints.MapStaticAssets(); }); await app.StartAsync(); @@ -101,7 +101,7 @@ public async Task CanServeNewFilesAddedAfterBuildDuringDevelopment() app.UseRouting(); app.UseEndpoints(endpoints => { - endpoints.MapStaticAssetEndpoints(); + endpoints.MapStaticAssets(); }); await app.StartAsync(); @@ -156,7 +156,7 @@ public async Task CanModifyAssetsOnTheFlyInDevelopment() app.UseRouting(); app.UseEndpoints(endpoints => { - endpoints.MapStaticAssetEndpoints(); + endpoints.MapStaticAssets(); }); await app.StartAsync(); @@ -209,7 +209,7 @@ public async Task CanModifyAssetsWithCompressedVersionsOnTheFlyInDevelopment() app.UseRouting(); app.UseEndpoints(endpoints => { - endpoints.MapStaticAssetEndpoints(); + endpoints.MapStaticAssets(); }); await app.StartAsync(); From fd9ecc2ac35f0a6ba3251b25570282ea6bdcc099 Mon Sep 17 00:00:00 2001 From: jacalvar Date: Fri, 10 May 2024 12:58:00 +0200 Subject: [PATCH 07/13] More API cleanups, make StaticAssetsDataSource internal --- src/Components/Components.slnf | 3 ++ .../Server/Startup.cs | 2 +- ...entsEndpointConventionBuilderExtensions.cs | 37 +++++++++++++----- src/Http/Routing/src/PublicAPI.Unshipped.txt | 4 -- .../StaticAssetsEndpointDataSourceHelper.cs | 39 +++++++++++++++++++ src/StaticAssets/src/PublicAPI.Unshipped.txt | 11 ++---- .../src/StaticAssetEndpointDataSource.cs | 17 +++++--- .../src/StaticAssetEndpointFactory.cs | 1 - ...ticAssetsEndpointRouteBuilderExtensions.cs | 2 +- src/StaticAssets/src/StaticAssetsManifest.cs | 2 +- 10 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs diff --git a/src/Components/Components.slnf b/src/Components/Components.slnf index 42e825f732d1..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", diff --git a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/Startup.cs b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/Startup.cs index 8af3e63b6622..10e942763938 100644 --- a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/Startup.cs +++ b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/Startup.cs @@ -56,7 +56,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { endpoints.MapRazorPages(); endpoints.MapControllers(); - endpoints.MapStaticAssetEndpoints(); + endpoints.MapStaticAssets(); endpoints.MapFallbackToPage("/_Host"); }); } diff --git a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs index 9aefce5b1612..493ce8dad245 100644 --- a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs @@ -1,22 +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 Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.StaticAssets; +using Microsoft.AspNetCore.StaticAssets.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using System.Linq; +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. @@ -55,16 +56,26 @@ public static RazorComponentsEndpointConventionBuilder AddInteractiveWebAssembly // 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. - var staticAssetsManifestPath = options.StaticAssetsManifestPath ?? Path.Combine(AppContext.BaseDirectory, $"{environment.ApplicationName}.staticwebassets.endpoints.json"); - staticAssetsManifestPath = Path.IsPathRooted(staticAssetsManifestPath) ? staticAssetsManifestPath : Path.Combine(AppContext.BaseDirectory, staticAssetsManifestPath); - if (HasStaticAssetDataSource(endpointBuilder, staticAssetsManifestPath)) + if (HasStaticAssetDataSource(endpointBuilder, options.StaticAssetsManifestPath)) { options.ConventionsApplied = true; - endpointBuilder.MapStaticAssets(staticAssetsManifestPath) + endpointBuilder.MapStaticAssets(options.StaticAssetsManifestPath) .AddBlazorWebAssemblyConventions(); return builder; } + else if (environment.IsDevelopment()) + { + var logger = endpointBuilder.ServiceProvider.GetRequiredService>(); + if (options.StaticAssetsManifestPath is null) + { + Log.StaticAssetsMappingNotFoundForDefaultManifest(logger); + } + else + { + Log.StaticAssetsMappingNotFoundWithManifest(logger, options.StaticAssetsManifestPath); + } + } return builder; } @@ -73,8 +84,7 @@ private static bool HasStaticAssetDataSource(IEndpointRouteBuilder endpointRoute { foreach (var ds in endpointRouteBuilder.DataSources) { - if (ds is StaticAssetsEndpointDataSource staticAssetsDataSource && - string.Equals(Path.GetFileName(staticAssetsDataSource.ManifestName), staticAssetsManifestName, StringComparison.OrdinalIgnoreCase)) + if (StaticAssetsEndpointDataSourceHelper.IsStaticAssetsDataSource(ds, staticAssetsManifestName)) { return true; } @@ -82,4 +92,13 @@ private static bool HasStaticAssetDataSource(IEndpointRouteBuilder endpointRoute 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/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 387c21e32872..1d1131ea95bf 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -3,8 +3,4 @@ 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! -Microsoft.AspNetCore.Routing.Matching.ContentEncodingMetadata -Microsoft.AspNetCore.Routing.Matching.ContentEncodingMetadata.ContentEncodingMetadata(string! value, double quality) -> void -Microsoft.AspNetCore.Routing.Matching.ContentEncodingMetadata.Quality.get -> double -Microsoft.AspNetCore.Routing.Matching.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/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs b/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs new file mode 100644 index 000000000000..3a0ea3a647b2 --- /dev/null +++ b/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs @@ -0,0 +1,39 @@ +// 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; + +/// +/// This type is not recommended for use outside of ASP.NET Core. +/// +public static class StaticAssetsEndpointDataSourceHelper +{ + /// + /// This method is not recommended for use outside of ASP.NET Core. + /// + /// + /// + + 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/PublicAPI.Unshipped.txt b/src/StaticAssets/src/PublicAPI.Unshipped.txt index 695ee73ed65d..8cf8331aa2ad 100644 --- a/src/StaticAssets/src/PublicAPI.Unshipped.txt +++ b/src/StaticAssets/src/PublicAPI.Unshipped.txt @@ -1,10 +1,7 @@ -Microsoft.AspNetCore.Builder.StaticAssetsEndpointRouteBuilderExtensions +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 -Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource -Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.DefaultBuilder.get -> Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder! -Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.ManifestName.get -> string! -override Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.Endpoints.get -> System.Collections.Generic.IReadOnlyList! -override Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointDataSource.GetChangeToken() -> Microsoft.Extensions.Primitives.IChangeToken! -static Microsoft.AspNetCore.Builder.StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssets(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string? staticAssetsManifestPath = null) -> Microsoft.AspNetCore.StaticAssets.StaticAssetsEndpointConventionBuilder! \ No newline at end of file +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/StaticAssetEndpointDataSource.cs b/src/StaticAssets/src/StaticAssetEndpointDataSource.cs index f9bec8607779..8c8968dc19ed 100644 --- a/src/StaticAssets/src/StaticAssetEndpointDataSource.cs +++ b/src/StaticAssets/src/StaticAssetEndpointDataSource.cs @@ -13,11 +13,10 @@ namespace Microsoft.AspNetCore.StaticAssets; /// An for static assets. /// [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -public class StaticAssetsEndpointDataSource : EndpointDataSource +internal class StaticAssetsEndpointDataSource : EndpointDataSource { private readonly object _lock = new(); private readonly StaticAssetsManifest _manifest; - private readonly string _manifestName; private readonly StaticAssetEndpointFactory _endpointFactory; private readonly List> _conventions = []; private readonly List> _finallyConventions = []; @@ -25,10 +24,11 @@ public class StaticAssetsEndpointDataSource : EndpointDataSource private CancellationTokenSource _cancellationTokenSource; private CancellationChangeToken _changeToken; - internal StaticAssetsEndpointDataSource(StaticAssetsManifest manifest, StaticAssetEndpointFactory endpointFactory, string manifestName, List descriptors) + internal StaticAssetsEndpointDataSource(IServiceProvider serviceProvider, StaticAssetsManifest manifest, StaticAssetEndpointFactory endpointFactory, string manifestName, List descriptors) { + ServiceProvider = serviceProvider; _manifest = manifest; - _manifestName = manifestName; + ManifestPath = manifestName; _endpointFactory = endpointFactory; _cancellationTokenSource = new CancellationTokenSource(); _changeToken = new CancellationChangeToken(_cancellationTokenSource.Token); @@ -43,7 +43,7 @@ internal StaticAssetsEndpointDataSource(StaticAssetsManifest manifest, StaticAss /// /// Gets the manifest name associated with this static asset endpoint data source. /// - public string ManifestName => _manifestName; + public string ManifestPath { get; } /// internal StaticAssetsEndpointConventionBuilder DefaultBuilder { get; set; } @@ -67,6 +67,8 @@ public override IReadOnlyList Endpoints } } + internal IServiceProvider ServiceProvider { get; } + private void Initialize() { if (_endpoints == null) @@ -110,5 +112,8 @@ public override IChangeToken GetChangeToken() return _changeToken; } - private string GetDebuggerDisplay() => _manifestName; + private string GetDebuggerDisplay() + { + return ManifestPath; + } } diff --git a/src/StaticAssets/src/StaticAssetEndpointFactory.cs b/src/StaticAssets/src/StaticAssetEndpointFactory.cs index a637deae7417..9340fe9cf33a 100644 --- a/src/StaticAssets/src/StaticAssetEndpointFactory.cs +++ b/src/StaticAssets/src/StaticAssetEndpointFactory.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Matching; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs index d0e423c88203..572dedaaf262 100644 --- a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs +++ b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs @@ -51,7 +51,7 @@ private static StaticAssetsEndpointConventionBuilder MapStaticAssetEndpointsCore { foreach (var ds in endpoints.DataSources) { - if (ds is StaticAssetsEndpointDataSource sads && sads.ManifestName.Equals(manifestPath, StringComparison.Ordinal)) + if (ds is StaticAssetsEndpointDataSource sads && sads.ManifestPath.Equals(manifestPath, StringComparison.Ordinal)) { return sads.DefaultBuilder; } diff --git a/src/StaticAssets/src/StaticAssetsManifest.cs b/src/StaticAssets/src/StaticAssetsManifest.cs index 668659917629..0bb70b10246e 100644 --- a/src/StaticAssets/src/StaticAssetsManifest.cs +++ b/src/StaticAssets/src/StaticAssetsManifest.cs @@ -37,7 +37,7 @@ internal static StaticAssetsManifest Parse(string manifestPath) internal StaticAssetsEndpointDataSource CreateDataSource(IEndpointRouteBuilder endpoints, string manifestName, List descriptors) { - var dataSource = new StaticAssetsEndpointDataSource(this, new StaticAssetEndpointFactory(endpoints.ServiceProvider), manifestName, descriptors); + var dataSource = new StaticAssetsEndpointDataSource(endpoints.ServiceProvider, this, new StaticAssetEndpointFactory(endpoints.ServiceProvider), manifestName, descriptors); endpoints.DataSources.Add(dataSource); return dataSource; } From 9f8995a212f84663f3350ea06c27097b401ee2fe Mon Sep 17 00:00:00 2001 From: jacalvar Date: Fri, 10 May 2024 14:41:22 +0200 Subject: [PATCH 08/13] Missing API changes --- .../content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs | 2 +- .../content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs | 2 +- .../content/RazorPagesWeb-CSharp/Program.Main.cs | 2 +- .../content/RazorPagesWeb-CSharp/Program.cs | 2 +- .../content/StarterWeb-CSharp/Program.Main.cs | 2 +- .../Web.ProjectTemplates/content/StarterWeb-CSharp/Program.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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 eafd61ee0a3a..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 @@ -110,7 +110,7 @@ public static void Main(string[] args) #endif app.UseAntiforgery(); - app.MapStaticAssetEndpoints(); + 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 1ab25ff9f0ee..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 @@ -105,7 +105,7 @@ app.UseAntiforgery(); -app.MapStaticAssetEndpoints(); +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 6fc59d5c8404..3d1056838dc8 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.Main.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.Main.cs @@ -141,7 +141,7 @@ public static void Main(string[] args) app.UseAuthorization(); - app.MapStaticAssetEndpoints(); + 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 a425bacd8d23..289247647714 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Program.cs @@ -135,7 +135,7 @@ app.UseAuthorization(); -app.MapStaticAssetEndpoints(); +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 5510c310c091..b386e6f6840c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.Main.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.Main.cs @@ -142,7 +142,7 @@ public static void Main(string[] args) app.UseAuthorization(); - app.MapStaticAssetEndpoints(); + 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 e144f103821e..f8425822e284 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Program.cs @@ -136,7 +136,7 @@ app.UseAuthorization(); -app.MapStaticAssetEndpoints(); +app.MapStaticAssets(); app.MapControllerRoute( name: "default", From 8915b7f6a17081713da5345caf0075c5cc747db7 Mon Sep 17 00:00:00 2001 From: jacalvar Date: Fri, 10 May 2024 15:14:48 +0200 Subject: [PATCH 09/13] Remove a few more internal 'MapStaticAssetEndpoints' instances --- .../src/WebAssemblyRazorComponentsBuilderExtensions.cs | 2 +- .../src/StaticAssetsEndpointRouteBuilderExtensions.cs | 8 ++++---- src/StaticAssets/test/StaticAssetsIntegrationTests.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs index af4b1af86b00..f390f78cb889 100644 --- a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs @@ -46,7 +46,7 @@ public override IEnumerable GetEndpointBuilders(IComponent } else { - // In case the app didn't call MapStaticAssetEndpoints, use the 8.0 approach to map the assets. + // 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; diff --git a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs index 572dedaaf262..55c9763dfd43 100644 --- a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs +++ b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs @@ -34,7 +34,7 @@ public static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndpoi Path.Combine(AppContext.BaseDirectory, staticAssetsManifestPath) : staticAssetsManifestPath; - var result = MapStaticAssetEndpointsCore(endpoints, staticAssetsManifestPath); + var result = MapStaticAssetsCore(endpoints, staticAssetsManifestPath); if (StaticAssetDevelopmentRuntimeHandler.IsEnabled(endpoints.ServiceProvider, environment)) { @@ -44,7 +44,7 @@ public static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndpoi return result; } - private static StaticAssetsEndpointConventionBuilder MapStaticAssetEndpointsCore( + private static StaticAssetsEndpointConventionBuilder MapStaticAssetsCore( IEndpointRouteBuilder endpoints, string manifestPath, StaticAssetsManifest? manifest = null) @@ -69,12 +69,12 @@ private static StaticAssetsEndpointConventionBuilder MapStaticAssetEndpointsCore } // For testing purposes - internal static StaticAssetsEndpointConventionBuilder MapStaticAssetEndpoints(this IEndpointRouteBuilder endpoints, StaticAssetsManifest manifest) + internal static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndpointRouteBuilder endpoints, StaticAssetsManifest manifest) { ArgumentNullException.ThrowIfNull(endpoints); var environment = endpoints.ServiceProvider.GetRequiredService(); - var result = MapStaticAssetEndpointsCore(endpoints, "unused", manifest); + var result = MapStaticAssetsCore(endpoints, "unused", manifest); if (StaticAssetDevelopmentRuntimeHandler.IsEnabled(endpoints.ServiceProvider, environment)) { diff --git a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs index 55bd6b142492..06e7f512ed1e 100644 --- a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs +++ b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs @@ -387,7 +387,7 @@ private static async Task CreateClient() app.UseRouting(); app.UseEndpoints(endpoints => { - endpoints.MapStaticAssetEndpoints(manifest); + endpoints.MapStaticAssets(manifest); }); await app.StartAsync(); From 3e9dbc896a485595ba87ae81409fe44aaaf2ea8c Mon Sep 17 00:00:00 2001 From: jacalvar Date: Fri, 10 May 2024 15:34:28 +0200 Subject: [PATCH 10/13] Additional tests for 406 behavior --- ...entEncodingNegotiationMatcherPolicyTest.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs b/src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs index 5d866880a6ee..9b9954343ebb 100644 --- a/src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs +++ b/src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs @@ -85,6 +85,7 @@ public async Task ApplyAsync_SelectsEndpointWithContentEncodingMetadata_IfAccept // Assert Assert.True(endpoints.IsValidCandidate(0)); Assert.False(endpoints.IsValidCandidate(1)); + Assert.Null(httpContext.GetEndpoint()); } [Fact] @@ -104,6 +105,7 @@ public async Task ApplyAsync_SelectsEndpointWihtoutEncodingMetadata_IfAcceptHead // Assert Assert.False(endpoints.IsValidCandidate(0)); Assert.True(endpoints.IsValidCandidate(1)); + Assert.Null(httpContext.GetEndpoint()); } [Fact] @@ -123,6 +125,7 @@ public async Task ApplyAsync_SelectsEndpointWihtoutEncodingMetadata_IfAcceptHead // Assert Assert.True(endpoints.IsValidCandidate(0)); Assert.False(endpoints.IsValidCandidate(1)); + Assert.Null(httpContext.GetEndpoint()); } [Fact] @@ -142,6 +145,7 @@ public async Task ApplyAsync_SelectsEndpointWithHigherAcceptEncodingQuality_IfHe // Assert Assert.False(endpoints.IsValidCandidate(0)); Assert.True(endpoints.IsValidCandidate(1)); + Assert.Null(httpContext.GetEndpoint()); } [Fact] @@ -161,6 +165,7 @@ public async Task ApplyAsync_SelectsEndpointWithHigherAcceptEncodingQuality_IfHe // Assert Assert.False(endpoints.IsValidCandidate(1)); Assert.True(endpoints.IsValidCandidate(0)); + Assert.Null(httpContext.GetEndpoint()); } [Fact] @@ -182,6 +187,86 @@ public async Task ApplyAsync_SelectsEndpointWithHigherContentEncodingMetadataQua 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() { From 11696f9e679a4a1c27e9e0f1f35e4d0443413bca Mon Sep 17 00:00:00 2001 From: jacalvar Date: Fri, 10 May 2024 18:36:13 +0200 Subject: [PATCH 11/13] Fix tests --- src/StaticAssets/test/StaticAssetsIntegrationTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs index 06e7f512ed1e..0fd3267487a7 100644 --- a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs +++ b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs @@ -465,7 +465,7 @@ public async Task IfMatchShouldBeIgnoredForUnsupportedMethods(HttpMethod method) var req = new HttpRequestMessage(method, "http://localhost/sample.txt"); req.Headers.Add("If-Match", "*"); var resp = await client.SendAsync(req); - Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, resp.StatusCode); } // 14.26 If-None-Match @@ -518,7 +518,7 @@ public async Task IfNoneMatchShouldBeIgnoredForNonTwoHundredAnd304Responses(Http 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.NotFound, resp2.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, resp2.StatusCode); } // 14.26 If-None-Match From f89d513b40e87bca68f1b8fa0f14e718aa203c42 Mon Sep 17 00:00:00 2001 From: jacalvar Date: Mon, 13 May 2024 13:58:48 +0200 Subject: [PATCH 12/13] Address feedback --- .../Server/appsettings.Development.json | 1 - ...zorComponentsEndpointConventionBuilderExtensions.cs | 5 +++-- ...yStaticAssetsEndpointConventionBuilderExtensions.cs | 5 +++-- .../ContentEncodingNegotiationMatcherPolicy.cs | 9 ++++++--- .../Routing/src/Matching/NegotiationMatcherPolicy.cs | 4 ++-- .../src/Development/StaticAssetDescriptorExtensions.cs | 10 +++++----- .../StaticAssetDevelopmentRuntimeHandler.cs | 8 +++++--- src/StaticAssets/src/EndpointProperty.cs | 2 +- .../StaticAssetsEndpointDataSourceHelper.cs | 7 ++----- src/StaticAssets/src/ResponseHeader.cs | 4 ++-- src/StaticAssets/src/StaticAssetDescriptor.cs | 4 ++-- src/StaticAssets/src/StaticAssetSelector.cs | 4 ++-- .../src/StaticAssetsEndpointRouteBuilderExtensions.cs | 3 +-- src/StaticAssets/src/StaticAssetsInvoker.cs | 6 ++---- src/StaticAssets/test/StaticAssetsIntegrationTests.cs | 6 +++--- 15 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/appsettings.Development.json b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/appsettings.Development.json index ab87f61c05f0..8983e0fc1c5e 100644 --- a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/appsettings.Development.json +++ b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Server/appsettings.Development.json @@ -1,5 +1,4 @@ { - "HotReloadStaticAssets": true, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs index 493ce8dad245..dd81603a225f 100644 --- a/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs @@ -52,7 +52,6 @@ public static RazorComponentsEndpointConventionBuilder AddInteractiveWebAssembly ComponentEndpointConventionBuilderHelper.AddRenderMode(builder, new WebAssemblyRenderModeWithOptions(options)); var endpointBuilder = ComponentEndpointConventionBuilderHelper.GetEndpointRouteBuilder(builder); - var environment = endpointBuilder.ServiceProvider.GetRequiredService(); // 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. @@ -64,7 +63,9 @@ public static RazorComponentsEndpointConventionBuilder AddInteractiveWebAssembly return builder; } - else if (environment.IsDevelopment()) + + var environment = endpointBuilder.ServiceProvider.GetRequiredService(); + if (environment.IsDevelopment()) { var logger = endpointBuilder.ServiceProvider.GetRequiredService>(); if (options.StaticAssetsManifestPath is null) diff --git a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs index 3a2515d2ff6c..841c81a8cef2 100644 --- a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.StaticAssets; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Components.WebAssembly.Server; @@ -62,7 +63,7 @@ private static void WrapEndpoint(EndpointBuilder endpoint) { var webHostEnvironment = context.RequestServices.GetRequiredService(); context.Response.Headers.Add("Blazor-Environment", webHostEnvironment.EnvironmentName); - context.Response.Headers.Add("Cache-Control", "no-cache"); + 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). @@ -83,5 +84,5 @@ private static void WrapEndpoint(EndpointBuilder endpoint) }; } - private sealed class WebAssemblyConventionsAppliedMetadata { }; + private sealed class WebAssemblyConventionsAppliedMetadata; } diff --git a/src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs b/src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs index bc902fce47f8..3275c760a1c0 100644 --- a/src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs +++ b/src/Http/Routing/src/Matching/ContentEncodingNegotiationMatcherPolicy.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Routing.Matching; -internal class ContentEncodingNegotiationMatcherPolicy : NegotiationMatcherPolicy +internal sealed class ContentEncodingNegotiationMatcherPolicy : NegotiationMatcherPolicy { internal static string HeaderName => "Accept-Encoding"; @@ -28,7 +28,7 @@ private protected override bool IsDefaultMetadataValue(ReadOnlySpan candid private protected override NegotiationPolicyJumpTable CreateTable(int exitDestination, (string negotiationValue, double quality, int destination)[] destinations, int noNegotiationHeaderDestination) => new ContentEncodingPolicyJumpTable(exitDestination, noNegotiationHeaderDestination, new ContentEncodingDestinationsLookUp(destinations)); - internal class ContentEncodingPolicyJumpTable(int anyContentEncodingDestination, int noContentEncodingDestination, ContentEncodingDestinationsLookUp destinations) : NegotiationPolicyJumpTable("Accept-Encoding", anyContentEncodingDestination, noContentEncodingDestination) + internal sealed class ContentEncodingPolicyJumpTable(int anyContentEncodingDestination, int noContentEncodingDestination, ContentEncodingDestinationsLookUp destinations) : NegotiationPolicyJumpTable("Accept-Encoding", anyContentEncodingDestination, noContentEncodingDestination) { private readonly ContentEncodingDestinationsLookUp _destinations = destinations; @@ -37,7 +37,7 @@ internal class ContentEncodingPolicyJumpTable(int anyContentEncodingDestination, protected override double GetQuality(string? value) => _destinations.GetValueQuality(value); } - internal class ContentEncodingDestinationsLookUp + internal sealed class ContentEncodingDestinationsLookUp { private readonly int _brotliDestination = -1; private readonly double _brotliQuality; @@ -76,6 +76,9 @@ public ContentEncodingDestinationsLookUp((string contentEncoding, double quality 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), diff --git a/src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs b/src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs index 862b513d513e..f14c9a041c30 100644 --- a/src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs +++ b/src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs @@ -198,8 +198,8 @@ private void EvaluateCandidate( } // Explainer: - // This is responsible for building the branches in the DFA that will be used to match the - // based on the Accept-Encoding header of the request. + // 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 ] diff --git a/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs b/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs index 2085f026afe8..0863e08ce436 100644 --- a/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs +++ b/src/StaticAssets/src/Development/StaticAssetDescriptorExtensions.cs @@ -13,7 +13,7 @@ internal static long GetContentLength(this StaticAssetDescriptor descriptor) { foreach (var header in descriptor.ResponseHeaders) { - if (header.Name == "Content-Length") + if (string.Equals(header.Name, HeaderNames.ContentLength, StringComparison.OrdinalIgnoreCase)) { return long.Parse(header.Value, CultureInfo.InvariantCulture); } @@ -26,7 +26,7 @@ internal static DateTimeOffset GetLastModified(this StaticAssetDescriptor descri { foreach (var header in descriptor.ResponseHeaders) { - if (header.Name == "Last-Modified") + if (string.Equals(header.Name, HeaderNames.LastModified, StringComparison.OrdinalIgnoreCase)) { return DateTimeOffset.Parse(header.Value, CultureInfo.InvariantCulture); } @@ -39,7 +39,7 @@ internal static EntityTagHeaderValue GetWeakETag(this StaticAssetDescriptor desc { foreach (var header in descriptor.ResponseHeaders) { - if (header.Name == "ETag") + if (string.Equals(header.Name, HeaderNames.ETag, StringComparison.OrdinalIgnoreCase)) { var eTag = EntityTagHeaderValue.Parse(header.Value); if (eTag.IsWeak) @@ -56,7 +56,7 @@ internal static bool HasContentEncoding(this StaticAssetDescriptor descriptor) { foreach (var selector in descriptor.Selectors) { - if (selector.Name == "Content-Encoding") + if (string.Equals(selector.Name, HeaderNames.ContentEncoding, StringComparison.OrdinalIgnoreCase)) { return true; } @@ -69,7 +69,7 @@ internal static bool HasETag(this StaticAssetDescriptor descriptor, string tag) { foreach (var header in descriptor.ResponseHeaders) { - if (header.Name == "ETag") + if (string.Equals(header.Name, HeaderNames.ETag, StringComparison.OrdinalIgnoreCase)) { var eTag = EntityTagHeaderValue.Parse(header.Value); if (!eTag.IsWeak && eTag.Tag == tag) diff --git a/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs b/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs index a114801a4fe0..95d59b6437e3 100644 --- a/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs +++ b/src/StaticAssets/src/Development/StaticAssetDevelopmentRuntimeHandler.cs @@ -25,6 +25,8 @@ 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!; @@ -125,7 +127,7 @@ public Task SendFileAsync(string path, long offset, long? count, CancellationTok { // 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, HotReloadStaticAsset will update it. + // 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); @@ -157,7 +159,7 @@ private static StaticAssetDescriptor FindOriginalAsset(string tag, List(); - var explicitlyConfigured = bool.TryParse(config["HotReloadStaticAssets"], out var hotReload); + var explicitlyConfigured = bool.TryParse(config[ReloadStaticAssetsAtRuntimeKey], out var hotReload); return (!explicitlyConfigured && environment.IsDevelopment()) || (explicitlyConfigured && hotReload); } @@ -170,7 +172,7 @@ internal static void EnableSupport( var config = endpoints.ServiceProvider.GetRequiredService(); var hotReloadHandler = new StaticAssetDevelopmentRuntimeHandler(descriptors); builder.Add(hotReloadHandler.AttachRuntimePatching); - var disableFallback = bool.TryParse(config["DisableStaticAssetFallback"], out var disableFallbackValue) && disableFallbackValue; + var disableFallback = bool.TryParse(config["DisableStaticAssetNotFoundRuntimeFallback"], out var disableFallbackValue) && disableFallbackValue; if (!disableFallback) { diff --git a/src/StaticAssets/src/EndpointProperty.cs b/src/StaticAssets/src/EndpointProperty.cs index 579c34a0e04e..944dcd61ca6c 100644 --- a/src/StaticAssets/src/EndpointProperty.cs +++ b/src/StaticAssets/src/EndpointProperty.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.StaticAssets; // Represents a property of an endpoint. [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -internal class EndpointProperty(string name, string value) +internal sealed class EndpointProperty(string name, string value) { public string Name { get; } = name; public string Value { get; } = value; diff --git a/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs b/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs index 3a0ea3a647b2..a89d556ac496 100644 --- a/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs +++ b/src/StaticAssets/src/Infrastructure/StaticAssetsEndpointDataSourceHelper.cs @@ -8,16 +8,13 @@ namespace Microsoft.AspNetCore.StaticAssets.Infrastructure; /// -/// This type is not recommended for use outside of ASP.NET Core. +/// For internal framework use only. /// public static class StaticAssetsEndpointDataSourceHelper { /// - /// This method is not recommended for use outside of ASP.NET Core. + /// For internal framework use only. /// - /// - /// - public static bool IsStaticAssetsDataSource(EndpointDataSource dataSource, string? staticAssetsManifestPath = null) { if (dataSource is StaticAssetsEndpointDataSource staticAssetsDataSource) diff --git a/src/StaticAssets/src/ResponseHeader.cs b/src/StaticAssets/src/ResponseHeader.cs index c21ae72dba90..0c57a463331c 100644 --- a/src/StaticAssets/src/ResponseHeader.cs +++ b/src/StaticAssets/src/ResponseHeader.cs @@ -7,10 +7,10 @@ namespace Microsoft.AspNetCore.StaticAssets; // Represents a response header for a static resource. [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -internal class ResponseHeader(string name, string value) +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}"; + private string GetDebuggerDisplay() => $"Name: {Name} Value: {Value}"; } diff --git a/src/StaticAssets/src/StaticAssetDescriptor.cs b/src/StaticAssets/src/StaticAssetDescriptor.cs index c6b9e1647594..c34150117c82 100644 --- a/src/StaticAssets/src/StaticAssetDescriptor.cs +++ b/src/StaticAssets/src/StaticAssetDescriptor.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.StaticAssets; // Represents a static resource. [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -internal class StaticAssetDescriptor( +internal sealed class StaticAssetDescriptor( string route, string assetFile, StaticAssetSelector[] selectors, @@ -22,6 +22,6 @@ internal class StaticAssetDescriptor( private string GetDebuggerDisplay() { - return $"Route: {Route} Path:{AssetFile}"; + return $"Route: {Route} Path: {AssetFile}"; } } diff --git a/src/StaticAssets/src/StaticAssetSelector.cs b/src/StaticAssets/src/StaticAssetSelector.cs index dc69f321e738..e155e387fbf9 100644 --- a/src/StaticAssets/src/StaticAssetSelector.cs +++ b/src/StaticAssets/src/StaticAssetSelector.cs @@ -7,11 +7,11 @@ namespace Microsoft.AspNetCore.StaticAssets; // Represents a selector for a static resource. [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -internal class StaticAssetSelector(string name, string value, string quality) +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}"; + private string GetDebuggerDisplay() => $"Name: {Name} Value: {Value} Quality: {Quality}"; } diff --git a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs index 55c9763dfd43..d4ef757ea9da 100644 --- a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs +++ b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs @@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Builder; public static class StaticAssetsEndpointRouteBuilderExtensions { /// - /// Maps static files produced during the build as endpoints. + /// Maps static files produced during the build as endpoints.. /// /// The . /// The path to the static assets manifest file. @@ -83,5 +83,4 @@ internal static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndp return result; } - } diff --git a/src/StaticAssets/src/StaticAssetsInvoker.cs b/src/StaticAssets/src/StaticAssetsInvoker.cs index 9e94663044de..e43ed498c6c4 100644 --- a/src/StaticAssets/src/StaticAssetsInvoker.cs +++ b/src/StaticAssets/src/StaticAssetsInvoker.cs @@ -144,10 +144,8 @@ public async Task Invoke(HttpContext context) await SendRangeAsync(requestContext, range); return; } - else - { - context.Response.ContentLength = _length; - } + + context.Response.ContentLength = _length; await SendAsync(requestContext); _logger.FileServed(Route, PhysicalPath); diff --git a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs index 0fd3267487a7..9314865bd764 100644 --- a/src/StaticAssets/test/StaticAssetsIntegrationTests.cs +++ b/src/StaticAssets/test/StaticAssetsIntegrationTests.cs @@ -90,7 +90,7 @@ public async Task CanServeNewFilesAddedAfterBuildDuringDevelopment() WebRootPath = webRoot }); - builder.WebHost.UseSetting("HotReloadStaticAssets", "true"); + builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true"); builder.WebHost.ConfigureServices(services => { services.AddRouting(); @@ -145,7 +145,7 @@ public async Task CanModifyAssetsOnTheFlyInDevelopment() EnvironmentName = "Development", WebRootPath = webRoot }); - builder.WebHost.UseSetting("HotReloadStaticAssets", "true"); + builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true"); builder.WebHost.ConfigureServices(services => { services.AddRouting(); @@ -198,7 +198,7 @@ public async Task CanModifyAssetsWithCompressedVersionsOnTheFlyInDevelopment() EnvironmentName = "Development", WebRootPath = webRoot }); - builder.WebHost.UseSetting("HotReloadStaticAssets", "true"); + builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true"); builder.WebHost.ConfigureServices(services => { services.AddRouting(); From e5668c5f213d7a27504ae7b1a5ad2a9db77b59cb Mon Sep 17 00:00:00 2001 From: jacalvar Date: Mon, 13 May 2024 16:02:30 +0200 Subject: [PATCH 13/13] Remaining feedback --- ...ticAssetsEndpointRouteBuilderExtensions.cs | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs index d4ef757ea9da..3ede03b92212 100644 --- a/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs +++ b/src/StaticAssets/src/StaticAssetsEndpointRouteBuilderExtensions.cs @@ -10,12 +10,12 @@ namespace Microsoft.AspNetCore.Builder; /// -/// Contains methods to integrate static assets with endpoints +/// Contains methods to integrate static assets with endpoints. /// public static class StaticAssetsEndpointRouteBuilderExtensions { /// - /// Maps static files produced during the build as endpoints.. + /// Maps static files produced during the build as endpoints. /// /// The . /// The path to the static assets manifest file. @@ -46,26 +46,41 @@ public static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndpoi private static StaticAssetsEndpointConventionBuilder MapStaticAssetsCore( IEndpointRouteBuilder endpoints, - string manifestPath, - StaticAssetsManifest? manifest = null) + string manifestPath) { - foreach (var ds in endpoints.DataSources) + var builder = GetExistingBuilder(endpoints, manifestPath); + if (builder != null) { - if (ds is StaticAssetsEndpointDataSource sads && sads.ManifestPath.Equals(manifestPath, StringComparison.Ordinal)) - { - return sads.DefaultBuilder; - } + return builder; } - if (manifest == null && !File.Exists(manifestPath)) + 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."); } - manifest ??= StaticAssetsManifest.Parse(manifestPath); + return StaticAssetsManifest.Parse(manifestPath); + } - var dataSource = manifest.CreateDataSource(endpoints, manifestPath, manifest.Endpoints); - return dataSource.DefaultBuilder; + 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 @@ -74,7 +89,7 @@ internal static StaticAssetsEndpointConventionBuilder MapStaticAssets(this IEndp ArgumentNullException.ThrowIfNull(endpoints); var environment = endpoints.ServiceProvider.GetRequiredService(); - var result = MapStaticAssetsCore(endpoints, "unused", manifest); + var result = manifest.CreateDataSource(endpoints, "", manifest.Endpoints).DefaultBuilder; if (StaticAssetDevelopmentRuntimeHandler.IsEnabled(endpoints.ServiceProvider, environment)) {