diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64ff041d5c..eac4268e4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ Contributing ====== -Information on contributing to this repo is in the [Contributing Guide](https://github.com/aspnet/Home/blob/dev/CONTRIBUTING.md) in the Home repo. +Information on contributing to this repo is in the [Contributing Guide](https://github.com/aspnet/Home/blob/master/CONTRIBUTING.md) in the Home repo. diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSingleEntryBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcheFindCandidateSetSingleEntryBenchmark.cs similarity index 83% rename from benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSingleEntryBenchmark.cs rename to benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcheFindCandidateSetSingleEntryBenchmark.cs index 1769ae37b9..18818df532 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSingleEntryBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcheFindCandidateSetSingleEntryBenchmark.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Routing.Matchers { - public class MatcherSelectCandidatesSingleEntryBenchmark : MatcherBenchmarkBase + public class MatcheFindCandidateSetSingleEntryBenchmark : MatcherBenchmarkBase { private TrivialMatcher _baseline; private DfaMatcher _dfa; @@ -40,9 +40,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers var path = httpContext.Request.Path.Value; var segments = new ReadOnlySpan(Array.Empty()); - var candidates = _baseline.SelectCandidates(path, segments); + var candidates = _baseline.FindCandidateSet(path, segments); - var endpoint = candidates.Candidates[0].Endpoint; + var endpoint = candidates[0].Endpoint; Validate(Requests[0], Endpoints[0], endpoint); } @@ -54,9 +54,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers Span segments = stackalloc PathSegment[FastPathTokenizer.DefaultSegmentCount]; var count = FastPathTokenizer.Tokenize(path, segments); - var candidates = _dfa.SelectCandidates(path, segments.Slice(0, count)); + var candidates = _dfa.FindCandidateSet(httpContext, path, segments.Slice(0, count)); - var endpoint = candidates.Candidates[0].Endpoint; + var endpoint = candidates[0].Endpoint; Validate(Requests[0], Endpoints[0], endpoint); } } diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherBenchmarkBase.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherBenchmarkBase.cs index 3cb9649887..bf6a18297b 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherBenchmarkBase.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherBenchmarkBase.cs @@ -8,6 +8,7 @@ using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing.EndpointConstraints; +using Microsoft.AspNetCore.Routing.Metadata; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetAzureBenchmark.cs similarity index 84% rename from benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.cs rename to benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetAzureBenchmark.cs index edf73d6a48..7c346eca4a 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetAzureBenchmark.cs @@ -7,7 +7,7 @@ using BenchmarkDotNet.Attributes; namespace Microsoft.AspNetCore.Routing.Matchers { // Generated from https://github.com/Azure/azure-rest-api-specs - public partial class MatcherSelectCandidatesAzureBenchmark : MatcherBenchmarkBase + public partial class MatcherFindCandidateSetAzureBenchmark : MatcherBenchmarkBase { private const int SampleCount = 100; @@ -42,9 +42,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers var path = httpContext.Request.Path.Value; var segments = new ReadOnlySpan(Array.Empty()); - var candidates = _baseline.Matchers[sample].SelectCandidates(path, segments); + var candidates = _baseline.Matchers[sample].FindCandidateSet(path, segments); - var endpoint = candidates.Candidates[0].Endpoint; + var endpoint = candidates[0].Endpoint; Validate(httpContext, Endpoints[sample], endpoint); } } @@ -61,9 +61,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers Span segments = stackalloc PathSegment[FastPathTokenizer.DefaultSegmentCount]; var count = FastPathTokenizer.Tokenize(path, segments); - var candidates = _dfa.SelectCandidates(path, segments.Slice(0, count)); + var candidates = _dfa.FindCandidateSet(httpContext, path, segments.Slice(0, count)); - var endpoint = candidates.Candidates[0].Endpoint; + var endpoint = candidates[0].Endpoint; Validate(httpContext, Endpoints[sample], endpoint); } } diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.generated.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetAzureBenchmark.generated.cs similarity index 99% rename from benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.generated.cs rename to benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetAzureBenchmark.generated.cs index 65bea532fd..c68f822fc2 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.generated.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetAzureBenchmark.generated.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Routing.Matchers { // This code was generated by the Swaggatherer - public partial class MatcherSelectCandidatesAzureBenchmark : MatcherBenchmarkBase + public partial class MatcherFindCandidateSetAzureBenchmark : MatcherBenchmarkBase { private const int EndpointCount = 3517; diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetGithubBenchmark.cs similarity index 83% rename from benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.cs rename to benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetGithubBenchmark.cs index f40382dd5d..7b13d8fddf 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetGithubBenchmark.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers { // Generated from https://github.com/APIs-guru/openapi-directory // Use https://editor2.swagger.io/ to convert from yaml to json- - public partial class MatcherSelectCandidatesGithubBenchmark : MatcherBenchmarkBase + public partial class MatcherFindCandidateSetGithubBenchmark : MatcherBenchmarkBase { private BarebonesMatcher _baseline; private DfaMatcher _dfa; @@ -35,9 +35,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers var path = httpContext.Request.Path.Value; var segments = new ReadOnlySpan(Array.Empty()); - var candidates = _baseline.Matchers[i].SelectCandidates(path, segments); + var candidates = _baseline.Matchers[i].FindCandidateSet(path, segments); - var endpoint = candidates.Candidates[0].Endpoint; + var endpoint = candidates[0].Endpoint; Validate(httpContext, Endpoints[i], endpoint); } } @@ -53,9 +53,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers Span segments = stackalloc PathSegment[FastPathTokenizer.DefaultSegmentCount]; var count = FastPathTokenizer.Tokenize(path, segments); - var candidates = _dfa.SelectCandidates(path, segments.Slice(0, count)); + var candidates = _dfa.FindCandidateSet(httpContext, path, segments.Slice(0, count)); - var endpoint = candidates.Candidates[0].Endpoint; + var endpoint = candidates[0].Endpoint; Validate(httpContext, Endpoints[i], endpoint); } } diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.generated.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetGithubBenchmark.generated.cs similarity index 99% rename from benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.generated.cs rename to benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetGithubBenchmark.generated.cs index 2326b6bd8a..034547b9d2 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.generated.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetGithubBenchmark.generated.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Routing.Matchers { // This code was generated by the Swaggatherer - public partial class MatcherSelectCandidatesGithubBenchmark : MatcherBenchmarkBase + public partial class MatcherFindCandidateSetGithubBenchmark : MatcherBenchmarkBase { private const int EndpointCount = 155; diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSmallEntryCountBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetSmallEntryCountBenchmark.cs similarity index 90% rename from benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSmallEntryCountBenchmark.cs rename to benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetSmallEntryCountBenchmark.cs index 790c8993f3..472ff9c2a8 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSmallEntryCountBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherFindCandidateSetSmallEntryCountBenchmark.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Routing.Matchers { - public class MatcherSelectCandidatesSmallEntryCountBenchmark : MatcherBenchmarkBase + public class MatcherFindCandidateSetSmallEntryCountBenchmark : MatcherBenchmarkBase { private TrivialMatcher _baseline; private DfaMatcher _dfa; @@ -75,9 +75,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers var segments = new ReadOnlySpan(Array.Empty()); - var candidates = _baseline.SelectCandidates(path, segments); + var candidates = _baseline.FindCandidateSet(path, segments); - var endpoint = candidates.Candidates[0].Endpoint; + var endpoint = candidates[0].Endpoint; Validate(Requests[0], Endpoints[9], endpoint); } @@ -90,9 +90,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers Span segments = stackalloc PathSegment[FastPathTokenizer.DefaultSegmentCount]; var count = FastPathTokenizer.Tokenize(path, segments); - var candidates = _dfa.SelectCandidates(path, segments.Slice(0, count)); + var candidates = _dfa.FindCandidateSet(httpContext, path, segments.Slice(0, count)); - var endpoint = candidates.Candidates[0].Endpoint; + var endpoint = candidates[0].Endpoint; Validate(Requests[0], Endpoints[9], endpoint); } } diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/TrivialMatcher.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/TrivialMatcher.cs index 0483a0b5a9..afdb27d0d9 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/TrivialMatcher.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/TrivialMatcher.cs @@ -13,17 +13,13 @@ namespace Microsoft.AspNetCore.Routing.Matchers internal sealed class TrivialMatcher : Matcher { private readonly MatcherEndpoint _endpoint; - private readonly CandidateSet _candidates; + private readonly Candidate[] _candidates; public TrivialMatcher(MatcherEndpoint endpoint) { _endpoint = endpoint; - _candidates = new CandidateSet( - new Candidate[] { new Candidate(endpoint), }, - - // Single candidate group that contains one entry. - CandidateSet.MakeGroups(new[] { 1 })); + _candidates = new Candidate[] { new Candidate(endpoint), }; } public sealed override Task MatchAsync(HttpContext httpContext, IEndpointFeature feature) @@ -49,14 +45,14 @@ namespace Microsoft.AspNetCore.Routing.Matchers } // This is here so this can be tested alongside DFA matcher. - internal CandidateSet SelectCandidates(string path, ReadOnlySpan segments) + internal Candidate[] FindCandidateSet(string path, ReadOnlySpan segments) { if (string.Equals(_endpoint.RoutePattern.RawText, path, StringComparison.OrdinalIgnoreCase)) { return _candidates; } - return CandidateSet.Empty; + return Array.Empty(); } } } diff --git a/src/Microsoft.AspNetCore.Routing.Abstractions/ISuppressLinkGenerationMetadata.cs b/src/Microsoft.AspNetCore.Routing.Abstractions/ISuppressLinkGenerationMetadata.cs new file mode 100644 index 0000000000..2e6a7da5d8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing.Abstractions/ISuppressLinkGenerationMetadata.cs @@ -0,0 +1,9 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Routing +{ + public interface ISuppressLinkGenerationMetadata + { + } +} diff --git a/src/Microsoft.AspNetCore.Routing/CompositeEndpointDataSource.cs b/src/Microsoft.AspNetCore.Routing/CompositeEndpointDataSource.cs index bb4836da32..2e6559817c 100644 --- a/src/Microsoft.AspNetCore.Routing/CompositeEndpointDataSource.cs +++ b/src/Microsoft.AspNetCore.Routing/CompositeEndpointDataSource.cs @@ -3,12 +3,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Text; using System.Threading; +using Microsoft.AspNetCore.Routing.EndpointConstraints; +using Microsoft.AspNetCore.Routing.Matchers; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Routing { + [DebuggerDisplay("{DebuggerDisplayString,nq}")] public class CompositeEndpointDataSource : EndpointDataSource { private readonly EndpointDataSource[] _dataSources; @@ -29,13 +34,12 @@ namespace Microsoft.AspNetCore.Routing _lock = new object(); } - public override IChangeToken ChangeToken + public override IChangeToken ChangeToken => GetChangeToken(); + + public override IChangeToken GetChangeToken() { - get - { - EnsureInitialized(); - return _consumerChangeToken; - } + EnsureInitialized(); + return _consumerChangeToken; } public override IReadOnlyList Endpoints @@ -106,5 +110,51 @@ namespace Microsoft.AspNetCore.Routing _cts = new CancellationTokenSource(); _consumerChangeToken = new CancellationChangeToken(_cts.Token); } + + private string DebuggerDisplayString + { + get + { + // Try using private variable '_endpoints' to avoid initialization + if (_endpoints == null) + { + return "No endpoints"; + } + + var sb = new StringBuilder(); + foreach (var endpoint in _endpoints) + { + if (endpoint is MatcherEndpoint matcherEndpoint) + { + var template = matcherEndpoint.RoutePattern.RawText; + template = string.IsNullOrEmpty(template) ? "\"\"" : template; + sb.Append(template); + var requiredValues = matcherEndpoint.RequiredValues.Select(kvp => $"{kvp.Key} = \"{kvp.Value ?? "null"}\""); + sb.Append(", Required Values: new { "); + sb.Append(string.Join(", ", requiredValues)); + sb.Append(" }"); + sb.Append(", Order:"); + sb.Append(matcherEndpoint.Order); + + var httpEndpointConstraints = matcherEndpoint.Metadata.GetOrderedMetadata() + .OfType(); + foreach (var constraint in httpEndpointConstraints) + { + sb.Append(", Http Methods: "); + sb.Append(string.Join(", ", constraint.HttpMethods)); + sb.Append(", Constraint Order:"); + sb.Append(constraint.Order); + } + sb.AppendLine(); + } + else + { + sb.Append("Non-MatcherEndpoint. DisplayName:"); + sb.AppendLine(endpoint.DisplayName); + } + } + return sb.ToString(); + } + } } } diff --git a/src/Microsoft.AspNetCore.Routing/DefaultEndpointDataSource.cs b/src/Microsoft.AspNetCore.Routing/DefaultEndpointDataSource.cs index 6dfbdbb38c..971f499afa 100644 --- a/src/Microsoft.AspNetCore.Routing/DefaultEndpointDataSource.cs +++ b/src/Microsoft.AspNetCore.Routing/DefaultEndpointDataSource.cs @@ -23,7 +23,9 @@ namespace Microsoft.AspNetCore.Routing _endpoints.AddRange(endpoints); } - public override IChangeToken ChangeToken { get; } = NullChangeToken.Singleton; + public override IChangeToken ChangeToken => GetChangeToken(); + + public override IChangeToken GetChangeToken() => NullChangeToken.Singleton; public override IReadOnlyList Endpoints => _endpoints; } diff --git a/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs index 1474d6e76d..fac20603a5 100644 --- a/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs @@ -73,11 +73,13 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton, NameBasedEndpointFinder>(); services.TryAddSingleton, RouteValuesBasedEndpointFinder>(); services.TryAddSingleton(); + // // Endpoint Selection // - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); // Will be cached by the EndpointSelector services.TryAddEnumerable( diff --git a/src/Microsoft.AspNetCore.Routing/EndpointConstraints/EndpointSelector.cs b/src/Microsoft.AspNetCore.Routing/EndpointConstraints/EndpointConstraintEndpointSelector.cs similarity index 69% rename from src/Microsoft.AspNetCore.Routing/EndpointConstraints/EndpointSelector.cs rename to src/Microsoft.AspNetCore.Routing/EndpointConstraints/EndpointConstraintEndpointSelector.cs index 4ade56e671..f063079f87 100644 --- a/src/Microsoft.AspNetCore.Routing/EndpointConstraints/EndpointSelector.cs +++ b/src/Microsoft.AspNetCore.Routing/EndpointConstraints/EndpointConstraintEndpointSelector.cs @@ -1,20 +1,17 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing.Matchers; -using Microsoft.Extensions.Internal; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Routing.EndpointConstraints { - internal class EndpointSelector + internal class EndpointConstraintEndpointSelector : EndpointSelector { private static readonly IReadOnlyList EmptyEndpoints = Array.Empty(); @@ -22,7 +19,7 @@ namespace Microsoft.AspNetCore.Routing.EndpointConstraints private readonly EndpointConstraintCache _endpointConstraintCache; private readonly ILogger _logger; - public EndpointSelector( + public EndpointConstraintEndpointSelector( CompositeEndpointDataSource dataSource, EndpointConstraintCache endpointConstraintCache, ILoggerFactory loggerFactory) @@ -32,11 +29,19 @@ namespace Microsoft.AspNetCore.Routing.EndpointConstraints _endpointConstraintCache = endpointConstraintCache; } - public Endpoint SelectBestCandidate(HttpContext context, IReadOnlyList candidates) + public override Task SelectAsync( + HttpContext httpContext, + IEndpointFeature feature, + CandidateSet candidates) { - if (context == null) + if (httpContext == null) { - throw new ArgumentNullException(nameof(context)); + throw new ArgumentNullException(nameof(httpContext)); + } + + if (feature == null) + { + throw new ArgumentNullException(nameof(feature)); } if (candidates == null) @@ -44,25 +49,30 @@ namespace Microsoft.AspNetCore.Routing.EndpointConstraints throw new ArgumentNullException(nameof(candidates)); } - var finalMatches = EvaluateEndpointConstraints(context, candidates); + var finalMatches = EvaluateEndpointConstraints(httpContext, candidates); if (finalMatches == null || finalMatches.Count == 0) { - return null; + return Task.CompletedTask; } else if (finalMatches.Count == 1) { - var selectedEndpoint = finalMatches[0]; + var endpoint = finalMatches[0].Endpoint; + var values = finalMatches[0].Values; - return selectedEndpoint; + feature.Endpoint = endpoint; + feature.Invoker = (endpoint as MatcherEndpoint)?.Invoker; + feature.Values = values; + + return Task.CompletedTask; } else { var endpointNames = string.Join( Environment.NewLine, - finalMatches.Select(a => a.DisplayName)); + finalMatches.Select(a => a.Endpoint.DisplayName)); - Log.MatchAmbiguous(_logger, context, finalMatches); + Log.MatchAmbiguous(_logger, httpContext, finalMatches); var message = Resources.FormatAmbiguousEndpoints( Environment.NewLine, @@ -72,31 +82,61 @@ namespace Microsoft.AspNetCore.Routing.EndpointConstraints } } - private IReadOnlyList EvaluateEndpointConstraints( + private IReadOnlyList EvaluateEndpointConstraints( HttpContext context, - IReadOnlyList endpoints) + CandidateSet candidateSet) { var candidates = new List(); // Perf: Avoid allocations - for (var i = 0; i < endpoints.Count; i++) + for (var i = 0; i < candidateSet.Count; i++) { - var endpoint = endpoints[i]; - var constraints = _endpointConstraintCache.GetEndpointConstraints(context, endpoint); - candidates.Add(new EndpointSelectorCandidate(endpoint, constraints)); + ref var candidate = ref candidateSet[i]; + if (candidate.IsValidCandidate) + { + var endpoint = candidate.Endpoint; + var constraints = _endpointConstraintCache.GetEndpointConstraints(context, endpoint); + candidates.Add(new EndpointSelectorCandidate( + endpoint, + candidate.Score, + candidate.Values, + constraints)); + } } var matches = EvaluateEndpointConstraintsCore(context, candidates, startingOrder: null); - List results = null; + List results = null; if (matches != null) { - results = new List(matches.Count); - // Perf: Avoid allocations - for (var i = 0; i < matches.Count; i++) + results = new List(matches.Count); + + // We need to disambiguate based on 'score' - take the first value of 'score' + // and then we only copy matches while they have the same score. This accounts + // for a difference in behavior between new routing and old. + switch (matches.Count) { - var candidate = matches[i]; - results.Add(candidate.Endpoint); + case 0: + break; + + case 1: + results.Add(matches[0]); + break; + + default: + var score = matches[0].Score; + for (var i = 0; i < matches.Count; i++) + { + if (matches[i].Score != score) + { + break; + } + + results.Add(matches[i]); + } + + break; + } } @@ -213,11 +253,11 @@ namespace Microsoft.AspNetCore.Routing.EndpointConstraints new EventId(1, "MatchAmbiguous"), "Request matched multiple endpoints for request path '{Path}'. Matching endpoints: {AmbiguousEndpoints}"); - public static void MatchAmbiguous(ILogger logger, HttpContext httpContext, IEnumerable endpoints) + public static void MatchAmbiguous(ILogger logger, HttpContext httpContext, IEnumerable endpoints) { if (logger.IsEnabled(LogLevel.Error)) { - _matchAmbiguous(logger, httpContext.Request.Path, endpoints.Select(e => e.DisplayName), null); + _matchAmbiguous(logger, httpContext.Request.Path, endpoints.Select(e => e.Endpoint.DisplayName), null); } } } diff --git a/src/Microsoft.AspNetCore.Routing/EndpointConstraints/HttpMethodEndpointConstraint.cs b/src/Microsoft.AspNetCore.Routing/EndpointConstraints/HttpMethodEndpointConstraint.cs index 9c890732bc..7631e10b22 100644 --- a/src/Microsoft.AspNetCore.Routing/EndpointConstraints/HttpMethodEndpointConstraint.cs +++ b/src/Microsoft.AspNetCore.Routing/EndpointConstraints/HttpMethodEndpointConstraint.cs @@ -4,10 +4,11 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using Microsoft.AspNetCore.Routing.Metadata; namespace Microsoft.AspNetCore.Routing.EndpointConstraints { - public class HttpMethodEndpointConstraint : IEndpointConstraint + public class HttpMethodEndpointConstraint : IEndpointConstraint, IHttpMethodMetadata { public static readonly int HttpMethodConstraintOrder = 100; @@ -40,6 +41,8 @@ namespace Microsoft.AspNetCore.Routing.EndpointConstraints public int Order => HttpMethodConstraintOrder; + IReadOnlyList IHttpMethodMetadata.HttpMethods => _httpMethods; + public virtual bool Accept(EndpointConstraintContext context) { if (context == null) diff --git a/src/Microsoft.AspNetCore.Routing/EndpointConstraints/IEndpointConstraint.cs b/src/Microsoft.AspNetCore.Routing/EndpointConstraints/IEndpointConstraint.cs index ace843104d..1a71ec738d 100644 --- a/src/Microsoft.AspNetCore.Routing/EndpointConstraints/IEndpointConstraint.cs +++ b/src/Microsoft.AspNetCore.Routing/EndpointConstraints/IEndpointConstraint.cs @@ -1,10 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.AspNetCore.Http; using System; using System.Collections.Generic; -using System.Text; +using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Routing.EndpointConstraints { @@ -28,9 +27,13 @@ namespace Microsoft.AspNetCore.Routing.EndpointConstraints { } - public struct EndpointSelectorCandidate + public readonly struct EndpointSelectorCandidate { - public EndpointSelectorCandidate(Endpoint endpoint, IReadOnlyList constraints) + public EndpointSelectorCandidate( + Endpoint endpoint, + int score, + RouteValueDictionary values, + IReadOnlyList constraints) { if (endpoint == null) { @@ -38,11 +41,33 @@ namespace Microsoft.AspNetCore.Routing.EndpointConstraints } Endpoint = endpoint; + Score = score; + Values = values; + Constraints = constraints; + } + + // Temporarily added to not break MVC build + public EndpointSelectorCandidate( + Endpoint endpoint, + IReadOnlyList constraints) + { + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + Endpoint = endpoint; + Score = 0; + Values = null; Constraints = constraints; } public Endpoint Endpoint { get; } + public int Score { get; } + + public RouteValueDictionary Values { get; } + public IReadOnlyList Constraints { get; } } diff --git a/src/Microsoft.AspNetCore.Routing/Internal/LinkGenerationDecisionTree.cs b/src/Microsoft.AspNetCore.Routing/Internal/LinkGenerationDecisionTree.cs index 951d6b645c..eb055f7696 100644 --- a/src/Microsoft.AspNetCore.Routing/Internal/LinkGenerationDecisionTree.cs +++ b/src/Microsoft.AspNetCore.Routing/Internal/LinkGenerationDecisionTree.cs @@ -3,12 +3,16 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; using Microsoft.AspNetCore.Routing.DecisionTree; using Microsoft.AspNetCore.Routing.Tree; namespace Microsoft.AspNetCore.Routing.Internal { // A decision tree that matches link generation entries based on route data. + [DebuggerDisplay("{DebuggerDisplayString,nq}")] public class LinkGenerationDecisionTree { private readonly DecisionTreeNode _root; @@ -160,5 +164,51 @@ namespace Microsoft.AspNetCore.Routing.Internal y.Match.Entry.RouteTemplate.TemplateText); } } + + // Example output: + // + // => action: Buy => controller: Store => version: V1(Matches: Store/Buy/V1) + // => action: Buy => controller: Store => version: V2(Matches: Store/Buy/V2) + // => action: Buy => controller: Store => area: Admin(Matches: Admin/Store/Buy) + // => action: Buy => controller: Products(Matches: Products/Buy) + // => action: Cart => controller: Store(Matches: Store/Cart) + internal string DebuggerDisplayString + { + get + { + var sb = new StringBuilder(); + var branchStack = new Stack(); + branchStack.Push(string.Empty); + FlattenTree(branchStack, sb, _root); + return sb.ToString(); + } + } + + private void FlattenTree(Stack branchStack, StringBuilder sb, DecisionTreeNode node) + { + // leaf node + if (node.Criteria.Count == 0) + { + var matchesSb = new StringBuilder(); + foreach (var branch in branchStack) + { + matchesSb.Insert(0, branch); + } + sb.Append(matchesSb.ToString()); + sb.Append(" (Matches: "); + sb.Append(string.Join(", ", node.Matches.Select(m => m.Entry.RouteTemplate.TemplateText))); + sb.AppendLine(")"); + } + + foreach (var criterion in node.Criteria) + { + foreach (var branch in criterion.Branches) + { + branchStack.Push($" => {criterion.Key}: {branch.Key}"); + FlattenTree(branchStack, sb, branch.Value); + branchStack.Pop(); + } + } + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/Candidate.cs b/src/Microsoft.AspNetCore.Routing/Matchers/Candidate.cs index 857baa24cc..5b6459b386 100644 --- a/src/Microsoft.AspNetCore.Routing/Matchers/Candidate.cs +++ b/src/Microsoft.AspNetCore.Routing/Matchers/Candidate.cs @@ -34,6 +34,19 @@ namespace Microsoft.AspNetCore.Routing.Matchers public readonly MatchProcessor[] MatchProcessors; + // Score is a sequential integer value that in determines the priority of an Endpoint. + // Scores are computed within the context of candidate set, and are meaningless when + // applied to endpoints not in the set. + // + // The score concept boils down the system of comparisons done when ordering Endpoints + // to a single value that can be compared easily. This can be defeated by having + // int32.MaxValue + 1 endpoints in a single set, but you would have other problems by + // that point. + // + // Score is not part of the Endpoint itself, because it's contextual based on where + // the endpoint appears. An Endpoint is often be a member of multiple candiate sets. + public readonly int Score; + // Used in tests. public Candidate(MatcherEndpoint endpoint) { @@ -44,12 +57,14 @@ namespace Microsoft.AspNetCore.Routing.Matchers CatchAll = default; ComplexSegments = Array.Empty<(RoutePatternPathSegment pathSegment, int segmentIndex)>(); MatchProcessors = Array.Empty(); + Score = 0; Flags = CandidateFlags.None; } public Candidate( MatcherEndpoint endpoint, + int score, KeyValuePair[] slots, (string parameterName, int segmentIndex, int slotIndex)[] captures, (string parameterName, int segmentIndex, int slotIndex) catchAll, @@ -57,6 +72,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers MatchProcessor[] matchProcessors) { Endpoint = endpoint; + Score = score; Slots = slots; Captures = captures; CatchAll = catchAll; diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/CandidateSet.cs b/src/Microsoft.AspNetCore.Routing/Matchers/CandidateSet.cs index ad85378f5c..9f89b765a2 100644 --- a/src/Microsoft.AspNetCore.Routing/Matchers/CandidateSet.cs +++ b/src/Microsoft.AspNetCore.Routing/Matchers/CandidateSet.cs @@ -2,56 +2,155 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Runtime.CompilerServices; namespace Microsoft.AspNetCore.Routing.Matchers { - internal class CandidateSet + public sealed class CandidateSet { - public static readonly CandidateSet Empty = new CandidateSet(Array.Empty(), Array.Empty()); + // We inline storage for 4 candidates here to avoid allocations in common + // cases. There's no real reason why 4 is important, it just seemed like + // a plausible number. + private CandidateState _state0; + private CandidateState _state1; + private CandidateState _state2; + private CandidateState _state3; - // The array of candidates. - public readonly Candidate[] Candidates; + private CandidateState[] _additionalCandidates; - // The number of groups. - public readonly int GroupCount; - - // The array of groups. Groups define a contiguous sets of indices into - // the candidates array. - // - // The groups array always contains N+1 entries where N is the number of groups. - // The extra array entry is there to make indexing easier, so we can lookup the 'end' - // of the last group without branching. - // - // Example: - // Group0: Candidates[0], Candidates[1] - // Group1: Candidates[2], Candidates[3], Candidates[4] - // - // The groups array would look like: { 0, 2, 5, } - public readonly int[] Groups; - - public CandidateSet(Candidate[] candidates, int[] groups) + // Provided to make testing possible/easy for someone implementing + // an EndpointSelector. + public CandidateSet(MatcherEndpoint[] endpoints, int[] scores) { - Candidates = candidates; - Groups = groups; + Count = endpoints.Length; - GroupCount = groups.Length == 0 ? 0 : groups.Length - 1; + switch (endpoints.Length) + { + case 0: + return; + + case 1: + _state0 = new CandidateState(endpoints[0], score: scores[0]); + break; + + case 2: + _state0 = new CandidateState(endpoints[0], score: scores[0]); + _state1 = new CandidateState(endpoints[1], score: scores[1]); + break; + + case 3: + _state0 = new CandidateState(endpoints[0], score: scores[0]); + _state1 = new CandidateState(endpoints[1], score: scores[1]); + _state2 = new CandidateState(endpoints[2], score: scores[2]); + break; + + case 4: + _state0 = new CandidateState(endpoints[0], score: scores[0]); + _state1 = new CandidateState(endpoints[1], score: scores[1]); + _state2 = new CandidateState(endpoints[2], score: scores[2]); + _state3 = new CandidateState(endpoints[3], score: scores[3]); + break; + + default: + _state0 = new CandidateState(endpoints[0], score: scores[0]); + _state1 = new CandidateState(endpoints[1], score: scores[1]); + _state2 = new CandidateState(endpoints[2], score: scores[2]); + _state3 = new CandidateState(endpoints[3], score: scores[3]); + + _additionalCandidates = new CandidateState[endpoints.Length - 4]; + for (var i = 4; i < endpoints.Length; i++) + { + _additionalCandidates[i - 4] = new CandidateState(endpoints[i], score: scores[i]); + } + break; + } } - // See description on Groups. - public static int[] MakeGroups(int[] lengths) + internal CandidateSet(Candidate[] candidates) { - var groups = new int[lengths.Length + 1]; + Count = candidates.Length; - var sum = 0; - for (var i = 0; i < lengths.Length; i++) + switch (candidates.Length) { - groups[i] = sum; - sum += lengths[i]; + case 0: + return; + + case 1: + _state0 = new CandidateState(candidates[0].Endpoint, candidates[0].Score); + break; + + case 2: + _state0 = new CandidateState(candidates[0].Endpoint, candidates[0].Score); + _state1 = new CandidateState(candidates[1].Endpoint, candidates[1].Score); + break; + + case 3: + _state0 = new CandidateState(candidates[0].Endpoint, candidates[0].Score); + _state1 = new CandidateState(candidates[1].Endpoint, candidates[1].Score); + _state2 = new CandidateState(candidates[2].Endpoint, candidates[2].Score); + break; + + case 4: + _state0 = new CandidateState(candidates[0].Endpoint, candidates[0].Score); + _state1 = new CandidateState(candidates[1].Endpoint, candidates[1].Score); + _state2 = new CandidateState(candidates[2].Endpoint, candidates[2].Score); + _state3 = new CandidateState(candidates[3].Endpoint, candidates[3].Score); + break; + + default: + _state0 = new CandidateState(candidates[0].Endpoint, candidates[0].Score); + _state1 = new CandidateState(candidates[1].Endpoint, candidates[1].Score); + _state2 = new CandidateState(candidates[2].Endpoint, candidates[2].Score); + _state3 = new CandidateState(candidates[3].Endpoint, candidates[3].Score); + + _additionalCandidates = new CandidateState[candidates.Length - 4]; + for (var i = 4; i < candidates.Length; i++) + { + _additionalCandidates[i - 4] = new CandidateState(candidates[i].Endpoint, candidates[i].Score); + } + break; } + } - groups[lengths.Length] = sum; + public int Count { get; } - return groups; + // Note that this is a ref-return because of both mutability and performance. + // We don't want to copy these fat structs if it can be avoided. + public ref CandidateState this[int index] + { + // PERF: Force inlining + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + // Friendliness for inlining + if ((uint)index >= Count) + { + ThrowIndexArgumentOutOfRangeException(); + } + + switch (index) + { + case 0: + return ref _state0; + + case 1: + return ref _state1; + + case 2: + return ref _state2; + + case 3: + return ref _state3; + + default: + return ref _additionalCandidates[index - 4]; + } + } + } + + private static void ThrowIndexArgumentOutOfRangeException() + { + throw new ArgumentOutOfRangeException("index"); } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/CandidateState.cs b/src/Microsoft.AspNetCore.Routing/Matchers/CandidateState.cs new file mode 100644 index 0000000000..b302b23bf2 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/CandidateState.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public struct CandidateState + { + // Provided for testability + public CandidateState(MatcherEndpoint endpoint) + : this(endpoint, score: 0) + { + } + + public CandidateState(MatcherEndpoint endpoint, int score) + { + Endpoint = endpoint; + Score = score; + + IsValidCandidate = true; + Values = null; + } + + public MatcherEndpoint Endpoint { get; } + + public int Score { get; } + + public bool IsValidCandidate { get; set; } + + public RouteValueDictionary Values { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DefaultEndpointSelector.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DefaultEndpointSelector.cs new file mode 100644 index 0000000000..b76010851f --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DefaultEndpointSelector.cs @@ -0,0 +1,86 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal class DefaultEndpointSelector : EndpointSelector + { + public override Task SelectAsync( + HttpContext httpContext, + IEndpointFeature feature, + CandidateSet candidates) + { + MatcherEndpoint endpoint = null; + RouteValueDictionary values = null; + int? foundScore = null; + for (var i = 0; i < candidates.Count; i++) + { + ref var state = ref candidates[i]; + + var isValid = state.IsValidCandidate; + if (isValid && foundScore == null) + { + // This is the first match we've seen - speculatively assign it. + endpoint = state.Endpoint; + values = state.Values; + foundScore = state.Score; + } + else if (isValid && foundScore < state.Score) + { + // This candidate is lower priority than the one we've seen + // so far, we can stop. + // + // Don't worry about the 'null < state.Score' case, it returns false. + break; + } + else if (isValid && foundScore == state.Score) + { + // This is the second match we've found of the same score, so there + // must be an ambiguity. + // + // Don't worry about the 'null == state.Score' case, it returns false. + + ReportAmbiguity(candidates); + + // Unreachable, ReportAmbiguity always throws. + throw new NotSupportedException(); + } + } + + if (endpoint != null) + { + feature.Endpoint = endpoint; + feature.Invoker = endpoint.Invoker; + feature.Values = values; + } + + return Task.CompletedTask; + } + + private static void ReportAmbiguity(CandidateSet candidates) + { + // If we get here it's the result of an ambiguity - we're OK with this + // being a littler slower and more allocatey. + var matches = new List(); + for (var i = 0; i < candidates.Count; i++) + { + ref var state = ref candidates[i]; + if (state.IsValidCandidate) + { + matches.Add(state.Endpoint); + } + } + + var message = Resources.FormatAmbiguousEndpoints( + Environment.NewLine, + string.Join(Environment.NewLine, matches.Select(e => e.DisplayName))); + throw new AmbiguousMatchException(message); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcher.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcher.cs index 75a700a02b..a7908e1662 100644 --- a/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcher.cs +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcher.cs @@ -2,25 +2,21 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections; using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing.EndpointConstraints; using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Routing.Matchers { internal sealed class DfaMatcher : Matcher { - private readonly EndpointSelector _endpointSelector; + private readonly EndpointSelector _selector; private readonly DfaState[] _states; - public DfaMatcher(EndpointSelector endpointSelector, DfaState[] states) + public DfaMatcher(EndpointSelector selector, DfaState[] states) { - _endpointSelector = endpointSelector; + _selector = selector; _states = states; } @@ -46,117 +42,40 @@ namespace Microsoft.AspNetCore.Routing.Matchers var count = FastPathTokenizer.Tokenize(path, buffer); var segments = buffer.Slice(0, count); - // SelectCandidates will process the DFA and return a candidate set. This does + // FindCandidateSet will process the DFA and return a candidate set. This does // some preliminary matching of the URL (mostly the literal segments). - var candidates = SelectCandidates(path, segments); - if (candidates.GroupCount == 0) + var candidates = FindCandidateSet(httpContext, path, segments); + if (candidates.Length == 0) { return Task.CompletedTask; } - // At this point we have a candidate set, defined as a list of groups - // of candidates. Each member of a given group has the same priority - // (priority is defined by order, precedence and other factors like http method - // or version). + // At this point we have a candidate set, defined as a list of endpoints in + // priority order. // // We don't yet know that any candidate can be considered a match, because // we haven't processed things like route constraints and complex segments. // - // Now we'll go group by group to capture route values, process constraints, + // Now we'll iterate each endpoint to capture route values, process constraints, // and process complex segments. + + // `candidates` has all of our internal state that we use to process the + // set of endpoints before we call the EndpointSelector. // - // Perf: using groups.Length - 1 here to elide the bounds check. We're relying - // on assumptions of how Groups works. - var candidatesArray = candidates.Candidates; - var groups = candidates.Groups; + // `candidateSet` is the mutable state that we pass to the EndpointSelector. + var candidateSet = new CandidateSet(candidates); - for (var i = 0; i < groups.Length - 1; i++) + for (var i = 0; i < candidates.Length; i++) { - var start = groups[i]; - var length = groups[i + 1] - groups[i]; - var group = candidatesArray.AsSpan(start, length); - - // Yes, these allocate. We should revise how this interaction works exactly - // once the extensibility is more locked down. + // PERF: using ref here to avoid copying around big structs. // - // Would could produce a fast path for a small number of members in - // a group. - var members = new BitArray(group.Length); - var groupValues = new RouteValueDictionary[group.Length]; + // Reminder! + // candidate: readonly data about the endpoint and how to match + // state: mutable storarge for our processing + ref var candidate = ref candidates[i]; + ref var state = ref candidateSet[i]; - if (FilterGroup( - httpContext, - path, - segments, - group, - members, - groupValues)) - { - // We must have some matches because FilterGroup returned true. - - // So: this code is SUPER SUPER temporary. We don't intent to keep - // EndpointSelector around for very long. - var candidatesForEndpointSelector = new List(); - for (var j = 0; j < group.Length; j++) - { - if (members.Get(j)) - { - candidatesForEndpointSelector.Add(group[j].Endpoint); - } - } - - var result = _endpointSelector.SelectBestCandidate(httpContext, candidatesForEndpointSelector); - if (result != null) - { - // Find the route values, based on which endpoint was selected. We have - // to do this because the endpoint selector returns an endpoint - // instead of mutating the feature. - for (var j = 0; j < group.Length; j++) - { - if (ReferenceEquals(result, group[j].Endpoint)) - { - feature.Endpoint = result; - feature.Invoker = ((MatcherEndpoint)result).Invoker; - feature.Values = groupValues[j]; - return Task.CompletedTask; - } - } - } - - // End super temporary code - } - } - - return Task.CompletedTask; - } - - internal CandidateSet SelectCandidates(string path, ReadOnlySpan segments) - { - var states = _states; - - var destination = 0; - for (var i = 0; i < segments.Length; i++) - { - destination = states[destination].Transitions.GetDestination(path, segments[i]); - } - - return states[destination].Candidates; - } - - private bool FilterGroup( - HttpContext httpContext, - string path, - ReadOnlySpan segments, - ReadOnlySpan group, - BitArray members, - RouteValueDictionary[] groupValues) - { - var hasMatch = false; - for (var i = 0; i < group.Length; i++) - { - // PERF: specifically not copying group[i] into a local. It's a relatively - // fat struct and we don't want to eagerly copy it. - var flags = group[i].Flags; + var flags = candidate.Flags; // First process all of the parameters and defaults. RouteValueDictionary values; @@ -170,7 +89,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers // // We want to create a new array for the route values based on Slots // as a prototype. - var prototype = group[i].Slots; + var prototype = candidate.Slots; var slots = new KeyValuePair[prototype.Length]; if ((flags & Candidate.CandidateFlags.HasDefaults) != 0) @@ -180,38 +99,62 @@ namespace Microsoft.AspNetCore.Routing.Matchers if ((flags & Candidate.CandidateFlags.HasCaptures) != 0) { - ProcessCaptures(slots, group[i].Captures, path, segments); + ProcessCaptures(slots, candidate.Captures, path, segments); } if ((flags & Candidate.CandidateFlags.HasCatchAll) != 0) - { - ProcessCatchAll(slots, group[i].CatchAll, path, segments); + { + ProcessCatchAll(slots, candidate.CatchAll, path, segments); } values = RouteValueDictionary.FromArray(slots); } - groupValues[i] = values; - + state.Values = values; + // Now that we have the route values, we need to process complex segments. // Complex segments go through an old API that requires a fully-materialized // route value dictionary. var isMatch = true; if ((flags & Candidate.CandidateFlags.HasComplexSegments) != 0) { - isMatch &= ProcessComplexSegments(group[i].ComplexSegments, path, segments, values); + isMatch &= ProcessComplexSegments(candidate.ComplexSegments, path, segments, values); } if ((flags & Candidate.CandidateFlags.HasMatchProcessors) != 0) { - isMatch &= ProcessMatchProcessors(group[i].MatchProcessors, httpContext, values); + isMatch &= ProcessMatchProcessors(candidate.MatchProcessors, httpContext, values); } - members.Set(i, isMatch); - hasMatch |= isMatch; + state.IsValidCandidate = isMatch; } - return hasMatch; + return _selector.SelectAsync(httpContext, feature, candidateSet); + } + + internal Candidate[] FindCandidateSet( + HttpContext httpContext, + string path, + ReadOnlySpan segments) + { + var states = _states; + + // Process each path segment + var destination = 0; + for (var i = 0; i < segments.Length; i++) + { + destination = states[destination].PathTransitions.GetDestination(path, segments[i]); + } + + // Process an arbitrary number of policy-based decisions + var policyTransitions = states[destination].PolicyTransitions; + while (policyTransitions != null) + { + destination = policyTransitions.GetDestination(httpContext); + policyTransitions = states[destination].PolicyTransitions; + } + + return states[destination].Candidates; } private void ProcessCaptures( diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcherBuilder.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcherBuilder.cs index ab9936ed06..c5f75cee4e 100644 --- a/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcherBuilder.cs +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcherBuilder.cs @@ -4,32 +4,37 @@ using System; using System.Collections.Generic; using System.Linq; -using Microsoft.AspNetCore.Routing.EndpointConstraints; using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.AspNetCore.Routing.Template; -using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Routing.Matchers { internal class DfaMatcherBuilder : MatcherBuilder { - private readonly List _entries = new List(); - private readonly IInlineConstraintResolver _constraintResolver = new DefaultInlineConstraintResolver(Options.Create(new RouteOptions())); + private readonly List _endpoints = new List(); private readonly MatchProcessorFactory _matchProcessorFactory; - private readonly EndpointSelector _endpointSelector; + private readonly EndpointSelector _selector; + private readonly MatcherPolicy[] _policies; + private readonly INodeBuilderPolicy[] _nodeBuilders; + private readonly MatcherEndpointComparer _comparer; public DfaMatcherBuilder( MatchProcessorFactory matchProcessorFactory, - EndpointSelector endpointSelector) + EndpointSelector selector, + IEnumerable policies) { - _matchProcessorFactory = matchProcessorFactory ?? throw new ArgumentNullException(nameof(matchProcessorFactory)); - _endpointSelector = endpointSelector ?? throw new ArgumentNullException(nameof(endpointSelector)); + _matchProcessorFactory = matchProcessorFactory; + _selector = selector; + _policies = policies.OrderBy(p => p.Order).ToArray(); + + // Taking care to use _policies, which has been sorted. + _nodeBuilders = _policies.OfType().ToArray(); + _comparer = new MatcherEndpointComparer(_policies.OfType().ToArray()); } public override void AddEndpoint(MatcherEndpoint endpoint) { - _entries.Add(new MatcherBuilderEntry(endpoint)); + _endpoints.Add(endpoint); } public DfaNode BuildDfaTree() @@ -38,48 +43,48 @@ namespace Microsoft.AspNetCore.Routing.Matchers // because a 'parameter' node can also traverse the same paths that literal nodes // traverse. This means that we need to order the entries first, or else we will // miss possible edges in the DFA. - _entries.Sort(); + _endpoints.Sort(_comparer); // Since we're doing a BFS we will process each 'level' of the tree in stages // this list will hold the set of items we need to process at the current // stage. - var work = new List<(MatcherBuilderEntry entry, List parents)>(); + var work = new List<(MatcherEndpoint endpoint, List parents)>(); var root = new DfaNode() { Depth = 0, Label = "/" }; // To prepare for this we need to compute the max depth, as well as // a seed list of items to process (entry, root). var maxDepth = 0; - for (var i = 0; i < _entries.Count; i++) + for (var i = 0; i < _endpoints.Count; i++) { - var entry = _entries[i]; - maxDepth = Math.Max(maxDepth, entry.RoutePattern.PathSegments.Count); + var endpoint = _endpoints[i]; + maxDepth = Math.Max(maxDepth, endpoint.RoutePattern.PathSegments.Count); - work.Add((entry, new List() { root, })); + work.Add((endpoint, new List() { root, })); } // Now we process the entries a level at a time. for (var depth = 0; depth <= maxDepth; depth++) { // As we process items, collect the next set of items. - var nextWork = new List<(MatcherBuilderEntry entry, List parents)>(); + var nextWork = new List<(MatcherEndpoint endpoint, List parents)>(); for (var i = 0; i < work.Count; i++) { - var (entry, parents) = work[i]; + var (endpoint, parents) = work[i]; - if (!HasAdditionalRequiredSegments(entry, depth)) + if (!HasAdditionalRequiredSegments(endpoint, depth)) { for (var j = 0; j < parents.Count; j++) { var parent = parents[j]; - parent.Matches.Add(entry); + parent.Matches.Add(endpoint); } } // Find the parents of this edge at the current depth var nextParents = new List(); - var segment = GetCurrentSegment(entry, depth); + var segment = GetCurrentSegment(endpoint, depth); if (segment == null) { continue; @@ -133,7 +138,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers parent.CatchAll.CatchAll = parent.CatchAll; } - parent.CatchAll.Matches.Add(entry); + parent.CatchAll.Matches.Add(endpoint); } else if (segment.IsSimple && part.IsParameter) { @@ -172,7 +177,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers if (nextParents.Count > 0) { - nextWork.Add((entry, nextParents)); + nextWork.Add((endpoint, nextParents)); } } @@ -180,22 +185,26 @@ namespace Microsoft.AspNetCore.Routing.Matchers work = nextWork; } + // Build the trees of policy nodes (like HTTP methods). Post-order traversal + // means that we won't have infinite recursion. + root.Visit(ApplyPolicies); + return root; } - private RoutePatternPathSegment GetCurrentSegment(MatcherBuilderEntry entry, int depth) + private RoutePatternPathSegment GetCurrentSegment(MatcherEndpoint endpoint, int depth) { - if (depth < entry.RoutePattern.PathSegments.Count) + if (depth < endpoint.RoutePattern.PathSegments.Count) { - return entry.RoutePattern.PathSegments[depth]; + return endpoint.RoutePattern.PathSegments[depth]; } - if (entry.RoutePattern.PathSegments.Count == 0) + if (endpoint.RoutePattern.PathSegments.Count == 0) { return null; } - var lastSegment = entry.RoutePattern.PathSegments[entry.RoutePattern.PathSegments.Count - 1]; + var lastSegment = endpoint.RoutePattern.PathSegments[endpoint.RoutePattern.PathSegments.Count - 1]; if (lastSegment.IsSimple && lastSegment.Parts[0] is RoutePatternParameterPart parameterPart && parameterPart.IsCatchAll) { return lastSegment; @@ -209,46 +218,56 @@ namespace Microsoft.AspNetCore.Routing.Matchers var root = BuildDfaTree(); var states = new List(); - var tables = new List(); - AddNode(root, states, tables); + var tableBuilders = new List<(JumpTableBuilder pathBuilder, PolicyJumpTableBuilder policyBuilder)>(); + AddNode(root, states, tableBuilders); var exit = states.Count; - states.Add(new DfaState(CandidateSet.Empty, null)); - tables.Add(new JumpTableBuilder() { DefaultDestination = exit, ExitDestination = exit, }); + states.Add(new DfaState(Array.Empty(), null, null)); + tableBuilders.Add((new JumpTableBuilder() { DefaultDestination = exit, ExitDestination = exit, }, null)); - for (var i = 0; i < tables.Count; i++) + for (var i = 0; i < tableBuilders.Count; i++) { - if (tables[i].DefaultDestination == JumpTableBuilder.InvalidDestination) + if (tableBuilders[i].pathBuilder?.DefaultDestination == JumpTableBuilder.InvalidDestination) { - tables[i].DefaultDestination = exit; + tableBuilders[i].pathBuilder.DefaultDestination = exit; } - if (tables[i].ExitDestination == JumpTableBuilder.InvalidDestination) + if (tableBuilders[i].pathBuilder?.ExitDestination == JumpTableBuilder.InvalidDestination) { - tables[i].ExitDestination = exit; + tableBuilders[i].pathBuilder.ExitDestination = exit; + } + + if (tableBuilders[i].policyBuilder?.ExitDestination == JumpTableBuilder.InvalidDestination) + { + tableBuilders[i].policyBuilder.ExitDestination = exit; } } for (var i = 0; i < states.Count; i++) { - states[i] = new DfaState(states[i].Candidates, tables[i].Build()); + states[i] = new DfaState( + states[i].Candidates, + tableBuilders[i].pathBuilder?.Build(), + tableBuilders[i].policyBuilder?.Build()); } - return new DfaMatcher(_endpointSelector, states.ToArray()); + return new DfaMatcher(_selector, states.ToArray()); } - private int AddNode(DfaNode node, List states, List tables) + private int AddNode( + DfaNode node, + List states, + List<(JumpTableBuilder pathBuilder, PolicyJumpTableBuilder policyBuilder)> tableBuilders) { - node.Matches.Sort(); + node.Matches.Sort(_comparer); var stateIndex = states.Count; - var candidates = new CandidateSet( - node.Matches.Select(CreateCandidate).ToArray(), - CandidateSet.MakeGroups(GetGroupLengths(node))); - states.Add(new DfaState(candidates, null)); - var table = new JumpTableBuilder(); - tables.Add(table); + var candidates = CreateCandidates(node.Matches); + states.Add(new DfaState(candidates, null, null)); + + var pathBuilder = new JumpTableBuilder(); + tableBuilders.Add((pathBuilder, null)); foreach (var kvp in node.Literals) { @@ -258,7 +277,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers } var transition = Transition(kvp.Value); - table.AddEntry(kvp.Key, transition); + pathBuilder.AddEntry(kvp.Key, transition); } if (node.Parameters != null && @@ -267,26 +286,37 @@ namespace Microsoft.AspNetCore.Routing.Matchers { // This node has a single transition to but it should accept zero-width segments // this can happen when a node only has catchall parameters. - table.DefaultDestination = Transition(node.Parameters); - table.ExitDestination = table.DefaultDestination; + pathBuilder.DefaultDestination = Transition(node.Parameters); + pathBuilder.ExitDestination = pathBuilder.DefaultDestination; } else if (node.Parameters != null && node.CatchAll != null) { // This node has a separate transition for zero-width segments // this can happen when a node has both parameters and catchall parameters. - table.DefaultDestination = Transition(node.Parameters); - table.ExitDestination = Transition(node.CatchAll); + pathBuilder.DefaultDestination = Transition(node.Parameters); + pathBuilder.ExitDestination = Transition(node.CatchAll); } else if (node.Parameters != null) { // This node has paramters but no catchall. - table.DefaultDestination = Transition(node.Parameters); + pathBuilder.DefaultDestination = Transition(node.Parameters); } else if (node.CatchAll != null) { // This node has a catchall but no parameters - table.DefaultDestination = Transition(node.CatchAll); - table.ExitDestination = table.DefaultDestination; + pathBuilder.DefaultDestination = Transition(node.CatchAll); + pathBuilder.ExitDestination = pathBuilder.DefaultDestination; + } + + if (node.PolicyEdges.Count > 0) + { + var policyBuilder = new PolicyJumpTableBuilder(node.NodeBuilder); + tableBuilders[stateIndex] = (pathBuilder, policyBuilder); + + foreach (var kvp in node.PolicyEdges) + { + policyBuilder.AddEntry(kvp.Key, Transition(kvp.Value)); + } } return stateIndex; @@ -294,27 +324,58 @@ namespace Microsoft.AspNetCore.Routing.Matchers int Transition(DfaNode next) { // Break cycles - return ReferenceEquals(node, next) ? stateIndex : AddNode(next, states, tables); + return ReferenceEquals(node, next) ? stateIndex : AddNode(next, states, tableBuilders); } } + // Builds an array of candidates for a node, assigns a 'score' for each + // endpoint. + internal Candidate[] CreateCandidates(IReadOnlyList endpoints) + { + if (endpoints.Count == 0) + { + return Array.Empty(); + } + + var candiates = new Candidate[endpoints.Count]; + + var score = 0; + var examplar = endpoints[0]; + candiates[0] = CreateCandidate(examplar, score); + + for (var i = 1; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + if (!_comparer.Equals(examplar, endpoint)) + { + // This endpoint doesn't have the same priority. + examplar = endpoint; + score++; + } + + candiates[i] = CreateCandidate(endpoint, score); + } + + return candiates; + } + // internal for tests - internal Candidate CreateCandidate(MatcherBuilderEntry entry) + internal Candidate CreateCandidate(MatcherEndpoint endpoint, int score) { var assignments = new Dictionary(StringComparer.OrdinalIgnoreCase); var slots = new List>(); var captures = new List<(string parameterName, int segmentIndex, int slotIndex)>(); (string parameterName, int segmentIndex, int slotIndex) catchAll = default; - foreach (var kvp in entry.Endpoint.RoutePattern.Defaults) + foreach (var kvp in endpoint.RoutePattern.Defaults) { assignments.Add(kvp.Key, assignments.Count); slots.Add(kvp); } - for (var i = 0; i < entry.Endpoint.RoutePattern.PathSegments.Count; i++) + for (var i = 0; i < endpoint.RoutePattern.PathSegments.Count; i++) { - var segment = entry.Endpoint.RoutePattern.PathSegments[i]; + var segment = endpoint.RoutePattern.PathSegments[i]; if (!segment.IsSimple) { continue; @@ -346,9 +407,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers } var complexSegments = new List<(RoutePatternPathSegment pathSegment, int segmentIndex)>(); - for (var i = 0; i < entry.RoutePattern.PathSegments.Count; i++) + for (var i = 0; i < endpoint.RoutePattern.PathSegments.Count; i++) { - var segment = entry.RoutePattern.PathSegments[i]; + var segment = endpoint.RoutePattern.PathSegments[i]; if (segment.IsSimple) { continue; @@ -358,9 +419,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers } var matchProcessors = new List(); - foreach (var kvp in entry.Endpoint.RoutePattern.Constraints) + foreach (var kvp in endpoint.RoutePattern.Constraints) { - var parameter = entry.Endpoint.RoutePattern.GetParameter(kvp.Key); // may be null, that's ok + var parameter = endpoint.RoutePattern.GetParameter(kvp.Key); // may be null, that's ok var constraintReferences = kvp.Value; for (var i = 0; i < constraintReferences.Count; i++) { @@ -371,7 +432,8 @@ namespace Microsoft.AspNetCore.Routing.Matchers } return new Candidate( - entry.Endpoint, + endpoint, + score, slots.ToArray(), captures.ToArray(), catchAll, @@ -393,7 +455,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers for (var i = 1; i < node.Matches.Count; i++) { - if (!exemplar.PriorityEquals(node.Matches[i])) + if (!_comparer.Equals(exemplar, node.Matches[i])) { groups.Add(length); length = 0; @@ -409,11 +471,11 @@ namespace Microsoft.AspNetCore.Routing.Matchers return groups.ToArray(); } - private static bool HasAdditionalRequiredSegments(MatcherBuilderEntry entry, int depth) + private static bool HasAdditionalRequiredSegments(MatcherEndpoint endpoint, int depth) { - for (var i = depth; i < entry.RoutePattern.PathSegments.Count; i++) + for (var i = depth; i < endpoint.RoutePattern.PathSegments.Count; i++) { - var segment = entry.RoutePattern.PathSegments[i]; + var segment = endpoint.RoutePattern.PathSegments[i]; if (!segment.IsSimple) { // Complex segments always require more processing @@ -437,5 +499,59 @@ namespace Microsoft.AspNetCore.Routing.Matchers return false; } + + private void ApplyPolicies(DfaNode node) + { + if (node.Matches.Count == 0) + { + return; + } + + // Start with the current node as the root. + var work = new List() { node, }; + for (var i = 0; i < _nodeBuilders.Length; i++) + { + var nodeBuilder = _nodeBuilders[i]; + + // Build a list of each + var nextWork = new List(); + + for (var j = 0; j < work.Count; j++) + { + var parent = work[j]; + if (!nodeBuilder.AppliesToNode(parent.Matches)) + { + // This node-builder doesn't care about this node, so add it to the list + // to be processed by the next node-builder. + nextWork.Add(parent); + continue; + } + + // This node-builder does apply to this node, so we need to create new nodes for each edge, + // and then attach them to the parent. + var edges = nodeBuilder.GetEdges(parent.Matches); + for (var k = 0; k < edges.Count; k++) + { + var edge = edges[k]; + + var next = new DfaNode(); + + // TODO: https://github.com/aspnet/Routing/issues/648 + next.Matches.AddRange(edge.Endpoints.Cast().ToArray()); + nextWork.Add(next); + + parent.PolicyEdges.Add(edge.State, next); + } + + // Associate the node-builder so we can build a jump table later. + parent.NodeBuilder = nodeBuilder; + + // The parent no longer has matches, it's not considered a terminal node. + parent.Matches.Clear(); + } + + work = nextWork; + } + } } } diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DfaNode.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DfaNode.cs index bf16975db8..5508f0f460 100644 --- a/src/Microsoft.AspNetCore.Routing/Matchers/DfaNode.cs +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DfaNode.cs @@ -16,7 +16,8 @@ namespace Microsoft.AspNetCore.Routing.Matchers public DfaNode() { Literals = new Dictionary(StringComparer.OrdinalIgnoreCase); - Matches = new List(); + Matches = new List(); + PolicyEdges = new Dictionary(); } // The depth of the node. The depth indicates the number of segments @@ -25,8 +26,8 @@ namespace Microsoft.AspNetCore.Routing.Matchers // Just for diagnostics and debugging public string Label { get; set; } - - public List Matches { get; } + + public List Matches { get; } public Dictionary Literals { get; } @@ -34,6 +35,37 @@ namespace Microsoft.AspNetCore.Routing.Matchers public DfaNode CatchAll { get; set; } + public INodeBuilderPolicy NodeBuilder { get; set; } + + public Dictionary PolicyEdges { get; } + + public void Visit(Action visitor) + { + foreach (var kvp in Literals) + { + kvp.Value.Visit(visitor); + } + + // Break cycles + if (Parameters != null && !ReferenceEquals(this, Parameters)) + { + Parameters.Visit(visitor); + } + + // Break cycles + if (CatchAll != null && !ReferenceEquals(this, CatchAll)) + { + CatchAll.Visit(visitor); + } + + foreach (var kvp in PolicyEdges) + { + kvp.Value.Visit(visitor); + } + + visitor(this); + } + private string DebuggerToString() { var builder = new StringBuilder(); diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DfaState.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DfaState.cs index cdab988567..a854fdbc6f 100644 --- a/src/Microsoft.AspNetCore.Routing/Matchers/DfaState.cs +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DfaState.cs @@ -8,18 +8,23 @@ namespace Microsoft.AspNetCore.Routing.Matchers [DebuggerDisplay("{DebuggerToString(),nq}")] internal readonly struct DfaState { - public readonly CandidateSet Candidates; - public readonly JumpTable Transitions; + public readonly Candidate[] Candidates; + public readonly JumpTable PathTransitions; + public readonly PolicyJumpTable PolicyTransitions; - public DfaState(CandidateSet candidates, JumpTable transitions) + public DfaState(Candidate[] candidates, JumpTable pathTransitions, PolicyJumpTable policyTransitions) { Candidates = candidates; - Transitions = transitions; + PathTransitions = pathTransitions; + PolicyTransitions = policyTransitions; } public string DebuggerToString() { - return $"m: {Candidates.Candidates?.Length ?? 0}, j: ({Transitions?.DebuggerToString()})"; + return + $"matches: {Candidates?.Length ?? 0}, " + + $"path: ({PathTransitions?.DebuggerToString()}), " + + $"policy: ({PolicyTransitions?.DebuggerToString()})"; } } } diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/EndpointMetadataComparer.cs b/src/Microsoft.AspNetCore.Routing/Matchers/EndpointMetadataComparer.cs new file mode 100644 index 0000000000..7a742fecba --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/EndpointMetadataComparer.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public abstract class EndpointMetadataComparer : IComparer where TMetadata : class + { + public static readonly EndpointMetadataComparer Default = new DefaultComparer(); + + public int Compare(Endpoint x, Endpoint y) + { + if (x == null) + { + throw new ArgumentNullException(nameof(x)); + } + + if (y == null) + { + throw new ArgumentNullException(nameof(y)); + } + + return CompareMetadata(GetMetadata(x), GetMetadata(y)); + } + + protected virtual TMetadata GetMetadata(Endpoint endpoint) + { + return endpoint.Metadata.GetMetadata(); + } + + protected virtual int CompareMetadata(TMetadata x, TMetadata y) + { + // The default policy is that if x endpoint defines TMetadata, and + // y endpoint does not, then x is *more specific* than y. We return + // -1 for this case so that x will come first in the sort order. + + if (x == null && y != null) + { + // y is more specific + return 1; + } + else if (x != null && y == null) + { + // x is more specific + return -1; + } + + // both endpoints have this metadata, or both do not have it, they have + // the same specificity. + return 0; + } + + private class DefaultComparer : EndpointMetadataComparer where T : class + { + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/EndpointSelector.cs b/src/Microsoft.AspNetCore.Routing/Matchers/EndpointSelector.cs new file mode 100644 index 0000000000..6f8e420a59 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/EndpointSelector.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public abstract class EndpointSelector + { + public abstract Task SelectAsync( + HttpContext httpContext, + IEndpointFeature feature, + CandidateSet candidates); + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/HttpMethodMatcherPolicy.cs b/src/Microsoft.AspNetCore.Routing/Matchers/HttpMethodMatcherPolicy.cs new file mode 100644 index 0000000000..17802e61c0 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/HttpMethodMatcherPolicy.cs @@ -0,0 +1,182 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Metadata; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public sealed class HttpMethodEndpointSelectorPolicy : MatcherPolicy, IEndpointComparerPolicy, INodeBuilderPolicy + { + // Used in tests + internal const string Http405EndpointDisplayName = "405 HTTP Method Not Supported"; + + // Used in tests + internal const string AnyMethod = "*"; + + public IComparer Comparer => new HttpMethodMetadataEndpointComparer(); + + // The order value is chosen to be less than 0, so that it comes before naively + // written policies. + public override int Order => -1000; + + public bool AppliesToNode(IReadOnlyList endpoints) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + for (var i = 0; i < endpoints.Count; i++) + { + if (endpoints[i].Metadata.GetMetadata()?.HttpMethods.Any() == true) + { + return true; + } + } + + return false; + } + + public IReadOnlyList GetEdges(IReadOnlyList endpoints) + { + // The algorithm here is designed to be preserve the order of the endpoints + // while also being relatively simple. Preserving order is important. + var allHttpMethods = endpoints + .SelectMany(e => GetHttpMethods(e)) + .Distinct() + .OrderBy(m => m); // Sort for testability + + var dictionary = new Dictionary>(); + foreach (var httpMethod in allHttpMethods) + { + dictionary.Add(httpMethod, new List()); + } + + dictionary.Add(AnyMethod, new List()); + + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + + var httpMethods = GetHttpMethods(endpoint); + if (httpMethods.Count == 0) + { + // This endpoint suports all HTTP methods. + foreach (var kvp in dictionary) + { + kvp.Value.Add(endpoint); + } + + continue; + } + + for (var j = 0; j < httpMethods.Count; j++) + { + dictionary[httpMethods[j]].Add(endpoint); + } + } + + // Adds a very low priority endpoint that will reject the request with + // a 405 if nothing else can handle this verb. This is only done if + // no other actions exist that handle the 'all verbs'. + // + // The rationale for this is that we want to report a 405 if none of + // the supported methods match, but we don't want to report a 405 in a + // case where an application defines an endpoint that handles all verbs, but + // a constraint rejects the request, or a complex segment fails to parse. We + // consider a case like that a 'user input validation' failure rather than + // a semantic violation of HTTP. + // + // This will make 405 much more likely in API-focused applications, and somewhat + // unlikely in a traditional MVC application. That's good. + if (dictionary[AnyMethod].Count == 0) + { + dictionary[AnyMethod].Add(CreateRejectionEndpoint(allHttpMethods)); + } + + var edges = new List(); + foreach (var kvp in dictionary) + { + edges.Add(new PolicyNodeEdge(kvp.Key, kvp.Value)); + } + + return edges; + + IReadOnlyList GetHttpMethods(Endpoint e) + { + return e.Metadata.GetMetadata()?.HttpMethods ?? Array.Empty(); + } + } + + public PolicyJumpTable BuildJumpTable(int exitDestination, IReadOnlyList edges) + { + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < edges.Count; i++) + { + // We create this data, so it's safe to cast it to a string. + dictionary.Add((string)edges[i].State, edges[i].Destination); + } + + if (dictionary.TryGetValue(AnyMethod, out var matchesAnyVerb)) + { + // If we have endpoints that match any HTTP method, use that as the exit. + exitDestination = matchesAnyVerb; + dictionary.Remove(AnyMethod); + } + + return new DictionaryPolicyJumpTable(exitDestination, dictionary); + } + + private Endpoint CreateRejectionEndpoint(IEnumerable httpMethods) + { + var allow = string.Join(", ", httpMethods); + return new MatcherEndpoint( + (next) => (context) => + { + context.Response.StatusCode = 405; + context.Response.Headers.Add("Allow", allow); + return Task.CompletedTask; + }, + RoutePatternFactory.Parse("/"), + new RouteValueDictionary(), + 0, + EndpointMetadataCollection.Empty, + Http405EndpointDisplayName); + } + + private class DictionaryPolicyJumpTable : PolicyJumpTable + { + private readonly int _exitDestination; + private readonly Dictionary _destinations; + + public DictionaryPolicyJumpTable(int exitDestination, Dictionary destinations) + { + _exitDestination = exitDestination; + _destinations = destinations; + } + + public override int GetDestination(HttpContext httpContext) + { + var httpMethod = httpContext.Request.Method; + return _destinations.TryGetValue(httpMethod, out var destination) ? destination : _exitDestination; + } + } + + private class HttpMethodMetadataEndpointComparer : EndpointMetadataComparer + { + protected override int CompareMetadata(IHttpMethodMetadata x, IHttpMethodMetadata y) + { + // Ignore the metadata if it has an empty list of HTTP methods. + return base.CompareMetadata( + x?.HttpMethods.Count > 0 ? x : null, + y?.HttpMethods.Count > 0 ? y : null); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/IEndpointComparerPolicy.cs b/src/Microsoft.AspNetCore.Routing/Matchers/IEndpointComparerPolicy.cs new file mode 100644 index 0000000000..abd16e5e9a --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/IEndpointComparerPolicy.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public interface IEndpointComparerPolicy + { + IComparer Comparer { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/INodeBuilderPolicy.cs b/src/Microsoft.AspNetCore.Routing/Matchers/INodeBuilderPolicy.cs new file mode 100644 index 0000000000..5dab03ceb8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/INodeBuilderPolicy.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public interface INodeBuilderPolicy + { + bool AppliesToNode(IReadOnlyList endpoints); + + IReadOnlyList GetEdges(IReadOnlyList endpoints); + + PolicyJumpTable BuildJumpTable(int exitDestination, IReadOnlyList edges); + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/MatcherBuilderEntry.cs b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherBuilderEntry.cs deleted file mode 100644 index d1aed77693..0000000000 --- a/src/Microsoft.AspNetCore.Routing/Matchers/MatcherBuilderEntry.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.AspNetCore.Routing.Template; - -namespace Microsoft.AspNetCore.Routing.Matchers -{ - internal class MatcherBuilderEntry : IComparable - { - public MatcherBuilderEntry(MatcherEndpoint endpoint) - { - Endpoint = endpoint; - - Precedence = RoutePrecedence.ComputeInbound(endpoint.RoutePattern); - } - - public MatcherEndpoint Endpoint { get; } - - public int Order => Endpoint.Order; - - public RoutePattern RoutePattern => Endpoint.RoutePattern; - - public decimal Precedence { get; } - - public int CompareTo(MatcherBuilderEntry other) - { - var comparison = Order.CompareTo(other.Order); - if (comparison != 0) - { - return comparison; - } - - comparison = Precedence.CompareTo(other.Precedence); - if (comparison != 0) - { - return comparison; - } - - return RoutePattern.RawText.CompareTo(other.RoutePattern.RawText); - } - - public bool PriorityEquals(MatcherBuilderEntry other) - { - return Order == other.Order && Precedence == other.Precedence; - } - } -} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/MatcherEndpointComparer.cs b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherEndpointComparer.cs new file mode 100644 index 0000000000..fb5f087297 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherEndpointComparer.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Diagnostics; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + // Use to sort and group MatcherEndpoints. + // + // NOTE: + // When ordering endpoints, we compare the route templates as an absolute last resort. + // This is used as a factor to ensure that we always have a predictable ordering + // for tests, errors, etc. + // + // When we group endpoints we don't consider the route template, because we're trying + // to group endpoints not separate them. + // + // TLDR: + // IComparer implementation considers the template string as a tiebreaker. + // IEqualityComparer implementation does not. + // This is cool and good. + internal class MatcherEndpointComparer : IComparer, IEqualityComparer + { + private readonly IComparer[] _comparers; + + public MatcherEndpointComparer(IEndpointComparerPolicy[] policies) + { + // Order, Precedence, (others)... + _comparers = new IComparer[2 + policies.Length]; + _comparers[0] = OrderComparer.Instance; + _comparers[1] = PrecedenceComparer.Instance; + for (var i = 0; i < policies.Length; i++) + { + _comparers[i + 2] = policies[i].Comparer; + } + } + + public int Compare(MatcherEndpoint x, MatcherEndpoint y) + { + // We don't expose this publicly, and we should never call it on + // a null endpoint. + Debug.Assert(x != null); + Debug.Assert(y != null); + + var compare = CompareCore(x, y); + + // Since we're sorting, use the route template as a last resort. + return compare == 0 ? x.RoutePattern.RawText.CompareTo(y.RoutePattern.RawText) : compare; + } + + public bool Equals(MatcherEndpoint x, MatcherEndpoint y) + { + // We don't expose this publicly, and we should never call it on + // a null endpoint. + Debug.Assert(x != null); + Debug.Assert(y != null); + + return CompareCore(x, y) == 0; + } + + public int GetHashCode(MatcherEndpoint obj) + { + // This should not be possible to call publicly. + Debug.Fail("We don't expect this to be called."); + throw new System.NotImplementedException(); + } + + private int CompareCore(MatcherEndpoint x, MatcherEndpoint y) + { + for (var i = 0; i < _comparers.Length; i++) + { + var compare = _comparers[i].Compare(x, y); + if (compare != 0) + { + return compare; + } + } + + return 0; + } + + private class OrderComparer : IComparer + { + public static readonly IComparer Instance = new OrderComparer(); + + public int Compare(MatcherEndpoint x, MatcherEndpoint y) + { + return x.Order.CompareTo(y.Order); + } + } + + private class PrecedenceComparer : IComparer + { + public static readonly IComparer Instance = new PrecedenceComparer(); + + public int Compare(MatcherEndpoint x, MatcherEndpoint y) + { + return x.RoutePattern.InboundPrecedence.CompareTo(y.RoutePattern.InboundPrecedence); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/MatcherPolicy.cs b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherPolicy.cs new file mode 100644 index 0000000000..92d715c936 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherPolicy.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Routing +{ + public abstract class MatcherPolicy + { + public abstract int Order { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTable.cs b/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTable.cs new file mode 100644 index 0000000000..0aecc7de6c --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTable.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public abstract class PolicyJumpTable + { + public abstract int GetDestination(HttpContext httpContext); + + internal virtual string DebuggerToString() + { + return GetType().Name; + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTableBuilder.cs b/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTableBuilder.cs new file mode 100644 index 0000000000..c4e07b1715 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTableBuilder.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal class PolicyJumpTableBuilder + { + private readonly INodeBuilderPolicy _nodeBuilder; + private readonly List _entries; + + public PolicyJumpTableBuilder(INodeBuilderPolicy nodeBuilder) + { + _nodeBuilder = nodeBuilder; + _entries = new List(); + } + + // The destination state for a non-match. + public int ExitDestination { get; set; } = JumpTableBuilder.InvalidDestination; + + public void AddEntry(object state, int destination) + { + _entries.Add(new PolicyJumpTableEdge(state, destination)); + } + + public PolicyJumpTable Build() + { + return _nodeBuilder.BuildJumpTable(ExitDestination, _entries); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTableEdge.cs b/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTableEdge.cs new file mode 100644 index 0000000000..62482bb22c --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/PolicyJumpTableEdge.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public readonly struct PolicyJumpTableEdge + { + public PolicyJumpTableEdge(object state, int destination) + { + State = state ?? throw new System.ArgumentNullException(nameof(state)); + Destination = destination; + } + + public object State { get; } + + public int Destination { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/PolicyNodeEdge.cs b/src/Microsoft.AspNetCore.Routing/Matchers/PolicyNodeEdge.cs new file mode 100644 index 0000000000..bde491fa94 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/PolicyNodeEdge.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public readonly struct PolicyNodeEdge + { + public PolicyNodeEdge(object state, IReadOnlyList endpoints) + { + State = state ?? throw new System.ArgumentNullException(nameof(state)); + Endpoints = endpoints ?? throw new System.ArgumentNullException(nameof(endpoints)); + } + + public IReadOnlyList Endpoints { get; } + + public object State { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Metadata/HttpMethodMetadata.cs b/src/Microsoft.AspNetCore.Routing/Metadata/HttpMethodMetadata.cs new file mode 100644 index 0000000000..90cfd057a3 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Metadata/HttpMethodMetadata.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Routing.Metadata +{ + public sealed class HttpMethodMetadata : IHttpMethodMetadata + { + public HttpMethodMetadata(IEnumerable httpMethods) + { + if (httpMethods == null) + { + throw new ArgumentNullException(nameof(httpMethods)); + } + + HttpMethods = httpMethods.ToArray(); + } + + public IReadOnlyList HttpMethods { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Metadata/IHttpMethodMetadata.cs b/src/Microsoft.AspNetCore.Routing/Metadata/IHttpMethodMetadata.cs new file mode 100644 index 0000000000..a77a617f13 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Metadata/IHttpMethodMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing.Metadata +{ + public interface IHttpMethodMetadata + { + IReadOnlyList HttpMethods { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePattern.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePattern.cs index 74757849c9..bc1551367b 100644 --- a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePattern.cs +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePattern.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using Microsoft.AspNetCore.Routing.Template; namespace Microsoft.AspNetCore.Routing.Patterns { @@ -30,12 +31,19 @@ namespace Microsoft.AspNetCore.Routing.Patterns Constraints = constraints; Parameters = parameters; PathSegments = pathSegments; + + InboundPrecedence = RoutePrecedence.ComputeInbound(this); + OutboundPrecedence = RoutePrecedence.ComputeOutbound(this); } public IReadOnlyDictionary Defaults { get; } public IReadOnlyDictionary> Constraints { get; } + public decimal InboundPrecedence { get; } + + public decimal OutboundPrecedence { get; } + public string RawText { get; } public IReadOnlyList Parameters { get; } diff --git a/src/Microsoft.AspNetCore.Routing/RouteValuesBasedEndpointFinder.cs b/src/Microsoft.AspNetCore.Routing/RouteValuesBasedEndpointFinder.cs index 2c3afaf55d..e4fc5c16a7 100644 --- a/src/Microsoft.AspNetCore.Routing/RouteValuesBasedEndpointFinder.cs +++ b/src/Microsoft.AspNetCore.Routing/RouteValuesBasedEndpointFinder.cs @@ -107,6 +107,13 @@ namespace Microsoft.AspNetCore.Routing var endpoints = _endpointDataSource.Endpoints.OfType(); foreach (var endpoint in endpoints) { + // Do not consider an endpoint for link generation if the following marker metadata is on it + var suppressLinkGeneration = endpoint.Metadata.GetMetadata(); + if (suppressLinkGeneration != null) + { + continue; + } + var entry = CreateOutboundRouteEntry(endpoint); var outboundMatch = new OutboundMatch() { Entry = entry }; diff --git a/test/Microsoft.AspNetCore.Routing.Tests/CompositeEndpointDataSourceTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/CompositeEndpointDataSourceTest.cs index 52ba52dafc..16a04cc587 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/CompositeEndpointDataSourceTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/CompositeEndpointDataSourceTest.cs @@ -173,7 +173,8 @@ namespace Microsoft.AspNetCore.Routing _token = new CancellationChangeToken(_cts.Token); } - public override IChangeToken ChangeToken => _token; + public override IChangeToken GetChangeToken() => _token; + public override IChangeToken ChangeToken => GetChangeToken(); public override IReadOnlyList Endpoints => Array.Empty(); } } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/EndpointConstraints/EndpointConstraintEndpointSelectorTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/EndpointConstraints/EndpointConstraintEndpointSelectorTest.cs new file mode 100644 index 0000000000..fe5d9bb3b2 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/EndpointConstraints/EndpointConstraintEndpointSelectorTest.cs @@ -0,0 +1,510 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.EndpointConstraints +{ + public class EndpointConstraintEndpointSelectorTest + { + [Fact] + public async Task SelectBestCandidate_MultipleEndpoints_BestMatchSelected() + { + // Arrange + var defaultEndpoint = CreateEndpoint("No constraint endpoint"); + + var postEndpoint = CreateEndpoint( + "POST constraint endpoint", + new HttpMethodEndpointConstraint(new[] { "POST" })); + + var endpoints = new[] + { + defaultEndpoint, + postEndpoint + }; + + var selector = CreateSelector(endpoints); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Same(postEndpoint, feature.Endpoint); + } + + [Fact] + public async Task SelectBestCandidate_MultipleEndpoints_AmbiguousMatchExceptionThrown() + { + // Arrange + var expectedMessage = + "The request matched multiple endpoints. Matches: " + Environment.NewLine + + Environment.NewLine + + "Ambiguous1" + Environment.NewLine + + "Ambiguous2"; + + var defaultEndpoint1 = CreateEndpoint("Ambiguous1"); + var defaultEndpoint2 = CreateEndpoint("Ambiguous2"); + + var endpoints = new[] + { + defaultEndpoint1, + defaultEndpoint2 + }; + + var selector = CreateSelector(endpoints); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + + var feature = new EndpointFeature(); + + // Act + var ex = await Assert.ThrowsAnyAsync(() => + { + return selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + }); + + // Assert + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public async Task SelectBestCandidate_AmbiguousEndpoints_LogIsCorrect() + { + // Arrange + var sink = new TestSink(); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + var endpoints = new[] + { + CreateEndpoint("A1"), + CreateEndpoint("A2"), + }; + + var selector = CreateSelector(endpoints, loggerFactory); + + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + var names = string.Join(", ", endpoints.Select(action => action.DisplayName)); + var expectedMessage = + $"Request matched multiple endpoints for request path '/test'. " + + $"Matching endpoints: {names}"; + + // Act + await Assert.ThrowsAsync(() => + { + return selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + }); + + // Assert + Assert.Empty(sink.Scopes); + var write = Assert.Single(sink.Writes); + Assert.Equal(expectedMessage, write.State?.ToString()); + } + + [Fact] + public async Task SelectBestCandidate_PrefersEndpointWithConstraints() + { + // Arrange + var endpointWithConstraint = CreateEndpoint( + "Has constraint", + new HttpMethodEndpointConstraint(new string[] { "POST" })); + + var endpointWithoutConstraints = CreateEndpoint("No constraint"); + + var endpoints = new[] { endpointWithConstraint, endpointWithoutConstraints }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Same(endpointWithConstraint, endpointWithConstraint); + } + + [Fact] + public async Task SelectBestCandidate_ConstraintsRejectAll() + { + // Arrange + var endpoint1 = CreateEndpoint( + "action1", + new BooleanConstraint() { Pass = false, }); + + var endpoint2 = CreateEndpoint( + "action2", + new BooleanConstraint() { Pass = false, }); + + var endpoints = new[] { endpoint1, endpoint2 }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Null(feature.Endpoint); + } + + [Fact] + public async Task SelectBestCandidate_ConstraintsRejectAll_DifferentStages() + { + // Arrange + var endpoint1 = CreateEndpoint( + "action1", + new BooleanConstraint() { Pass = false, Order = 0 }, + new BooleanConstraint() { Pass = true, Order = 1 }); + + var endpoint2 = CreateEndpoint( + "action2", + new BooleanConstraint() { Pass = true, Order = 0 }, + new BooleanConstraint() { Pass = false, Order = 1 }); + + var endpoints = new[] { endpoint1, endpoint2 }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Null(feature.Endpoint); + } + + [Fact] + public async Task SelectBestCandidate_EndpointConstraintFactory() + { + // Arrange + var endpointWithConstraints = CreateEndpoint( + "actionWithConstraints", + new ConstraintFactory() + { + Constraint = new BooleanConstraint() { Pass = true }, + }); + + var actionWithoutConstraints = CreateEndpoint("actionWithoutConstraints"); + + var endpoints = new[] { endpointWithConstraints, actionWithoutConstraints }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Same(endpointWithConstraints, feature.Endpoint); + } + + [Fact] + public async Task SelectBestCandidate_MultipleCallsNoConstraint_ReturnsEndpoint() + { + // Arrange + var noConstraint = CreateEndpoint("noConstraint"); + + var endpoints = new[] { noConstraint }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + var endpoint1 = feature.Endpoint; + + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + var endpoint2 = feature.Endpoint; + + // Assert + Assert.Same(endpoint1, noConstraint); + Assert.Same(endpoint2, noConstraint); + } + + [Fact] + public async Task SelectBestCandidate_MultipleCallsNonConstraintMetadata_ReturnsEndpoint() + { + // Arrange + var noConstraint = CreateEndpoint("noConstraint", new object()); + + var endpoints = new[] { noConstraint }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + var endpoint1 = feature.Endpoint; + + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + var endpoint2 = feature.Endpoint; + + // Assert + Assert.Same(endpoint1, noConstraint); + Assert.Same(endpoint2, noConstraint); + } + + [Fact] + public async Task SelectBestCandidate_EndpointConstraintFactory_ReturnsNull() + { + // Arrange + var nullConstraint = CreateEndpoint("nullConstraint", new ConstraintFactory()); + + var endpoints = new[] { nullConstraint }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + var endpoint1 = feature.Endpoint; + + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + var endpoint2 = feature.Endpoint; + + // Assert + Assert.Same(endpoint1, nullConstraint); + Assert.Same(endpoint2, nullConstraint); + } + + // There's a custom constraint provider registered that only understands BooleanConstraintMarker + [Fact] + public async Task SelectBestCandidate_CustomProvider() + { + // Arrange + var endpointWithConstraints = CreateEndpoint( + "actionWithConstraints", + new BooleanConstraintMarker() { Pass = true }); + + var endpointWithoutConstraints = CreateEndpoint("actionWithoutConstraints"); + + var endpoints = new[] { endpointWithConstraints, endpointWithoutConstraints, }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Same(endpointWithConstraints, feature.Endpoint); + } + + // Due to ordering of stages, the first action will be better. + [Fact] + public async Task SelectBestCandidate_ConstraintsInOrder() + { + // Arrange + var best = CreateEndpoint("best", new BooleanConstraint() { Pass = true, Order = 0, }); + + var worst = CreateEndpoint("worst", new BooleanConstraint() { Pass = true, Order = 1, }); + + var endpoints = new[] { best, worst }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Same(best, feature.Endpoint); + } + + // Due to ordering of stages, the first action will be better. + [Fact] + public async Task SelectBestCandidate_ConstraintsInOrder_MultipleStages() + { + // Arrange + var best = CreateEndpoint( + "best", + new BooleanConstraint() { Pass = true, Order = 0, }, + new BooleanConstraint() { Pass = true, Order = 1, }, + new BooleanConstraint() { Pass = true, Order = 2, }); + + var worst = CreateEndpoint( + "worst", + new BooleanConstraint() { Pass = true, Order = 0, }, + new BooleanConstraint() { Pass = true, Order = 1, }, + new BooleanConstraint() { Pass = true, Order = 3, }); + + var endpoints = new[] { best, worst }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Same(best, feature.Endpoint); + } + + [Fact] + public async Task SelectBestCandidate_Fallback_ToEndpointWithoutConstraints() + { + // Arrange + var nomatch1 = CreateEndpoint( + "nomatch1", + new BooleanConstraint() { Pass = true, Order = 0, }, + new BooleanConstraint() { Pass = true, Order = 1, }, + new BooleanConstraint() { Pass = false, Order = 2, }); + + var nomatch2 = CreateEndpoint( + "nomatch2", + new BooleanConstraint() { Pass = true, Order = 0, }, + new BooleanConstraint() { Pass = true, Order = 1, }, + new BooleanConstraint() { Pass = false, Order = 3, }); + + var best = CreateEndpoint("best"); + + var endpoints = new[] { best, nomatch1, nomatch2 }; + + var selector = CreateSelector(endpoints); + var httpContext = CreateHttpContext("POST"); + var feature = new EndpointFeature(); + + // Act + await selector.SelectAsync(httpContext, feature, CreateCandidateSet(endpoints)); + + // Assert + Assert.Same(best, feature.Endpoint); + } + + private static MatcherEndpoint CreateEndpoint(string displayName, params object[] metadata) + { + return new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse("/"), + new RouteValueDictionary(), + 0, + new EndpointMetadataCollection(metadata), + displayName); + } + + private static CandidateSet CreateCandidateSet(MatcherEndpoint[] endpoints) + { + var scores = new int[endpoints.Length]; + return new CandidateSet(endpoints, scores); + } + + private static EndpointSelector CreateSelector(IReadOnlyList actions, ILoggerFactory loggerFactory = null) + { + loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + + var endpointDataSource = new CompositeEndpointDataSource(new[] { new DefaultEndpointDataSource(actions) }); + + var actionConstraintProviders = new IEndpointConstraintProvider[] { + new DefaultEndpointConstraintProvider(), + new BooleanConstraintProvider(), + }; + + return new EndpointConstraintEndpointSelector( + endpointDataSource, + GetEndpointConstraintCache(actionConstraintProviders), + loggerFactory); + } + + private static HttpContext CreateHttpContext(string httpMethod) + { + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + + var httpContext = new Mock(MockBehavior.Strict); + + var request = new Mock(MockBehavior.Strict); + request.SetupGet(r => r.Method).Returns(httpMethod); + request.SetupGet(r => r.Path).Returns(new PathString("/test")); + request.SetupGet(r => r.Headers).Returns(new HeaderDictionary()); + httpContext.SetupGet(c => c.Request).Returns(request.Object); + httpContext.SetupGet(c => c.RequestServices).Returns(serviceProvider); + + return httpContext.Object; + } + + private static EndpointConstraintCache GetEndpointConstraintCache(IEndpointConstraintProvider[] actionConstraintProviders = null) + { + return new EndpointConstraintCache( + new CompositeEndpointDataSource(Array.Empty()), + actionConstraintProviders.AsEnumerable() ?? new List()); + } + + private class BooleanConstraint : IEndpointConstraint + { + public bool Pass { get; set; } + + public int Order { get; set; } + + public bool Accept(EndpointConstraintContext context) + { + return Pass; + } + } + + private class ConstraintFactory : IEndpointConstraintFactory + { + public IEndpointConstraint Constraint { get; set; } + + public bool IsReusable => true; + + public IEndpointConstraint CreateInstance(IServiceProvider services) + { + return Constraint; + } + } + + private class BooleanConstraintMarker : IEndpointConstraintMetadata + { + public bool Pass { get; set; } + } + + private class BooleanConstraintProvider : IEndpointConstraintProvider + { + public int Order { get; set; } + + public void OnProvidersExecuting(EndpointConstraintProviderContext context) + { + foreach (var item in context.Results) + { + if (item.Metadata is BooleanConstraintMarker marker) + { + Assert.Null(item.Constraint); + item.Constraint = new BooleanConstraint() { Pass = marker.Pass }; + } + } + } + + public void OnProvidersExecuted(EndpointConstraintProviderContext context) + { + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/EndpointConstraints/EndpointSelectorTests.cs b/test/Microsoft.AspNetCore.Routing.Tests/EndpointConstraints/EndpointSelectorTests.cs deleted file mode 100644 index 008464113e..0000000000 --- a/test/Microsoft.AspNetCore.Routing.Tests/EndpointConstraints/EndpointSelectorTests.cs +++ /dev/null @@ -1,505 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing.Matchers; -using Microsoft.AspNetCore.Routing.TestObjects; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Logging.Testing; -using Moq; -using Xunit; - -namespace Microsoft.AspNetCore.Routing.EndpointConstraints -{ - public class EndpointSelectorTests - { - [Fact] - public void SelectBestCandidate_MultipleEndpoints_BestMatchSelected() - { - // Arrange - var defaultEndpoint = new TestEndpoint( - EndpointMetadataCollection.Empty, - "No constraint endpoint"); - - var postEndpoint = new TestEndpoint( - new EndpointMetadataCollection(new object[] { new HttpMethodEndpointConstraint(new[] { "POST" }) }), - "POST constraint endpoint"); - - var endpoints = new Endpoint[] - { - defaultEndpoint, - postEndpoint - }; - - var endpointSelector = CreateSelector(endpoints); - - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "POST"; - - // Act - var bestCandidateEndpoint = endpointSelector.SelectBestCandidate(httpContext, endpoints); - - // Assert - Assert.NotNull(postEndpoint); - } - - [Fact] - public void SelectBestCandidate_MultipleEndpoints_AmbiguousMatchExceptionThrown() - { - // Arrange - var expectedMessage = - "The request matched multiple endpoints. Matches: " + Environment.NewLine + - Environment.NewLine + - "Ambiguous1" + Environment.NewLine + - "Ambiguous2"; - - var defaultEndpoint1 = new TestEndpoint( - EndpointMetadataCollection.Empty, - "Ambiguous1"); - - var defaultEndpoint2 = new TestEndpoint( - EndpointMetadataCollection.Empty, - "Ambiguous2"); - - var endpoints = new Endpoint[] - { - defaultEndpoint1, - defaultEndpoint2 - }; - - var endpointSelector = CreateSelector(endpoints); - - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "POST"; - - // Act - var ex = Assert.ThrowsAny(() => - { - endpointSelector.SelectBestCandidate(httpContext, endpoints); - }); - - // Assert - Assert.Equal(expectedMessage, ex.Message); - } - - [Fact] - public void SelectBestCandidate_AmbiguousEndpoints_LogIsCorrect() - { - // Arrange - var sink = new TestSink(); - var loggerFactory = new TestLoggerFactory(sink, enabled: true); - - var actions = new Endpoint[] - { - new TestEndpoint(EndpointMetadataCollection.Empty, "A1"), - new TestEndpoint(EndpointMetadataCollection.Empty, "A2"), - }; - var selector = CreateSelector(actions, loggerFactory); - - var httpContext = CreateHttpContext("POST"); - var actionNames = string.Join(", ", actions.Select(action => action.DisplayName)); - var expectedMessage = $"Request matched multiple endpoints for request path '/test'. Matching endpoints: {actionNames}"; - - // Act - Assert.Throws(() => { selector.SelectBestCandidate(httpContext, actions); }); - - // Assert - Assert.Empty(sink.Scopes); - var write = Assert.Single(sink.Writes); - Assert.Equal(expectedMessage, write.State?.ToString()); - } - - [Fact] - public void SelectBestCandidate_PrefersEndpointWithConstraints() - { - // Arrange - var actionWithConstraints = new TestEndpoint( - new EndpointMetadataCollection(new[] { new HttpMethodEndpointConstraint(new string[] { "POST" }) }), - "Has constraint"); - - var actionWithoutConstraints = new TestEndpoint( - EndpointMetadataCollection.Empty, - "No constraint"); - - var actions = new Endpoint[] { actionWithConstraints, actionWithoutConstraints }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action, actionWithConstraints); - } - - [Fact] - public void SelectBestCandidate_ConstraintsRejectAll() - { - // Arrange - var action1 = new TestEndpoint( - new EndpointMetadataCollection(new[] { new BooleanConstraint() { Pass = false, } }), - "action1"); - - var action2 = new TestEndpoint( - new EndpointMetadataCollection(new[] { new BooleanConstraint() { Pass = false, } }), - "action2"); - - var actions = new Endpoint[] { action1, action2 }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Null(action); - } - - [Fact] - public void SelectBestCandidate_ConstraintsRejectAll_DifferentStages() - { - // Arrange - var action1 = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraint() { Pass = false, Order = 0 }, - new BooleanConstraint() { Pass = true, Order = 1 }, - }), - "action1"); - - var action2 = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraint() { Pass = true, Order = 0 }, - new BooleanConstraint() { Pass = false, Order = 1 }, - }), - "action2"); - - var actions = new Endpoint[] { action1, action2 }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Null(action); - } - - [Fact] - public void SelectBestCandidate_EndpointConstraintFactory() - { - // Arrange - var actionWithConstraints = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new ConstraintFactory() - { - Constraint = new BooleanConstraint() { Pass = true }, - }, - }), - "actionWithConstraints"); - - var actionWithoutConstraints = new TestEndpoint( - EndpointMetadataCollection.Empty, - "actionWithoutConstraints"); - - var actions = new Endpoint[] { actionWithConstraints, actionWithoutConstraints }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action, actionWithConstraints); - } - - [Fact] - public void SelectBestCandidate_MultipleCallsNoConstraint_ReturnsEndpoint() - { - // Arrange - var noConstraint = new TestEndpoint(EndpointMetadataCollection.Empty, "noConstraint"); - - var actions = new Endpoint[] { noConstraint }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action1 = selector.SelectBestCandidate(context, actions); - var action2 = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action1, noConstraint); - Assert.Same(action2, noConstraint); - } - - [Fact] - public void SelectBestCandidate_MultipleCallsNonConstraintMetadata_ReturnsEndpoint() - { - // Arrange - var noConstraint = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new object(), - }), - "noConstraint"); - - var actions = new Endpoint[] { noConstraint }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action1 = selector.SelectBestCandidate(context, actions); - var action2 = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action1, noConstraint); - Assert.Same(action2, noConstraint); - } - - [Fact] - public void SelectBestCandidate_EndpointConstraintFactory_ReturnsNull() - { - // Arrange - var nullConstraint = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new ConstraintFactory(), - }), - "nullConstraint"); - - var actions = new Endpoint[] { nullConstraint }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action1 = selector.SelectBestCandidate(context, actions); - var action2 = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action1, nullConstraint); - Assert.Same(action2, nullConstraint); - } - - // There's a custom constraint provider registered that only understands BooleanConstraintMarker - [Fact] - public void SelectBestCandidate_CustomProvider() - { - // Arrange - var actionWithConstraints = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraintMarker() { Pass = true }, - }), - "actionWithConstraints"); - - var actionWithoutConstraints = new TestEndpoint( - EndpointMetadataCollection.Empty, - "actionWithoutConstraints"); - - var actions = new Endpoint[] { actionWithConstraints, actionWithoutConstraints, }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action, actionWithConstraints); - } - - // Due to ordering of stages, the first action will be better. - [Fact] - public void SelectBestCandidate_ConstraintsInOrder() - { - // Arrange - var best = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraint() { Pass = true, Order = 0, }, - }), - "best"); - - var worst = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraint() { Pass = true, Order = 1, }, - }), - "worst"); - - var actions = new Endpoint[] { best, worst }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action, best); - } - - // Due to ordering of stages, the first action will be better. - [Fact] - public void SelectBestCandidate_ConstraintsInOrder_MultipleStages() - { - // Arrange - var best = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraint() { Pass = true, Order = 0, }, - new BooleanConstraint() { Pass = true, Order = 1, }, - new BooleanConstraint() { Pass = true, Order = 2, }, - }), - "best"); - - var worst = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraint() { Pass = true, Order = 0, }, - new BooleanConstraint() { Pass = true, Order = 1, }, - new BooleanConstraint() { Pass = true, Order = 3, }, - }), - "worst"); - - var actions = new Endpoint[] { best, worst }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action, best); - } - - [Fact] - public void SelectBestCandidate_Fallback_ToEndpointWithoutConstraints() - { - // Arrange - var nomatch1 = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraint() { Pass = true, Order = 0, }, - new BooleanConstraint() { Pass = true, Order = 1, }, - new BooleanConstraint() { Pass = false, Order = 2, }, - }), - "nomatch1"); - - var nomatch2 = new TestEndpoint(new EndpointMetadataCollection(new[] - { - new BooleanConstraint() { Pass = true, Order = 0, }, - new BooleanConstraint() { Pass = true, Order = 1, }, - new BooleanConstraint() { Pass = false, Order = 3, }, - }), - "nomatch2"); - - var best = new TestEndpoint(EndpointMetadataCollection.Empty, "best"); - - var actions = new Endpoint[] { best, nomatch1, nomatch2 }; - - var selector = CreateSelector(actions); - var context = CreateHttpContext("POST"); - - // Act - var action = selector.SelectBestCandidate(context, actions); - - // Assert - Assert.Same(action, best); - } - - private static EndpointSelector CreateSelector(IReadOnlyList actions, ILoggerFactory loggerFactory = null) - { - loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; - - var endpointDataSource = new CompositeEndpointDataSource(new[] { new DefaultEndpointDataSource(actions) }); - - var actionConstraintProviders = new IEndpointConstraintProvider[] { - new DefaultEndpointConstraintProvider(), - new BooleanConstraintProvider(), - }; - - return new EndpointSelector( - endpointDataSource, - GetEndpointConstraintCache(actionConstraintProviders), - loggerFactory); - } - - private static HttpContext CreateHttpContext(string httpMethod) - { - var serviceProvider = new ServiceCollection().BuildServiceProvider(); - - var httpContext = new Mock(MockBehavior.Strict); - - var request = new Mock(MockBehavior.Strict); - request.SetupGet(r => r.Method).Returns(httpMethod); - request.SetupGet(r => r.Path).Returns(new PathString("/test")); - request.SetupGet(r => r.Headers).Returns(new HeaderDictionary()); - httpContext.SetupGet(c => c.Request).Returns(request.Object); - httpContext.SetupGet(c => c.RequestServices).Returns(serviceProvider); - - return httpContext.Object; - } - - private static EndpointConstraintCache GetEndpointConstraintCache(IEndpointConstraintProvider[] actionConstraintProviders = null) - { - return new EndpointConstraintCache( - new CompositeEndpointDataSource(Array.Empty()), - actionConstraintProviders.AsEnumerable() ?? new List()); - } - - private class BooleanConstraint : IEndpointConstraint - { - public bool Pass { get; set; } - - public int Order { get; set; } - - public bool Accept(EndpointConstraintContext context) - { - return Pass; - } - } - - private class ConstraintFactory : IEndpointConstraintFactory - { - public IEndpointConstraint Constraint { get; set; } - - public bool IsReusable => true; - - public IEndpointConstraint CreateInstance(IServiceProvider services) - { - return Constraint; - } - } - - private class BooleanConstraintMarker : IEndpointConstraintMetadata - { - public bool Pass { get; set; } - } - - private class BooleanConstraintProvider : IEndpointConstraintProvider - { - public int Order { get; set; } - - public void OnProvidersExecuting(EndpointConstraintProviderContext context) - { - foreach (var item in context.Results) - { - if (item.Metadata is BooleanConstraintMarker marker) - { - Assert.Null(item.Constraint); - item.Constraint = new BooleanConstraint() { Pass = marker.Pass }; - } - } - } - - public void OnProvidersExecuted(EndpointConstraintProviderContext context) - { - } - } - } -} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Internal/HttpMethodEndpointConstraintTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Internal/HttpMethodEndpointConstraintTest.cs index 67af016137..53b38fea1a 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Internal/HttpMethodEndpointConstraintTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Internal/HttpMethodEndpointConstraintTest.cs @@ -63,6 +63,8 @@ namespace Microsoft.AspNetCore.Routing.Internal var endpointSelectorCandidate = new EndpointSelectorCandidate( new TestEndpoint(EndpointMetadataCollection.Empty, string.Empty), + 0, + new RouteValueDictionary(), new List { constraint }); context.Candidates = new List { endpointSelectorCandidate }; diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Internal/LinkGenerationDecisionTreeTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Internal/LinkGenerationDecisionTreeTest.cs index c8c34b09d4..f07296faba 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Internal/LinkGenerationDecisionTreeTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Internal/LinkGenerationDecisionTreeTest.cs @@ -1,9 +1,11 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.AspNetCore.Routing.Template; using Microsoft.AspNetCore.Routing.Tree; using Xunit; @@ -318,11 +320,45 @@ namespace Microsoft.AspNetCore.Routing.Internal.Routing Assert.Equal(entries, matches); } - private OutboundMatch CreateMatch(object requiredValues) + [Fact] + public void ToDebuggerDisplayString_GivesAFlattenedTree() + { + // Arrange + var entries = new List(); + entries.Add(CreateMatch(new { action = "Buy", controller = "Store", version = "V1" }, "Store/Buy/V1")); + entries.Add(CreateMatch(new { action = "Buy", controller = "Store", area = "Admin" }, "Admin/Store/Buy")); + entries.Add(CreateMatch(new { action = "Buy", controller = "Products" }, "Products/Buy")); + entries.Add(CreateMatch(new { action = "Buy", controller = "Store", version = "V2" }, "Store/Buy/V2")); + entries.Add(CreateMatch(new { action = "Cart", controller = "Store" }, "Store/Cart")); + entries.Add(CreateMatch(new { action = "Index", controller = "Home" }, "Home/Index/{id?}")); + var tree = new LinkGenerationDecisionTree(entries); + var newLine = Environment.NewLine; + var expected = + " => action: Buy => controller: Store => version: V1 (Matches: Store/Buy/V1)" + newLine + + " => action: Buy => controller: Store => version: V2 (Matches: Store/Buy/V2)" + newLine + + " => action: Buy => controller: Store => area: Admin (Matches: Admin/Store/Buy)" + newLine + + " => action: Buy => controller: Products (Matches: Products/Buy)" + newLine + + " => action: Cart => controller: Store (Matches: Store/Cart)" + newLine + + " => action: Index => controller: Home (Matches: Home/Index/{id?})" + newLine; + + // Act + var flattenedTree = tree.DebuggerDisplayString; + + // Assert + Assert.Equal(expected, flattenedTree); + } + + private OutboundMatch CreateMatch(object requiredValues, string routeTemplate = null) { var match = new OutboundMatch(); match.Entry = new OutboundRouteEntry(); match.Entry.RequiredLinkValues = new RouteValueDictionary(requiredValues); + + if (!string.IsNullOrEmpty(routeTemplate)) + { + match.Entry.RouteTemplate = new RouteTemplate(RoutePatternFactory.Parse(routeTemplate)); + } + return match; } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/BarebonesMatcher.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/BarebonesMatcher.cs index d70cd6eece..1d94447c06 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/BarebonesMatcher.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/BarebonesMatcher.cs @@ -49,18 +49,14 @@ namespace Microsoft.AspNetCore.Routing.Matchers public readonly MatcherEndpoint Endpoint; private readonly string[] _segments; - private readonly CandidateSet _candidates; + private readonly Candidate[] _candidates; public InnerMatcher(string[] segments, MatcherEndpoint endpoint) { _segments = segments; Endpoint = endpoint; - _candidates = new CandidateSet( - new Candidate[] { new Candidate(endpoint), }, - - // Single candidate group that contains one entry. - CandidateSet.MakeGroups(new[] { 1 })); + _candidates = new Candidate[] { new Candidate(endpoint), }; } public bool TryMatch(string path) @@ -114,14 +110,14 @@ namespace Microsoft.AspNetCore.Routing.Matchers return segment == _segments.Length; } - internal CandidateSet SelectCandidates(string path, ReadOnlySpan segments) + internal Candidate[] FindCandidateSet(string path, ReadOnlySpan segments) { if (TryMatch(path)) { return _candidates; } - return CandidateSet.Empty; + return Array.Empty(); } public override Task MatchAsync(HttpContext httpContext, IEndpointFeature feature) diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/CandidateSetTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/CandidateSetTest.cs new file mode 100644 index 0000000000..7b75859884 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/CandidateSetTest.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.AspNetCore.Routing.Patterns; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public class CandidateSetTest + { + // We special case low numbers of candidates, so we want to verify that it works correctly for a variety + // of input sizes. + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] // this is the break-point where we start to use a list. + [InlineData(6)] + public void Create_CreatesCandidateSet(int count) + { + // Arrange + var endpoints = new MatcherEndpoint[count]; + for (var i = 0; i < endpoints.Length; i++) + { + endpoints[i] = CreateEndpoint($"/{i}"); + } + + var builder = CreateDfaMatcherBuilder(); + var candidates = builder.CreateCandidates(endpoints); + + // Act + var candidateSet = new CandidateSet(candidates); + + // Assert + for (var i = 0; i < candidateSet.Count; i++) + { + ref var state = ref candidateSet[i]; + Assert.True(state.IsValidCandidate); + Assert.Same(endpoints[i], state.Endpoint); + Assert.Equal(candidates[i].Score, state.Score); + Assert.Null(state.Values); + } + } + + // We special case low numbers of candidates, so we want to verify that it works correctly for a variety + // of input sizes. + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] // this is the break-point where we start to use a list. + [InlineData(6)] + public void Create_CreatesCandidateSet_TestConstructor(int count) + { + // Arrange + var endpoints = new MatcherEndpoint[count]; + for (var i = 0; i < endpoints.Length; i++) + { + endpoints[i] = CreateEndpoint($"/{i}"); + } + + // Act + var candidateSet = new CandidateSet(endpoints, Enumerable.Range(0, count).ToArray()); + + // Assert + for (var i = 0; i < candidateSet.Count; i++) + { + ref var state = ref candidateSet[i]; + Assert.True(state.IsValidCandidate); + Assert.Same(endpoints[i], state.Endpoint); + Assert.Equal(i, state.Score); + Assert.Null(state.Values); + } + } + + private MatcherEndpoint CreateEndpoint(string template) + { + return new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse(template), + new RouteValueDictionary(), + 0, + EndpointMetadataCollection.Empty, + "test"); + } + + private static DfaMatcherBuilder CreateDfaMatcherBuilder(params MatcherPolicy[] policies) + { + var dataSource = new CompositeEndpointDataSource(Array.Empty()); + return new DfaMatcherBuilder( + Mock.Of(), + Mock.Of(), + policies); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DefaultEndpointSelectorTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DefaultEndpointSelectorTest.cs new file mode 100644 index 0000000000..13d6ff86e9 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DefaultEndpointSelectorTest.cs @@ -0,0 +1,203 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Patterns; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public class DefaultEndpointSelectorTest + { + [Fact] + public async Task SelectAsync_NoCandidates_DoesNothing() + { + // Arrange + var endpoints = new MatcherEndpoint[] { }; + var scores = new int[] { }; + var candidateSet = CreateCandidateSet(endpoints, scores); + + var (httpContext, feature) = CreateContext(); + var selector = CreateSelector(); + + // Act + await selector.SelectAsync(httpContext, feature, candidateSet); + + // Assert + Assert.Null(feature.Endpoint); + } + + [Fact] + public async Task SelectAsync_NoValidCandidates_DoesNothing() + { + // Arrange + var endpoints = new MatcherEndpoint[] { CreateEndpoint("/test"), }; + var scores = new int[] { 0, }; + var candidateSet = CreateCandidateSet(endpoints, scores); + + candidateSet[0].Values = new RouteValueDictionary(); + candidateSet[0].IsValidCandidate = false; + + var (httpContext, feature) = CreateContext(); + var selector = CreateSelector(); + + // Act + await selector.SelectAsync(httpContext, feature, candidateSet); + + // Assert + Assert.Null(feature.Endpoint); + Assert.Null(feature.Values); + } + + [Fact] + public async Task SelectAsync_SingleCandidate_ChoosesCandidate() + { + // Arrange + var endpoints = new MatcherEndpoint[] { CreateEndpoint("/test"), }; + var scores = new int[] { 0, }; + var candidateSet = CreateCandidateSet(endpoints, scores); + + candidateSet[0].Values = new RouteValueDictionary(); + candidateSet[0].IsValidCandidate = true; + + var (httpContext, feature) = CreateContext(); + var selector = CreateSelector(); + + // Act + await selector.SelectAsync(httpContext, feature, candidateSet); + + // Assert + Assert.Same(endpoints[0], feature.Endpoint); + Assert.Same(endpoints[0].Invoker, feature.Invoker); + Assert.NotNull(feature.Values); + } + + [Fact] + public async Task SelectAsync_SingleValidCandidate_ChoosesCandidate() + { + // Arrange + var endpoints = new MatcherEndpoint[] { CreateEndpoint("/test1"), CreateEndpoint("/test2"), }; + var scores = new int[] { 0, 0 }; + var candidateSet = CreateCandidateSet(endpoints, scores); + + candidateSet[0].IsValidCandidate = false; + candidateSet[1].IsValidCandidate = true; + + var (httpContext, feature) = CreateContext(); + var selector = CreateSelector(); + + // Act + await selector.SelectAsync(httpContext, feature, candidateSet); + + // Assert + Assert.Same(endpoints[1], feature.Endpoint); + } + + [Fact] + public async Task SelectAsync_SingleValidCandidateInGroup_ChoosesCandidate() + { + // Arrange + var endpoints = new MatcherEndpoint[] { CreateEndpoint("/test1"), CreateEndpoint("/test2"), CreateEndpoint("/test3"), }; + var scores = new int[] { 0, 0, 1 }; + var candidateSet = CreateCandidateSet(endpoints, scores); + + candidateSet[0].IsValidCandidate = false; + candidateSet[1].IsValidCandidate = true; + candidateSet[2].IsValidCandidate = true; + + var (httpContext, feature) = CreateContext(); + var selector = CreateSelector(); + + // Act + await selector.SelectAsync(httpContext, feature, candidateSet); + + // Assert + Assert.Same(endpoints[1], feature.Endpoint); + } + + [Fact] + public async Task SelectAsync_ManyGroupsLastCandidate_ChoosesCandidate() + { + // Arrange + var endpoints = new MatcherEndpoint[] + { + CreateEndpoint("/test1"), + CreateEndpoint("/test2"), + CreateEndpoint("/test3"), + CreateEndpoint("/test4"), + CreateEndpoint("/test5"), + }; + var scores = new int[] { 0, 1, 2, 3, 4 }; + var candidateSet = CreateCandidateSet(endpoints, scores); + + candidateSet[0].IsValidCandidate = false; + candidateSet[1].IsValidCandidate = false; + candidateSet[2].IsValidCandidate = false; + candidateSet[3].IsValidCandidate = false; + candidateSet[4].IsValidCandidate = true; + + var (httpContext, feature) = CreateContext(); + var selector = CreateSelector(); + + // Act + await selector.SelectAsync(httpContext, feature, candidateSet); + + // Assert + Assert.Same(endpoints[4], feature.Endpoint); + } + + [Fact] + public async Task SelectAsync_MultipleValidCandidatesInGroup_ReportsAmbiguity() + { + // Arrange + var endpoints = new MatcherEndpoint[] { CreateEndpoint("/test1"), CreateEndpoint("/test2"), CreateEndpoint("/test3"), }; + var scores = new int[] { 0, 1, 1 }; + var candidateSet = CreateCandidateSet(endpoints, scores); + + candidateSet[0].IsValidCandidate = false; + candidateSet[1].IsValidCandidate = true; + candidateSet[2].IsValidCandidate = true; + + var (httpContext, feature) = CreateContext(); + var selector = CreateSelector(); + + // Act + var ex = await Assert.ThrowsAsync(() => selector.SelectAsync(httpContext, feature, candidateSet)); + + // Assert + Assert.Equal( +@"The request matched multiple endpoints. Matches: + +test: /test2 +test: /test3", ex.Message); + Assert.Null(feature.Endpoint); + } + + private static (HttpContext httpContext, IEndpointFeature feature) CreateContext() + { + return (new DefaultHttpContext(), new EndpointFeature()); + } + + private static MatcherEndpoint CreateEndpoint(string template) + { + return new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse(template), + new RouteValueDictionary(), + 0, + EndpointMetadataCollection.Empty, + $"test: {template}"); + } + + private static CandidateSet CreateCandidateSet(MatcherEndpoint[] endpoints, int[] scores) + { + return new CandidateSet(endpoints, scores); + } + + private static DefaultEndpointSelector CreateSelector() + { + return new DefaultEndpointSelector(); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilderTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilderTest.cs index b8a064b59a..71318fd943 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilderTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilderTest.cs @@ -5,9 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Routing.Constraints; -using Microsoft.AspNetCore.Routing.EndpointConstraints; using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -28,7 +26,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var root = builder.BuildDfaTree(); // Assert - Assert.Same(endpoint, Assert.Single(root.Matches).Endpoint); + Assert.Same(endpoint, Assert.Single(root.Matches)); Assert.Null(root.Parameters); Assert.Empty(root.Literals); } @@ -67,7 +65,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers Assert.Equal("c", next.Key); var c = next.Value; - Assert.Same(endpoint, Assert.Single(c.Matches).Endpoint); + Assert.Same(endpoint, Assert.Single(c.Matches)); Assert.Null(c.Parameters); Assert.Empty(c.Literals); } @@ -97,7 +95,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers Assert.Empty(b.Literals); var c = b.Parameters; - Assert.Same(endpoint, Assert.Single(c.Matches).Endpoint); + Assert.Same(endpoint, Assert.Single(c.Matches)); Assert.Null(c.Parameters); Assert.Empty(c.Literals); } @@ -121,14 +119,14 @@ namespace Microsoft.AspNetCore.Routing.Matchers var a = root.Parameters; // The catch all can match a path like '/a' - Assert.Same(endpoint, Assert.Single(a.Matches).Endpoint); + Assert.Same(endpoint, Assert.Single(a.Matches)); Assert.Empty(a.Literals); Assert.Null(a.Parameters); // Catch-all nodes include an extra transition that loops to process // extra segments. var catchAll = a.CatchAll; - Assert.Same(endpoint, Assert.Single(catchAll.Matches).Endpoint); + Assert.Same(endpoint, Assert.Single(catchAll.Matches)); Assert.Empty(catchAll.Literals); Assert.Same(catchAll, catchAll.Parameters); Assert.Same(catchAll, catchAll.CatchAll); @@ -147,13 +145,13 @@ namespace Microsoft.AspNetCore.Routing.Matchers var root = builder.BuildDfaTree(); // Assert - Assert.Same(endpoint, Assert.Single(root.Matches).Endpoint); + Assert.Same(endpoint, Assert.Single(root.Matches)); Assert.Empty(root.Literals); // Catch-all nodes include an extra transition that loops to process // extra segments. var catchAll = root.CatchAll; - Assert.Same(endpoint, Assert.Single(catchAll.Matches).Endpoint); + Assert.Same(endpoint, Assert.Single(catchAll.Matches)); Assert.Empty(catchAll.Literals); Assert.Same(catchAll, catchAll.Parameters); } @@ -193,7 +191,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers Assert.Equal("c", next.Key); var c1 = next.Value; - Assert.Same(endpoint1, Assert.Single(c1.Matches).Endpoint); + Assert.Same(endpoint1, Assert.Single(c1.Matches)); Assert.Null(c1.Parameters); Assert.Empty(c1.Literals); @@ -205,7 +203,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers Assert.Equal("c", next.Key); var c2 = next.Value; - Assert.Same(endpoint2, Assert.Single(c2.Matches).Endpoint); + Assert.Same(endpoint2, Assert.Single(c2.Matches)); Assert.Null(c2.Parameters); Assert.Empty(c2.Literals); } @@ -248,8 +246,8 @@ namespace Microsoft.AspNetCore.Routing.Matchers var c1 = next.Value; Assert.Collection( c1.Matches, - e => Assert.Same(endpoint1, e.Endpoint), - e => Assert.Same(endpoint2, e.Endpoint)); + e => Assert.Same(endpoint1, e), + e => Assert.Same(endpoint2, e)); Assert.Null(c1.Parameters); Assert.Empty(c1.Literals); @@ -261,7 +259,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers Assert.Equal("c", next.Key); var c2 = next.Value; - Assert.Same(endpoint2, Assert.Single(c2.Matches).Endpoint); + Assert.Same(endpoint2, Assert.Single(c2.Matches)); Assert.Null(c2.Parameters); Assert.Empty(c2.Literals); } @@ -302,8 +300,8 @@ namespace Microsoft.AspNetCore.Routing.Matchers var c = next.Value; Assert.Collection( c.Matches, - e => Assert.Same(endpoint1, e.Endpoint), - e => Assert.Same(endpoint2, e.Endpoint)); + e => Assert.Same(endpoint1, e), + e => Assert.Same(endpoint2, e)); Assert.Null(c.Parameters); Assert.Empty(c.Literals); } @@ -331,13 +329,13 @@ namespace Microsoft.AspNetCore.Routing.Matchers Assert.Equal("a", next.Key); var a = next.Value; - Assert.Same(endpoint2, Assert.Single(a.Matches).Endpoint); + Assert.Same(endpoint2, Assert.Single(a.Matches)); next = Assert.Single(a.Literals); Assert.Equal("b", next.Key); var b1 = next.Value; - Assert.Same(endpoint2, Assert.Single(a.Matches).Endpoint); + Assert.Same(endpoint2, Assert.Single(a.Matches)); Assert.Null(b1.Parameters); next = Assert.Single(b1.Literals); @@ -346,13 +344,13 @@ namespace Microsoft.AspNetCore.Routing.Matchers var c1 = next.Value; Assert.Collection( c1.Matches, - e => Assert.Same(endpoint1, e.Endpoint), - e => Assert.Same(endpoint2, e.Endpoint)); + e => Assert.Same(endpoint1, e), + e => Assert.Same(endpoint2, e)); Assert.Null(c1.Parameters); Assert.Empty(c1.Literals); var catchAll = a.CatchAll; - Assert.Same(endpoint2, Assert.Single(catchAll.Matches).Endpoint); + Assert.Same(endpoint2, Assert.Single(catchAll.Matches)); Assert.Same(catchAll, catchAll.Parameters); Assert.Same(catchAll, catchAll.CatchAll); } @@ -380,11 +378,11 @@ namespace Microsoft.AspNetCore.Routing.Matchers Assert.Equal("a", next.Key); var a = next.Value; - Assert.Same(endpoint2, Assert.Single(a.Matches).Endpoint); + Assert.Same(endpoint2, Assert.Single(a.Matches)); Assert.Empty(a.Literals); var b1 = a.Parameters; - Assert.Same(endpoint2, Assert.Single(a.Matches).Endpoint); + Assert.Same(endpoint2, Assert.Single(a.Matches)); Assert.Null(b1.Parameters); next = Assert.Single(b1.Literals); @@ -393,17 +391,239 @@ namespace Microsoft.AspNetCore.Routing.Matchers var c1 = next.Value; Assert.Collection( c1.Matches, - e => Assert.Same(endpoint1, e.Endpoint), - e => Assert.Same(endpoint2, e.Endpoint)); + e => Assert.Same(endpoint1, e), + e => Assert.Same(endpoint2, e)); Assert.Null(c1.Parameters); Assert.Empty(c1.Literals); var catchAll = a.CatchAll; - Assert.Same(endpoint2, Assert.Single(catchAll.Matches).Endpoint); + Assert.Same(endpoint2, Assert.Single(catchAll.Matches)); Assert.Same(catchAll, catchAll.Parameters); Assert.Same(catchAll, catchAll.CatchAll); } + [Fact] + public void BuildDfaTree_WithPolicies() + { + // Arrange + var builder = CreateDfaMatcherBuilder(new TestMetadata1MatcherPolicy(), new TestMetadata2MatcherPolicy()); + + var endpoint1 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata1(0), new TestMetadata2(true), }); + builder.AddEndpoint(endpoint1); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Empty(a.Matches); + Assert.IsType(a.NodeBuilder); + Assert.Collection( + a.PolicyEdges.OrderBy(e => e.Key), + e => Assert.Equal(0, e.Key)); + + var test1_0 = a.PolicyEdges[0]; + Assert.Empty(a.Matches); + Assert.IsType(test1_0.NodeBuilder); + Assert.Collection( + test1_0.PolicyEdges.OrderBy(e => e.Key), + e => Assert.Equal(true, e.Key)); + + var test2_true = test1_0.PolicyEdges[true]; + Assert.Same(endpoint1, Assert.Single(test2_true.Matches)); + Assert.Null(test2_true.NodeBuilder); + Assert.Empty(test2_true.PolicyEdges); + } + + [Fact] + public void BuildDfaTree_WithPolicies_AndBranches() + { + // Arrange + var builder = CreateDfaMatcherBuilder(new TestMetadata1MatcherPolicy(), new TestMetadata2MatcherPolicy()); + + var endpoint1 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata1(0), new TestMetadata2(true), }); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata1(1), new TestMetadata2(true), }); + builder.AddEndpoint(endpoint2); + + var endpoint3 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata1(1), new TestMetadata2(false), }); + builder.AddEndpoint(endpoint3); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Empty(a.Matches); + Assert.IsType(a.NodeBuilder); + Assert.Collection( + a.PolicyEdges.OrderBy(e => e.Key), + e => Assert.Equal(0, e.Key), + e => Assert.Equal(1, e.Key)); + + var test1_0 = a.PolicyEdges[0]; + Assert.Empty(test1_0.Matches); + Assert.IsType(test1_0.NodeBuilder); + Assert.Collection( + test1_0.PolicyEdges.OrderBy(e => e.Key), + e => Assert.Equal(true, e.Key)); + + var test2_true = test1_0.PolicyEdges[true]; + Assert.Same(endpoint1, Assert.Single(test2_true.Matches)); + Assert.Null(test2_true.NodeBuilder); + Assert.Empty(test2_true.PolicyEdges); + + var test1_1 = a.PolicyEdges[1]; + Assert.Empty(test1_1.Matches); + Assert.IsType(test1_1.NodeBuilder); + Assert.Collection( + test1_1.PolicyEdges.OrderBy(e => e.Key), + e => Assert.Equal(false, e.Key), + e => Assert.Equal(true, e.Key)); + + test2_true = test1_1.PolicyEdges[true]; + Assert.Same(endpoint2, Assert.Single(test2_true.Matches)); + Assert.Null(test2_true.NodeBuilder); + Assert.Empty(test2_true.PolicyEdges); + + var test2_false = test1_1.PolicyEdges[false]; + Assert.Same(endpoint3, Assert.Single(test2_false.Matches)); + Assert.Null(test2_false.NodeBuilder); + Assert.Empty(test2_false.PolicyEdges); + } + + [Fact] + public void BuildDfaTree_WithPolicies_AndBranches_FirstPolicySkipped() + { + // Arrange + var builder = CreateDfaMatcherBuilder(new TestMetadata1MatcherPolicy(), new TestMetadata2MatcherPolicy()); + + var endpoint1 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata2(true), }); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata2(true), }); + builder.AddEndpoint(endpoint2); + + var endpoint3 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata2(false), }); + builder.AddEndpoint(endpoint3); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Empty(a.Matches); + Assert.IsType(a.NodeBuilder); + Assert.Collection( + a.PolicyEdges.OrderBy(e => e.Key), + e => Assert.Equal(false, e.Key), + e => Assert.Equal(true, e.Key)); + + var test2_true = a.PolicyEdges[true]; + Assert.Equal(new[] { endpoint1, endpoint2, }, test2_true.Matches); + Assert.Null(test2_true.NodeBuilder); + Assert.Empty(test2_true.PolicyEdges); + + var test2_false = a.PolicyEdges[false]; + Assert.Equal(new[] { endpoint3, }, test2_false.Matches); + Assert.Null(test2_false.NodeBuilder); + Assert.Empty(test2_false.PolicyEdges); + } + + [Fact] + public void BuildDfaTree_WithPolicies_AndBranches_SecondSkipped() + { + // Arrange + var builder = CreateDfaMatcherBuilder(new TestMetadata1MatcherPolicy(), new TestMetadata2MatcherPolicy()); + + var endpoint1 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata1(0), }); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata1(1), }); + builder.AddEndpoint(endpoint2); + + var endpoint3 = CreateEndpoint("/a", metadata: new object[] { new TestMetadata1(1), }); + builder.AddEndpoint(endpoint3); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Empty(a.Matches); + Assert.IsType(a.NodeBuilder); + Assert.Collection( + a.PolicyEdges.OrderBy(e => e.Key), + e => Assert.Equal(0, e.Key), + e => Assert.Equal(1, e.Key)); + + var test1_0 = a.PolicyEdges[0]; + Assert.Equal(new[] { endpoint1, }, test1_0.Matches); + Assert.Null(test1_0.NodeBuilder); + Assert.Empty(test1_0.PolicyEdges); + + var test1_1 = a.PolicyEdges[1]; + Assert.Equal(new[] { endpoint2, endpoint3, }, test1_1.Matches); + Assert.Null(test1_1.NodeBuilder); + Assert.Empty(test1_1.PolicyEdges); + } + + [Fact] + public void BuildDfaTree_WithPolicies_AndBranches_BothPoliciesSkipped() + { + // Arrange + var builder = CreateDfaMatcherBuilder(new TestMetadata1MatcherPolicy(), new TestMetadata2MatcherPolicy()); + + var endpoint1 = CreateEndpoint("/a", metadata: new object[] { }); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("/a", metadata: new object[] { }); + builder.AddEndpoint(endpoint2); + + var endpoint3 = CreateEndpoint("/a", metadata: new object[] { }); + builder.AddEndpoint(endpoint3); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Equal(new[] { endpoint1, endpoint2, endpoint3, }, a.Matches); + Assert.Null(a.NodeBuilder); + Assert.Empty(a.PolicyEdges); + } + [Fact] public void CreateCandidate_JustLiterals() { @@ -413,7 +633,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var builder = CreateDfaMatcherBuilder(); // Act - var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + var candidate = builder.CreateCandidate(endpoint, score: 0); // Assert Assert.Equal(Candidate.CandidateFlags.None, candidate.Flags); @@ -433,7 +653,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var builder = CreateDfaMatcherBuilder(); // Act - var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + var candidate = builder.CreateCandidate(endpoint, score: 0); // Assert Assert.Equal(Candidate.CandidateFlags.HasCaptures, candidate.Flags); @@ -457,7 +677,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var builder = CreateDfaMatcherBuilder(); // Act - var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + var candidate = builder.CreateCandidate(endpoint, score: 0); // Assert Assert.Equal( @@ -487,7 +707,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var builder = CreateDfaMatcherBuilder(); // Act - var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + var candidate = builder.CreateCandidate(endpoint, score: 0); // Assert Assert.Equal( @@ -519,7 +739,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var builder = CreateDfaMatcherBuilder(); // Act - var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + var candidate = builder.CreateCandidate(endpoint, score: 0); // Assert Assert.Equal( @@ -550,7 +770,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var builder = CreateDfaMatcherBuilder(); // Act - var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + var candidate = builder.CreateCandidate(endpoint, score: 0); // Assert Assert.Equal( @@ -581,7 +801,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var builder = CreateDfaMatcherBuilder(); // Act - var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + var candidate = builder.CreateCandidate(endpoint, score: 0); // Assert Assert.Equal( Candidate.CandidateFlags.HasMatchProcessors, candidate.Flags); @@ -591,30 +811,137 @@ namespace Microsoft.AspNetCore.Routing.Matchers Assert.Empty(candidate.ComplexSegments); Assert.Single(candidate.MatchProcessors); } + + [Fact] + public void CreateCandidates_CreatesScoresCorrectly() + { + // Arrange + var endpoints = new[] + { + CreateEndpoint("/a/b/c", constraints: new { a = new IntRouteConstraint(), }, metadata: new object[] { new TestMetadata1(), new TestMetadata2(), }), + CreateEndpoint("/a/b/c", constraints: new { a = new AlphaRouteConstraint(), }, metadata: new object[] { new TestMetadata1(), new TestMetadata2(), }), + CreateEndpoint("/a/b/c", constraints: new { a = new IntRouteConstraint(), }, metadata: new object[] { new TestMetadata1(), }), + CreateEndpoint("/a/b/c", constraints: new { a = new IntRouteConstraint(), }, metadata: new object[] { new TestMetadata2(), }), + CreateEndpoint("/a/b/c", constraints: new { }, metadata: new object[] { }), + CreateEndpoint("/a/b/c", constraints: new { }, metadata: new object[] { }), + }; - private static DfaMatcherBuilder CreateDfaMatcherBuilder() + var builder = CreateDfaMatcherBuilder(new TestMetadata1MatcherPolicy(), new TestMetadata2MatcherPolicy()); + + // Act + var candidates = builder.CreateCandidates(endpoints); + + // Assert + Assert.Collection( + candidates, + c => Assert.Equal(0, c.Score), + c => Assert.Equal(0, c.Score), + c => Assert.Equal(1, c.Score), + c => Assert.Equal(2, c.Score), + c => Assert.Equal(3, c.Score), + c => Assert.Equal(3, c.Score)); + } + + private static DfaMatcherBuilder CreateDfaMatcherBuilder(params MatcherPolicy[] policies) { var dataSource = new CompositeEndpointDataSource(Array.Empty()); return new DfaMatcherBuilder( Mock.Of(), - new EndpointSelector( - dataSource, - new EndpointConstraintCache(dataSource, Array.Empty()), - NullLoggerFactory.Instance)); + Mock.Of(), + policies); } private MatcherEndpoint CreateEndpoint( string template, object defaults = null, - object constraints = null) + object constraints = null, + params object[] metadata) { return new MatcherEndpoint( MatcherEndpoint.EmptyInvoker, RoutePatternFactory.Parse(template, new RouteValueDictionary(defaults), new RouteValueDictionary(constraints)), new RouteValueDictionary(), 0, - new EndpointMetadataCollection(Array.Empty()), + new EndpointMetadataCollection(metadata), "test"); } + + private class TestMetadata1 + { + public TestMetadata1() + { + } + + public TestMetadata1(int state) + { + State = state; + } + + public int State { get; set; } + } + + private class TestMetadata1MatcherPolicy : MatcherPolicy, IEndpointComparerPolicy, INodeBuilderPolicy + { + public override int Order => 100; + + public IComparer Comparer => EndpointMetadataComparer.Default; + + public bool AppliesToNode(IReadOnlyList endpoints) + { + return endpoints.Any(e => e.Metadata.GetMetadata() != null); + } + + public PolicyJumpTable BuildJumpTable(int exitDestination, IReadOnlyList edges) + { + throw new NotImplementedException(); + } + + public IReadOnlyList GetEdges(IReadOnlyList endpoints) + { + return endpoints + .GroupBy(e => e.Metadata.GetMetadata().State) + .Select(g => new PolicyNodeEdge(g.Key, g.ToArray())) + .ToArray(); + } + } + + private class TestMetadata2 + { + public TestMetadata2() + { + } + + public TestMetadata2(bool state) + { + State = state; + } + + public bool State { get; set; } + } + + private class TestMetadata2MatcherPolicy : MatcherPolicy, IEndpointComparerPolicy, INodeBuilderPolicy + { + public override int Order => 101; + + public IComparer Comparer => EndpointMetadataComparer.Default; + + public bool AppliesToNode(IReadOnlyList endpoints) + { + return endpoints.Any(e => e.Metadata.GetMetadata() != null); + } + + public PolicyJumpTable BuildJumpTable(int exitDestination, IReadOnlyList edges) + { + throw new NotImplementedException(); + } + + public IReadOnlyList GetEdges(IReadOnlyList endpoints) + { + return endpoints + .GroupBy(e => e.Metadata.GetMetadata().State) + .Select(g => new PolicyNodeEdge(g.Key, g.ToArray())) + .ToArray(); + } + } } } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherTest.cs index 821a0d0806..ee6d64cfc1 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherTest.cs @@ -1,16 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing.EndpointConstraints; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Moq; using Xunit; namespace Microsoft.AspNetCore.Routing.Matchers @@ -51,7 +47,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers CreateEndpoint("/{p:int}", 0) }); - var treeMatcher = CreateDfaMatcher(endpointDataSource); + var matcher = CreateDfaMatcher(endpointDataSource); var httpContext = new DefaultHttpContext(); httpContext.Request.Path = "/1"; @@ -59,7 +55,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var endpointFeature = new EndpointFeature(); // Act - await treeMatcher.MatchAsync(httpContext, endpointFeature); + await matcher.MatchAsync(httpContext, endpointFeature); // Assert Assert.NotNull(endpointFeature.Endpoint); @@ -74,7 +70,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers CreateEndpoint("/{p:int}", 0) }); - var treeMatcher = CreateDfaMatcher(endpointDataSource); + var matcher = CreateDfaMatcher(endpointDataSource); var httpContext = new DefaultHttpContext(); httpContext.Request.Path = "/One"; @@ -82,7 +78,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var endpointFeature = new EndpointFeature(); // Act - await treeMatcher.MatchAsync(httpContext, endpointFeature); + await matcher.MatchAsync(httpContext, endpointFeature); // Assert Assert.Null(endpointFeature.Endpoint); @@ -101,7 +97,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers lowerOrderEndpoint }); - var treeMatcher = CreateDfaMatcher(endpointDataSource); + var matcher = CreateDfaMatcher(endpointDataSource); var httpContext = new DefaultHttpContext(); httpContext.Request.Path = "/Teams"; @@ -109,7 +105,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var endpointFeature = new EndpointFeature(); // Act - await treeMatcher.MatchAsync(httpContext, endpointFeature); + await matcher.MatchAsync(httpContext, endpointFeature); // Assert Assert.Equal(lowerOrderEndpoint, endpointFeature.Endpoint); @@ -131,7 +127,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers endpointWithConstraint }); - var treeMatcher = CreateDfaMatcher(endpointDataSource); + var matcher = CreateDfaMatcher(endpointDataSource); var httpContext = new DefaultHttpContext(); httpContext.Request.Method = "POST"; @@ -140,7 +136,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers var endpointFeature = new EndpointFeature(); // Act - await treeMatcher.MatchAsync(httpContext, endpointFeature); + await matcher.MatchAsync(httpContext, endpointFeature); // Assert Assert.Equal(endpointWithConstraint, endpointFeature.Endpoint); diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/EndpointMetadataComparerTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/EndpointMetadataComparerTest.cs new file mode 100644 index 0000000000..4a6ca5babd --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/EndpointMetadataComparerTest.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Routing.TestObjects; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public class EndpointMetadataComparerTest + { + [Fact] + public void Compare_EndpointWithMetadata_MoreSpecific() + { + // Arrange + var endpoint1 = new TestEndpoint(new EndpointMetadataCollection(new object[] { new TestMetadata(), }), "test1"); + var endpoint2 = new TestEndpoint(new EndpointMetadataCollection(new object[] { }), "test2"); + + // Act + var result = EndpointMetadataComparer.Default.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(-1, result); + } + + [Fact] + public void Compare_EndpointWithMetadata_ReverseOrder_MoreSpecific() + { + // Arrange + var endpoint1 = new TestEndpoint(new EndpointMetadataCollection(new object[] { }), "test1"); + var endpoint2 = new TestEndpoint(new EndpointMetadataCollection(new object[] { new TestMetadata(), }), "test2"); + + // Act + var result = EndpointMetadataComparer.Default.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public void Compare_BothEndpointsWithMetadata_Equal() + { + // Arrange + var endpoint1 = new TestEndpoint(new EndpointMetadataCollection(new object[] { new TestMetadata(), }), "test1"); + var endpoint2 = new TestEndpoint(new EndpointMetadataCollection(new object[] { new TestMetadata(), }), "test2"); + + // Act + var result = EndpointMetadataComparer.Default.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void Compare_BothEndpointsWithoutMetadata_Equal() + { + // Arrange + var endpoint1 = new TestEndpoint(new EndpointMetadataCollection(new object[] { }), "test1"); + var endpoint2 = new TestEndpoint(new EndpointMetadataCollection(new object[] { }), "test2"); + + // Act + var result = EndpointMetadataComparer.Default.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void Sort_EndpointWithMetadata_FirstInList() + { + // Arrange + var endpoint1 = new TestEndpoint(new EndpointMetadataCollection(new object[] { new TestMetadata(), }), "test1"); + var endpoint2 = new TestEndpoint(new EndpointMetadataCollection(new object[] { }), "test2"); + + var list = new List() { endpoint2, endpoint1, }; + + // Act + list.Sort(EndpointMetadataComparer.Default); + + // Assert + Assert.Collection( + list, + e => Assert.Same(endpoint1, e), + e => Assert.Same(endpoint2, e)); + } + + private class TestMetadata + { + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/HttpMethodMatcherPolicyIntegrationTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/HttpMethodMatcherPolicyIntegrationTest.cs new file mode 100644 index 0000000000..26bf2173de --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/HttpMethodMatcherPolicyIntegrationTest.cs @@ -0,0 +1,232 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Metadata; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + // End-to-end tests for the HTTP method matching functionality + public class HttpMethodMatcherPolicyIntegrationTest + { + [Fact] + public async Task Match_HttpMethod() + { + // Arrange + var endpoint = CreateEndpoint("/hello", httpMethods: new string[] { "GET", }); + + var matcher = CreateMatcher(endpoint); + var (httpContext, feature) = CreateContext("/hello", "GET"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + MatcherAssert.AssertMatch(feature, endpoint); + } + + [Fact] + public async Task Match_HttpMethod_CaseInsensitive() + { + // Arrange + var endpoint = CreateEndpoint("/hello", httpMethods: new string[] { "GeT", }); + + var matcher = CreateMatcher(endpoint); + var (httpContext, feature) = CreateContext("/hello", "GET"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + MatcherAssert.AssertMatch(feature, endpoint); + } + + [Fact] + public async Task Match_NoMetadata_MatchesAnyHttpMethod() + { + // Arrange + var endpoint = CreateEndpoint("/hello"); + + var matcher = CreateMatcher(endpoint); + var (httpContext, feature) = CreateContext("/hello", "GET"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + MatcherAssert.AssertMatch(feature, endpoint); + } + + [Fact] + public async Task Match_EmptyMethodList_MatchesAnyHttpMethod() + { + // Arrange + var endpoint = CreateEndpoint("/hello", httpMethods: new string[] { }); + + var matcher = CreateMatcher(endpoint); + var (httpContext, feature) = CreateContext("/hello", "GET"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + MatcherAssert.AssertMatch(feature, endpoint); + } + + [Fact] // When all of the candidates handles specific verbs, use a 405 endpoint + public async Task NotMatch_HttpMethod_Returns405Endpoint() + { + // Arrange + var endpoint1 = CreateEndpoint("/hello", httpMethods: new string[] { "GET", "PUT" }); + var endpoint2 = CreateEndpoint("/hello", httpMethods: new string[] { "DELETE" }); + + var matcher = CreateMatcher(endpoint1, endpoint2); + var (httpContext, feature) = CreateContext("/hello", "POST"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + Assert.NotSame(endpoint1, feature.Endpoint); + Assert.NotSame(endpoint2, feature.Endpoint); + + Assert.Same(HttpMethodEndpointSelectorPolicy.Http405EndpointDisplayName, feature.Endpoint.DisplayName); + + // Invoke the endpoint + await feature.Invoker((c) => Task.CompletedTask)(httpContext); + Assert.Equal(405, httpContext.Response.StatusCode); + Assert.Equal("DELETE, GET, PUT", httpContext.Response.Headers["Allow"]); + } + + [Fact] // When one of the candidates handles all verbs, dont use a 405 endpoint + public async Task NotMatch_HttpMethod_WithAllMethodEndpoint_DoesNotReturn405() + { + // Arrange + var endpoint1 = CreateEndpoint("/{x:int}", httpMethods: new string[] { }); + var endpoint2 = CreateEndpoint("/hello", httpMethods: new string[] { "DELETE" }); + + var matcher = CreateMatcher(endpoint1, endpoint2); + var (httpContext, feature) = CreateContext("/hello", "POST"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + MatcherAssert.AssertNotMatch(feature); + } + + [Fact] + public async Task Match_EndpointWithHttpMethodPreferred() + { + // Arrange + var endpoint1 = CreateEndpoint("/hello", httpMethods: new string[] { "GET", }); + var endpoint2 = CreateEndpoint("/bar"); + + var matcher = CreateMatcher(endpoint1, endpoint2); + var (httpContext, feature) = CreateContext("/hello", "GET"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + MatcherAssert.AssertMatch(feature, endpoint1); + } + + [Fact] + public async Task Match_EndpointWithHttpMethodPreferred_EmptyList() + { + // Arrange + var endpoint1 = CreateEndpoint("/hello", httpMethods: new string[] { "GET", }); + var endpoint2 = CreateEndpoint("/bar", httpMethods: new string[] { }); + + var matcher = CreateMatcher(endpoint1, endpoint2); + var (httpContext, feature) = CreateContext("/hello", "GET"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + MatcherAssert.AssertMatch(feature, endpoint1); + } + + [Fact] // The non-http-method-specific endpoint is part of the same candidate set + public async Task Match_EndpointWithHttpMethodPreferred_FallsBackToNonSpecific() + { + // Arrange + var endpoint1 = CreateEndpoint("/{x}", httpMethods: new string[] { "GET", }); + var endpoint2 = CreateEndpoint("/{x}", httpMethods: new string[] { }); + + var matcher = CreateMatcher(endpoint1, endpoint2); + var (httpContext, feature) = CreateContext("/hello", "POST"); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + MatcherAssert.AssertMatch(feature, endpoint2, ignoreValues: true); + } + + private static Matcher CreateMatcher(params MatcherEndpoint[] endpoints) + { + var services = new ServiceCollection() + .AddOptions() + .AddLogging() + .AddRouting() + .BuildServiceProvider(); + + var builder = services.GetRequiredService(); + for (var i = 0; i < endpoints.Length; i++) + { + builder.AddEndpoint(endpoints[i]); + } + + return builder.Build(); + } + + internal static (HttpContext httpContext, IEndpointFeature feature) CreateContext(string path, string httpMethod) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = httpMethod; + httpContext.Request.Path = path; + + var feature = new EndpointFeature(); + httpContext.Features.Set(feature); + + return (httpContext, feature); + } + internal static MatcherEndpoint CreateEndpoint( + string template, + object defaults = null, + object constraints = null, + int order = 0, + string[] httpMethods = null) + { + var metadata = new List(); + if (httpMethods != null) + { + metadata.Add(new HttpMethodMetadata(httpMethods)); + } + + var displayName = "endpoint: " + template + " " + string.Join(", ", httpMethods ?? new[] { "(any)" }); + return new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse(template, defaults, constraints), + new RouteValueDictionary(), + order, + new EndpointMetadataCollection(metadata), + displayName); + } + + internal (Matcher matcher, MatcherEndpoint endpoint) CreateMatcher(string template) + { + var endpoint = CreateEndpoint(template); + return (CreateMatcher(endpoint), endpoint); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/HttpMethodMatcherPolicyTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/HttpMethodMatcherPolicyTest.cs new file mode 100644 index 0000000000..d4e3149852 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/HttpMethodMatcherPolicyTest.cs @@ -0,0 +1,171 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Routing.Metadata; +using Microsoft.AspNetCore.Routing.Patterns; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public class HttpMethodMatcherPolicyTest + { + [Fact] + public void AppliesToNode_EndpointWithoutMetadata_ReturnsFalse() + { + // Arrange + var endpoints = new[] { CreateEndpoint("/", null), }; + + var policy = CreatePolicy(); + + // Act + var result = policy.AppliesToNode(endpoints); + + // Assert + Assert.False(result); + } + + [Fact] + public void AppliesToNode_EndpointWithoutHttpMethods_ReturnsFalse() + { + // Arrange + var endpoints = new[] { CreateEndpoint("/", Array.Empty()), }; + + var policy = CreatePolicy(); + + // Act + var result = policy.AppliesToNode(endpoints); + + // Assert + Assert.False(result); + } + + [Fact] + public void AppliesToNode_EndpointHasHttpMethods_ReturnsTrue() + { + // Arrange + var endpoints = new[] { CreateEndpoint("/", Array.Empty()), CreateEndpoint("/", new[] { "GET", })}; + + var policy = CreatePolicy(); + + // Act + var result = policy.AppliesToNode(endpoints); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetEdges_GroupsByHttpMethod() + { + // Arrange + var endpoints = new[] + { + // These are arrange in an order that we won't actually see in a product scenario. It's done + // this way so we can verify that ordering is preserved by GetEdges. + CreateEndpoint("/", new[] { "GET", }), + CreateEndpoint("/", Array.Empty()), + CreateEndpoint("/", new[] { "GET", "PUT", "POST" }), + CreateEndpoint("/", new[] { "PUT", "POST" }), + CreateEndpoint("/", Array.Empty()), + }; + + var policy = CreatePolicy(); + + // Act + var edges = policy.GetEdges(endpoints); + + // Assert + Assert.Collection( + edges.OrderBy(e => e.State), + e => + { + Assert.Equal(HttpMethodEndpointSelectorPolicy.AnyMethod, e.State); + Assert.Equal(new[] { endpoints[1], endpoints[4], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("GET", e.State); + Assert.Equal(new[] { endpoints[0], endpoints[1], endpoints[2], endpoints[4], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("POST", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[2], endpoints[3], endpoints[4], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("PUT", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[2], endpoints[3], endpoints[4], }, e.Endpoints.ToArray()); + }); + } + + [Fact] // See explanation in GetEdges for how this case is different + public void GetEdges_GroupsByHttpMethod_CreatesHttp405Endpoint() + { + // Arrange + var endpoints = new[] + { + // These are arrange in an order that we won't actually see in a product scenario. It's done + // this way so we can verify that ordering is preserved by GetEdges. + CreateEndpoint("/", new[] { "GET", }), + CreateEndpoint("/", new[] { "GET", "PUT", "POST" }), + CreateEndpoint("/", new[] { "PUT", "POST" }), + }; + + var policy = CreatePolicy(); + + // Act + var edges = policy.GetEdges(endpoints); + + // Assert + Assert.Collection( + edges.OrderBy(e => e.State), + e => + { + Assert.Equal(HttpMethodEndpointSelectorPolicy.AnyMethod, e.State); + Assert.Equal(HttpMethodEndpointSelectorPolicy.Http405EndpointDisplayName, e.Endpoints.Single().DisplayName); + }, + e => + { + Assert.Equal("GET", e.State); + Assert.Equal(new[] { endpoints[0], endpoints[1], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("POST", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[2], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("PUT", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[2], }, e.Endpoints.ToArray()); + }); + } + + private static MatcherEndpoint CreateEndpoint(string template, string[] httpMethods) + { + var metadata = new List(); + if (httpMethods != null) + { + metadata.Add(new HttpMethodMetadata(httpMethods)); + } + + return new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse(template), + new RouteValueDictionary(), + 0, + new EndpointMetadataCollection(metadata), + $"test: {template}"); + } + + private static HttpMethodEndpointSelectorPolicy CreatePolicy() + { + return new HttpMethodEndpointSelectorPolicy(); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/MatcherEndpointComparerTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/MatcherEndpointComparerTest.cs new file mode 100644 index 0000000000..383a2153e3 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/MatcherEndpointComparerTest.cs @@ -0,0 +1,254 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Routing.Patterns; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public class MatcherEndpointComparerTest + { + [Fact] + public void Compare_PrefersOrder_IfDifferent() + { + // Arrange + var endpoint1 = CreateEndpoint("/", order: 1); + var endpoint2 = CreateEndpoint("/api/foo", order: -1); + + var comparer = CreateComparer(); + + // Act + var result = comparer.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public void Compare_PrefersPrecedence_IfOrderIsSame() + { + // Arrange + var endpoint1 = CreateEndpoint("/api/foo", order: 1); + var endpoint2 = CreateEndpoint("/", order: 1); + + var comparer = CreateComparer(); + + // Act + var result = comparer.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public void Compare_PrefersPolicy_IfPrecedenceIsSame() + { + // Arrange + var endpoint1 = CreateEndpoint("/", order: 1, new TestMetadata1()); + var endpoint2 = CreateEndpoint("/", order: 1); + + var comparer = CreateComparer(new TestMetadata1Policy()); + + // Act + var result = comparer.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(-1, result); + } + + [Fact] + public void Compare_PrefersSecondPolicy_IfFirstPolicyIsSame() + { + // Arrange + var endpoint1 = CreateEndpoint("/", order: 1, new TestMetadata1()); + var endpoint2 = CreateEndpoint("/", order: 1, new TestMetadata1(), new TestMetadata2()); + + var comparer = CreateComparer(new TestMetadata1Policy(), new TestMetadata2Policy()); + + // Act + var result = comparer.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public void Compare_PrefersTemplate_IfOtherCriteriaIsSame() + { + // Arrange + var endpoint1 = CreateEndpoint("/foo", order: 1, new TestMetadata1()); + var endpoint2 = CreateEndpoint("/bar", order: 1, new TestMetadata1()); + + var comparer = CreateComparer(new TestMetadata1Policy(), new TestMetadata2Policy()); + + // Act + var result = comparer.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public void Compare_ReturnsZero_WhenIdentical() + { + // Arrange + var endpoint1 = CreateEndpoint("/foo", order: 1, new TestMetadata1()); + var endpoint2 = CreateEndpoint("/foo", order: 1, new TestMetadata1()); + + var comparer = CreateComparer(new TestMetadata1Policy(), new TestMetadata2Policy()); + + // Act + var result = comparer.Compare(endpoint1, endpoint2); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void Equals_NotEqual_IfOrderDifferent() + { + // Arrange + var endpoint1 = CreateEndpoint("/", order: 1); + var endpoint2 = CreateEndpoint("/api/foo", order: -1); + + var comparer = CreateComparer(); + + // Act + var result = comparer.Equals(endpoint1, endpoint2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_NotEqual_IfPrecedenceDifferent() + { + // Arrange + var endpoint1 = CreateEndpoint("/api/foo", order: 1); + var endpoint2 = CreateEndpoint("/", order: 1); + + var comparer = CreateComparer(); + + // Act + var result = comparer.Equals(endpoint1, endpoint2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_NotEqual_IfFirstPolicyDifferent() + { + // Arrange + var endpoint1 = CreateEndpoint("/", order: 1, new TestMetadata1()); + var endpoint2 = CreateEndpoint("/", order: 1); + + var comparer = CreateComparer(new TestMetadata1Policy()); + + // Act + var result = comparer.Equals(endpoint1, endpoint2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_NotEqual_IfSecondPolicyDifferent() + { + // Arrange + var endpoint1 = CreateEndpoint("/", order: 1, new TestMetadata1()); + var endpoint2 = CreateEndpoint("/", order: 1, new TestMetadata1(), new TestMetadata2()); + + var comparer = CreateComparer(new TestMetadata1Policy(), new TestMetadata2Policy()); + + // Act + var result = comparer.Equals(endpoint1, endpoint2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_Equals_WhenTemplateIsDifferent() + { + // Arrange + var endpoint1 = CreateEndpoint("/foo", order: 1, new TestMetadata1()); + var endpoint2 = CreateEndpoint("/bar", order: 1, new TestMetadata1()); + + var comparer = CreateComparer(new TestMetadata1Policy(), new TestMetadata2Policy()); + + // Act + var result = comparer.Equals(endpoint1, endpoint2); + + // Assert + Assert.True(result); + } + + [Fact] + public void Sort_MoreSpecific_FirstInList() + { + // Arrange + var endpoint1 = CreateEndpoint("/foo", order: -1); + var endpoint2 = CreateEndpoint("/bar/{baz}", order: -1); + var endpoint3 = CreateEndpoint("/bar", order: 0, new TestMetadata1()); + var endpoint4 = CreateEndpoint("/foo", order: 0, new TestMetadata2()); + var endpoint5 = CreateEndpoint("/foo", order: 0); + var endpoint6 = CreateEndpoint("/a{baz}", order: 0, new TestMetadata1(), new TestMetadata2()); + var endpoint7 = CreateEndpoint("/bar{baz}", order: 0, new TestMetadata1(), new TestMetadata2()); + + // Endpoints listed in reverse of the desired order. + var list = new List() { endpoint7, endpoint6, endpoint5, endpoint4, endpoint3, endpoint2, endpoint1, }; + + var comparer = CreateComparer(new TestMetadata1Policy(), new TestMetadata2Policy()); + + // Act + list.Sort(comparer); + + // Assert + Assert.Collection( + list, + e => Assert.Same(endpoint1, e), + e => Assert.Same(endpoint2, e), + e => Assert.Same(endpoint3, e), + e => Assert.Same(endpoint4, e), + e => Assert.Same(endpoint5, e), + e => Assert.Same(endpoint6, e), + e => Assert.Same(endpoint7, e)); + } + + private static MatcherEndpoint CreateEndpoint(string template, int order, params object[] metadata) + { + return new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse(template), + new RouteValueDictionary(), + order, + new EndpointMetadataCollection(metadata), + "test: " + template); + } + + private static MatcherEndpointComparer CreateComparer(params IEndpointComparerPolicy[] policies) + { + return new MatcherEndpointComparer(policies); + } + + private class TestMetadata1 + { + } + + private class TestMetadata1Policy : IEndpointComparerPolicy + { + public IComparer Comparer => EndpointMetadataComparer.Default; + } + + private class TestMetadata2 + { + } + + private class TestMetadata2Policy : IEndpointComparerPolicy + { + public IComparer Comparer => EndpointMetadataComparer.Default; + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/RouteMatcherBuilder.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/RouteMatcherBuilder.cs index 3748454b92..a57eea4387 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/RouteMatcherBuilder.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/RouteMatcherBuilder.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Routing.EndpointConstraints; using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.AspNetCore.Routing.Template; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -16,39 +15,37 @@ namespace Microsoft.AspNetCore.Routing.Matchers internal class RouteMatcherBuilder : MatcherBuilder { private readonly IInlineConstraintResolver _constraintResolver; - private readonly List _entries; + private readonly List _endpoints; public RouteMatcherBuilder() { _constraintResolver = new DefaultInlineConstraintResolver(Options.Create(new RouteOptions())); - _entries = new List(); + _endpoints = new List(); } public override void AddEndpoint(MatcherEndpoint endpoint) { - _entries.Add(new MatcherBuilderEntry(endpoint)); + _endpoints.Add(endpoint); } public override Matcher Build() { - _entries.Sort(); - var cache = new EndpointConstraintCache( new CompositeEndpointDataSource(Array.Empty()), new[] { new DefaultEndpointConstraintProvider(), }); - var selector = new EndpointSelector(null, cache, NullLoggerFactory.Instance); + var selector = new EndpointConstraintEndpointSelector(null, cache, NullLoggerFactory.Instance); - var groups = _entries - .GroupBy(e => (e.Order, e.Precedence, e.Endpoint.RoutePattern.RawText)) + var groups = _endpoints + .GroupBy(e => (e.Order, e.RoutePattern.InboundPrecedence, e.RoutePattern.RawText)) .OrderBy(g => g.Key.Order) - .ThenBy(g => g.Key.Precedence); + .ThenBy(g => g.Key.InboundPrecedence); var routes = new RouteCollection(); foreach (var group in groups) { - var candidates = group.Select(e => e.Endpoint).ToArray(); - var endpoint = group.First().Endpoint; + var candidates = group.ToArray(); + var endpoint = group.First(); // RoutePattern.Defaults contains the default values parsed from the template // as well as those specified with a literal. We need to separate those @@ -81,12 +78,15 @@ namespace Microsoft.AspNetCore.Routing.Matchers private class SelectorRouter : IRouter { private readonly EndpointSelector _selector; - private readonly Endpoint[] _candidates; + private readonly MatcherEndpoint[] _candidates; + private readonly int[] _scores; - public SelectorRouter(EndpointSelector selector, Endpoint[] candidates) + public SelectorRouter(EndpointSelector selector, MatcherEndpoint[] candidates) { _selector = selector; _candidates = candidates; + + _scores = new int[_candidates.Length]; } public VirtualPathData GetVirtualPath(VirtualPathContext context) @@ -94,15 +94,19 @@ namespace Microsoft.AspNetCore.Routing.Matchers throw new NotImplementedException(); } - public Task RouteAsync(RouteContext context) + public async Task RouteAsync(RouteContext context) { - var endpoint = _selector.SelectBestCandidate(context.HttpContext, _candidates); - if (endpoint != null) + var feature = context.HttpContext.Features.Get(); + + // This is needed due to a quirk of our tests - they reuse the endpoint feature + // across requests. + feature.Endpoint = null; + + await _selector.SelectAsync(context.HttpContext, feature, new CandidateSet(_candidates, _scores)); + if (feature.Endpoint != null) { - context.HttpContext.Features.Get().Endpoint = endpoint; context.Handler = (_) => Task.CompletedTask; } - return Task.CompletedTask; } } } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/TreeRouterMatcherBuilder.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/TreeRouterMatcherBuilder.cs index 3ed3162666..949b0a7df2 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/TreeRouterMatcherBuilder.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/TreeRouterMatcherBuilder.cs @@ -17,22 +17,20 @@ namespace Microsoft.AspNetCore.Routing.Matchers { internal class TreeRouterMatcherBuilder : MatcherBuilder { - private readonly List _entries; + private readonly List _endpoints; public TreeRouterMatcherBuilder() { - _entries = new List(); + _endpoints = new List(); } public override void AddEndpoint(MatcherEndpoint endpoint) { - _entries.Add(new MatcherBuilderEntry(endpoint)); + _endpoints.Add(endpoint); } public override Matcher Build() { - _entries.Sort(); - var builder = new TreeRouteBuilder( NullLoggerFactory.Instance, new DefaultObjectPool(new UriBuilderContextPooledObjectPolicy()), @@ -41,23 +39,23 @@ namespace Microsoft.AspNetCore.Routing.Matchers var cache = new EndpointConstraintCache( new CompositeEndpointDataSource(Array.Empty()), new[] { new DefaultEndpointConstraintProvider(), }); - var selector = new EndpointSelector(null, cache, NullLoggerFactory.Instance); + var selector = new EndpointConstraintEndpointSelector(null, cache, NullLoggerFactory.Instance); - var groups = _entries - .GroupBy(e => (e.Order, e.Precedence, e.Endpoint.RoutePattern.RawText)) + var groups = _endpoints + .GroupBy(e => (e.Order, e.RoutePattern.InboundPrecedence, e.RoutePattern.RawText)) .OrderBy(g => g.Key.Order) - .ThenBy(g => g.Key.Precedence); + .ThenBy(g => g.Key.InboundPrecedence); var routes = new RouteCollection(); foreach (var group in groups) { - var candidates = group.Select(e => e.Endpoint).ToArray(); + var candidates = group.ToArray(); // MatcherEndpoint.Values contains the default values parsed from the template // as well as those specified with a literal. We need to separate those // for legacy cases. - var endpoint = group.First().Endpoint; + var endpoint = group.First(); var defaults = new RouteValueDictionary(endpoint.RoutePattern.Defaults); for (var i = 0; i < endpoint.RoutePattern.Parameters.Count; i++) { @@ -81,12 +79,15 @@ namespace Microsoft.AspNetCore.Routing.Matchers private class SelectorRouter : IRouter { private readonly EndpointSelector _selector; - private readonly Endpoint[] _candidates; + private readonly MatcherEndpoint[] _candidates; + private readonly int[] _scores; - public SelectorRouter(EndpointSelector selector, Endpoint[] candidates) + public SelectorRouter(EndpointSelector selector, MatcherEndpoint[] candidates) { _selector = selector; _candidates = candidates; + + _scores = new int[_candidates.Length]; } public VirtualPathData GetVirtualPath(VirtualPathContext context) @@ -94,15 +95,18 @@ namespace Microsoft.AspNetCore.Routing.Matchers throw new NotImplementedException(); } - public Task RouteAsync(RouteContext context) + public async Task RouteAsync(RouteContext context) { - var endpoint = _selector.SelectBestCandidate(context.HttpContext, _candidates); - if (endpoint != null) + var feature = context.HttpContext.Features.Get(); + + // This is needed due to a quirk of our tests - they reuse the endpoint feature. + feature.Endpoint = null; + + await _selector.SelectAsync(context.HttpContext, feature, new CandidateSet(_candidates, _scores)); + if (feature.Endpoint != null) { - context.HttpContext.Features.Get().Endpoint = endpoint; context.Handler = (_) => Task.CompletedTask; } - return Task.CompletedTask; } } } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/RouteValueBasedEndpointFinderTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/RouteValueBasedEndpointFinderTest.cs index 6296604d2a..fab9c41600 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/RouteValueBasedEndpointFinderTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/RouteValueBasedEndpointFinderTest.cs @@ -224,6 +224,21 @@ namespace Microsoft.AspNetCore.Routing Assert.Same(expected, actual); } + [Fact] + public void GetOutboundMatches_DoesNotInclude_EndpointsWithSuppressLinkGenerationMetadata() + { + // Arrange + var endpoint = CreateEndpoint( + "/a", + metadataCollection: new EndpointMetadataCollection(new[] { new SuppressLinkGenerationMetadata() })); + + // Act + var finder = CreateEndpointFinder(endpoint); + + // Assert + Assert.Empty(finder.AllMatches); + } + private CustomRouteValuesBasedEndpointFinder CreateEndpointFinder(params Endpoint[] endpoints) { return CreateEndpointFinder(new DefaultEndpointDataSource(endpoints)); @@ -304,5 +319,7 @@ namespace Microsoft.AspNetCore.Routing return matches; } } + + private class SuppressLinkGenerationMetadata : ISuppressLinkGenerationMetadata { } } } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/TestObjects/DynamicEndpointDataSource.cs b/test/Microsoft.AspNetCore.Routing.Tests/TestObjects/DynamicEndpointDataSource.cs index d434b9c924..79e71babdb 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/TestObjects/DynamicEndpointDataSource.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/TestObjects/DynamicEndpointDataSource.cs @@ -23,7 +23,9 @@ namespace Microsoft.AspNetCore.Routing.TestObjects CreateChangeToken(); } - public override IChangeToken ChangeToken => _changeToken; + public override IChangeToken GetChangeToken() => _changeToken; + + public override IChangeToken ChangeToken => GetChangeToken(); public override IReadOnlyList Endpoints => _endpoints; diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouterTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouterTest.cs index 6a1fb47e0d..a99846126c 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouterTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouterTest.cs @@ -1399,7 +1399,6 @@ namespace Microsoft.AspNetCore.Routing.Tree Assert.Empty(pathData.DataTokens); } - [Fact] public void TreeRouter_GenerateLink_Match_WithParameters() { @@ -1965,6 +1964,94 @@ namespace Microsoft.AspNetCore.Routing.Tree Assert.DoesNotContain(nestedValues, kvp => kvp.Key == "category1"); } + [Fact] + public void TreeRouter_GenerateLink_MatchesNullRequiredValue_WithNullRequestValueString() + { + // Arrange + var builder = CreateBuilder(); + var entry = MapOutboundEntry( + builder, + "Help/Store", + requiredValues: new { area = (string)null, action = "Edit", controller = "Store" }); + var route = builder.Build(); + var context = CreateVirtualPathContext(new { area = (string)null, action = "Edit", controller = "Store" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal("/Help/Store", pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [Fact] + public void TreeRouter_GenerateLink_MatchesNullRequiredValue_WithEmptyRequestValueString() + { + // Arrange + var builder = CreateBuilder(); + var entry = MapOutboundEntry( + builder, + "Help/Store", + requiredValues: new { area = (string)null, action = "Edit", controller = "Store" }); + var route = builder.Build(); + var context = CreateVirtualPathContext(new { area = "", action = "Edit", controller = "Store" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal("/Help/Store", pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [Fact] + public void TreeRouter_GenerateLink_MatchesEmptyStringRequiredValue_WithNullRequestValueString() + { + // Arrange + var builder = CreateBuilder(); + var entry = MapOutboundEntry( + builder, + "Help/Store", + requiredValues: new { foo = "", action = "Edit", controller = "Store" }); + var route = builder.Build(); + var context = CreateVirtualPathContext(new { foo = (string)null, action = "Edit", controller = "Store" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal("/Help/Store", pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [Fact] + public void TreeRouter_GenerateLink_MatchesEmptyStringRequiredValue_WithEmptyRequestValueString() + { + // Arrange + var builder = CreateBuilder(); + var entry = MapOutboundEntry( + builder, + "Help/Store", + requiredValues: new { foo = "", action = "Edit", controller = "Store" }); + var route = builder.Build(); + var context = CreateVirtualPathContext(new { foo = "", action = "Edit", controller = "Store" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal("/Help/Store", pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + private static RouteContext CreateRouteContext(string requestPath) { var request = new Mock(MockBehavior.Strict);