From 6bb4a3f370416d566c8ba0ed14523727198e0feb Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 20 Nov 2020 20:43:11 +0100 Subject: [PATCH] [Blazor] Fixes issues with route precedence (#27907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Description In 5.0 we introduced two features on Blazor routing that enable users to write routing templates that match paths with variable length segments. These two features are optional parameters {parameter?} and catch all parameters {*catchall}. Our routing system ordered the routes based on precedence and the (now false) assumption that route templates would only match paths with an equal number of segments. The implementation that we have worked for naïve scenarios but breaks on more real world scenarios. The change here includes fixes to the way we order the routes in the route table to match the expectations as well as fixes on the route matching algorithm to ensure we match routes with variable number of segments correctly. Customer Impact This was reported by customers on #27250 The impact is that a route with {*catchall} will prevent more specific routes like /page/{parameter} from being accessible. There are no workarounds since precedence is a fundamental behavior of the routing system. Regression? No, these Blazor features were initially added in 5.0. Risk Low. These two features were just introduced in 5.0 and their usage is not as prevalent as in asp.net core routing. That said, it's important to fix them as otherwise we run the risk of diverting in behavior from asp.net core routing and Blazor routing, which is not something we want to do. We have functional tests covering the area and we've added a significant amount of unit tests to validate the changes. --- .../Components/src/PublicAPI.Unshipped.txt | 2 + .../Components/src/Routing/IRouteTable.cs | 15 + .../LegacyOptionalTypeRouteConstraint.cs} | 10 +- .../LegacyRouteConstraint.cs | 113 +++ .../LegacyRouteMatching/LegacyRouteEntry.cs | 146 ++++ .../LegacyRouteMatching/LegacyRouteTable.cs | 31 + .../LegacyRouteTableFactory.cs | 236 ++++++ .../LegacyRouteTemplate.cs | 29 + .../LegacyTemplateParser.cs | 115 +++ .../LegacyTemplateSegment.cs | 123 +++ .../LegacyTypeRouteConstraint.cs | 37 + .../Components/src/Routing/RouteConstraint.cs | 25 - .../Components/src/Routing/RouteEntry.cs | 190 ++--- .../Components/src/Routing/RouteTable.cs | 4 +- .../src/Routing/RouteTableFactory.cs | 96 +-- .../Components/src/Routing/Router.cs | 20 +- .../Components/src/Routing/TemplateParser.cs | 7 +- .../Components/src/Routing/TemplateSegment.cs | 96 ++- .../src/Routing/TypeRouteConstraint.cs | 14 + .../LegacyRouteConstraintTest.cs | 46 ++ .../LegacyRouteTableFactoryTests.cs | 740 ++++++++++++++++++ .../LegacyTemplateParserTests.cs | 295 +++++++ .../test/Routing/RouteConstraintTest.cs | 10 - .../test/Routing/RouteTableFactoryTests.cs | 399 ++++++++-- .../Components/test/Routing/RouterTest.cs | 68 +- .../test/Routing/TemplateParserTests.cs | 2 +- .../Samples/BlazorServerApp/App.razor | 2 +- .../testassets/blazorwasm-minimal/App.razor | 2 +- .../Sdk/testassets/blazorwasm/App.razor | 2 +- .../testassets/StandaloneApp/App.razor | 2 +- .../Wasm.Authentication.Client/App.razor | 2 +- .../BlazingPizza.Server/App.razor | 2 +- .../Wasm.Performance/TestApp/App.razor | 2 +- .../BasicTestApp/AuthTest/AuthRouter.razor | 2 +- .../BasicTestApp/RouterTest/TestRouter.razor | 2 +- .../TestRouterWithAdditionalAssembly.razor | 2 +- .../TestRouterWithLazyAssembly.razor | 2 +- .../RouterTest/TestRouterWithOnNavigate.razor | 2 +- .../testassets/ComponentsApp.App/App.razor | 2 +- .../content/BlazorServerWeb-CSharp/App.razor | 4 +- .../Client/App.razor | 4 +- 41 files changed, 2616 insertions(+), 287 deletions(-) create mode 100644 src/Components/Components/src/Routing/IRouteTable.cs rename src/Components/Components/src/Routing/{OptionalTypeRouteConstraint.cs => LegacyRouteMatching/LegacyOptionalTypeRouteConstraint.cs} (76%) create mode 100644 src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteConstraint.cs create mode 100644 src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteEntry.cs create mode 100644 src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTable.cs create mode 100644 src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTableFactory.cs create mode 100644 src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTemplate.cs create mode 100644 src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateParser.cs create mode 100644 src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateSegment.cs create mode 100644 src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTypeRouteConstraint.cs create mode 100644 src/Components/Components/test/LegacyRouteMatching/LegacyRouteConstraintTest.cs create mode 100644 src/Components/Components/test/LegacyRouteMatching/LegacyRouteTableFactoryTests.cs create mode 100644 src/Components/Components/test/LegacyRouteMatching/LegacyTemplateParserTests.cs diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 7dc5c58110..79ec685f40 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool +Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void diff --git a/src/Components/Components/src/Routing/IRouteTable.cs b/src/Components/Components/src/Routing/IRouteTable.cs new file mode 100644 index 0000000000..15f5e51ddd --- /dev/null +++ b/src/Components/Components/src/Routing/IRouteTable.cs @@ -0,0 +1,15 @@ +// 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.Components.Routing +{ + /// + /// Provides an abstraction over and . + /// This is only an internal implementation detail of and can be removed once + /// the legacy route matching logic is removed. + /// + internal interface IRouteTable + { + void Route(RouteContext routeContext); + } +} diff --git a/src/Components/Components/src/Routing/OptionalTypeRouteConstraint.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyOptionalTypeRouteConstraint.cs similarity index 76% rename from src/Components/Components/src/Routing/OptionalTypeRouteConstraint.cs rename to src/Components/Components/src/Routing/LegacyRouteMatching/LegacyOptionalTypeRouteConstraint.cs index dcd0238ea4..74cde946ab 100644 --- a/src/Components/Components/src/Routing/OptionalTypeRouteConstraint.cs +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyOptionalTypeRouteConstraint.cs @@ -1,20 +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. -namespace Microsoft.AspNetCore.Components.Routing +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching { /// /// A route constraint that allows the value to be null or parseable as the specified /// type. /// /// The type to which the value must be parseable. - internal class OptionalTypeRouteConstraint : RouteConstraint + internal class LegacyOptionalTypeRouteConstraint : LegacyRouteConstraint { - public delegate bool TryParseDelegate(string str, out T result); + public delegate bool LegacyTryParseDelegate(string str, out T result); - private readonly TryParseDelegate _parser; + private readonly LegacyTryParseDelegate _parser; - public OptionalTypeRouteConstraint(TryParseDelegate parser) + public LegacyOptionalTypeRouteConstraint(LegacyTryParseDelegate parser) { _parser = parser; } diff --git a/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteConstraint.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteConstraint.cs new file mode 100644 index 0000000000..caad4b9068 --- /dev/null +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteConstraint.cs @@ -0,0 +1,113 @@ +// 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.Concurrent; +using System.Globalization; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + internal abstract class LegacyRouteConstraint + { + // note: the things that prevent this cache from growing unbounded is that + // we're the only caller to this code path, and the fact that there are only + // 8 possible instances that we create. + // + // The values passed in here for parsing are always static text defined in route attributes. + private static readonly ConcurrentDictionary _cachedConstraints + = new ConcurrentDictionary(); + + public abstract bool Match(string pathSegment, out object? convertedValue); + + public static LegacyRouteConstraint Parse(string template, string segment, string constraint) + { + if (string.IsNullOrEmpty(constraint)) + { + throw new ArgumentException($"Malformed segment '{segment}' in route '{template}' contains an empty constraint."); + } + + if (_cachedConstraints.TryGetValue(constraint, out var cachedInstance)) + { + return cachedInstance; + } + else + { + var newInstance = CreateRouteConstraint(constraint); + if (newInstance != null) + { + // We've done to the work to create the constraint now, but it's possible + // we're competing with another thread. GetOrAdd can ensure only a single + // instance is returned so that any extra ones can be GC'ed. + return _cachedConstraints.GetOrAdd(constraint, newInstance); + } + else + { + throw new ArgumentException($"Unsupported constraint '{constraint}' in route '{template}'."); + } + } + } + + /// + /// Creates a structured RouteConstraint object given a string that contains + /// the route constraint. A constraint is the place after the colon in a + /// parameter definition, for example `{age:int?}`. + /// + /// If the constraint denotes an optional, this method will return an + /// which handles the appropriate checks. + /// + /// String representation of the constraint + /// Type-specific RouteConstraint object + private static LegacyRouteConstraint? CreateRouteConstraint(string constraint) + { + switch (constraint) + { + case "bool": + return new LegacyTypeRouteConstraint(bool.TryParse); + case "bool?": + return new LegacyOptionalTypeRouteConstraint(bool.TryParse); + case "datetime": + return new LegacyTypeRouteConstraint((string str, out DateTime result) + => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result)); + case "datetime?": + return new LegacyOptionalTypeRouteConstraint((string str, out DateTime result) + => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result)); + case "decimal": + return new LegacyTypeRouteConstraint((string str, out decimal result) + => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); + case "decimal?": + return new LegacyOptionalTypeRouteConstraint((string str, out decimal result) + => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); + case "double": + return new LegacyTypeRouteConstraint((string str, out double result) + => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); + case "double?": + return new LegacyOptionalTypeRouteConstraint((string str, out double result) + => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); + case "float": + return new LegacyTypeRouteConstraint((string str, out float result) + => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); + case "float?": + return new LegacyOptionalTypeRouteConstraint((string str, out float result) + => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); + case "guid": + return new LegacyTypeRouteConstraint(Guid.TryParse); + case "guid?": + return new LegacyOptionalTypeRouteConstraint(Guid.TryParse); + case "int": + return new LegacyTypeRouteConstraint((string str, out int result) + => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)); + case "int?": + return new LegacyOptionalTypeRouteConstraint((string str, out int result) + => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)); + case "long": + return new LegacyTypeRouteConstraint((string str, out long result) + => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)); + case "long?": + return new LegacyOptionalTypeRouteConstraint((string str, out long result) + => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)); + default: + return null; + } + } + } +} diff --git a/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteEntry.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteEntry.cs new file mode 100644 index 0000000000..9aefb6876a --- /dev/null +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteEntry.cs @@ -0,0 +1,146 @@ +// 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. + +#nullable disable warnings + +using System; +using System.Collections.Generic; +using System.Diagnostics; + +// Avoid referencing the whole Microsoft.AspNetCore.Components.Routing namespace to +// avoid the risk of accidentally relying on the non-legacy types in the legacy fork +using RouteContext = Microsoft.AspNetCore.Components.Routing.RouteContext; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + [DebuggerDisplay("Handler = {Handler}, Template = {Template}")] + internal class LegacyRouteEntry + { + public LegacyRouteEntry(LegacyRouteTemplate template, Type handler, string[] unusedRouteParameterNames) + { + Template = template; + UnusedRouteParameterNames = unusedRouteParameterNames; + Handler = handler; + } + + public LegacyRouteTemplate Template { get; } + + public string[] UnusedRouteParameterNames { get; } + + public Type Handler { get; } + + internal void Match(RouteContext context) + { + string? catchAllValue = null; + + // If this template contains a catch-all parameter, we can concatenate the pathSegments + // at and beyond the catch-all segment's position. For example: + // Template: /foo/bar/{*catchAll} + // PathSegments: /foo/bar/one/two/three + if (Template.ContainsCatchAllSegment && context.Segments.Length >= Template.Segments.Length) + { + catchAllValue = string.Join('/', context.Segments[Range.StartAt(Template.Segments.Length - 1)]); + } + // If there are no optional segments on the route and the length of the route + // and the template do not match, then there is no chance of this matching and + // we can bail early. + else if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length) + { + return; + } + + // Parameters will be lazily initialized. + Dictionary parameters = null; + var numMatchingSegments = 0; + for (var i = 0; i < Template.Segments.Length; i++) + { + var segment = Template.Segments[i]; + + if (segment.IsCatchAll) + { + numMatchingSegments += 1; + parameters ??= new Dictionary(StringComparer.Ordinal); + parameters[segment.Value] = catchAllValue; + break; + } + + // If the template contains more segments than the path, then + // we may need to break out of this for-loop. This can happen + // in one of two cases: + // + // (1) If we are comparing a literal route with a literal template + // and the route is shorter than the template. + // (2) If we are comparing a template where the last value is an optional + // parameter that the route does not provide. + if (i >= context.Segments.Length) + { + // If we are under condition (1) above then we can stop evaluating + // matches on the rest of this template. + if (!segment.IsParameter && !segment.IsOptional) + { + break; + } + } + + string pathSegment = null; + if (i < context.Segments.Length) + { + pathSegment = context.Segments[i]; + } + + if (!segment.Match(pathSegment, out var matchedParameterValue)) + { + return; + } + else + { + numMatchingSegments++; + if (segment.IsParameter) + { + parameters ??= new Dictionary(StringComparer.Ordinal); + parameters[segment.Value] = matchedParameterValue; + } + } + } + + // In addition to extracting parameter values from the URL, each route entry + // also knows which other parameters should be supplied with null values. These + // are parameters supplied by other route entries matching the same handler. + if (!Template.ContainsCatchAllSegment && UnusedRouteParameterNames.Length > 0) + { + parameters ??= new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < UnusedRouteParameterNames.Length; i++) + { + parameters[UnusedRouteParameterNames[i]] = null; + } + } + + // We track the number of segments in the template that matched + // against this particular route then only select the route that + // matches the most number of segments on the route that was passed. + // This check is an exactness check that favors the more precise of + // two templates in the event that the following route table exists. + // Route 1: /{anythingGoes} + // Route 2: /users/{id:int} + // And the provided route is `/users/1`. We want to choose Route 2 + // over Route 1. + // Furthermore, literal routes are preferred over parameterized routes. + // If the two routes below are registered in the route table. + // Route 1: /users/1 + // Route 2: /users/{id:int} + // And the provided route is `/users/1`. We want to choose Route 1 over + // Route 2. + var allRouteSegmentsMatch = numMatchingSegments >= context.Segments.Length; + // Checking that all route segments have been matches does not suffice if we are + // comparing literal templates with literal routes. For example, the template + // `/this/is/a/template` and the route `/this/`. In that case, we want to ensure + // that all non-optional segments have matched as well. + var allNonOptionalSegmentsMatch = numMatchingSegments >= (Template.Segments.Length - Template.OptionalSegmentsCount); + if (Template.ContainsCatchAllSegment || (allRouteSegmentsMatch && allNonOptionalSegmentsMatch)) + { + context.Parameters = parameters; + context.Handler = Handler; + } + } + } +} diff --git a/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTable.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTable.cs new file mode 100644 index 0000000000..de8fc9ef90 --- /dev/null +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTable.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. + +// Avoid referencing the whole Microsoft.AspNetCore.Components.Routing namespace to +// avoid the risk of accidentally relying on the non-legacy types in the legacy fork +using RouteContext = Microsoft.AspNetCore.Components.Routing.RouteContext; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + internal class LegacyRouteTable : Routing.IRouteTable + { + public LegacyRouteTable(LegacyRouteEntry[] routes) + { + Routes = routes; + } + + public LegacyRouteEntry[] Routes { get; } + + public void Route(RouteContext routeContext) + { + for (var i = 0; i < Routes.Length; i++) + { + Routes[i].Match(routeContext); + if (routeContext.Handler != null) + { + return; + } + } + } + } +} diff --git a/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTableFactory.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTableFactory.cs new file mode 100644 index 0000000000..a87f5adc9d --- /dev/null +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTableFactory.cs @@ -0,0 +1,236 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + /// + /// Resolves components for an application. + /// + internal static class LegacyRouteTableFactory + { + private static readonly ConcurrentDictionary Cache = + new ConcurrentDictionary(); + public static readonly IComparer RoutePrecedence = Comparer.Create(RouteComparison); + + public static LegacyRouteTable Create(IEnumerable assemblies) + { + var key = new Key(assemblies.OrderBy(a => a.FullName).ToArray()); + if (Cache.TryGetValue(key, out var resolvedComponents)) + { + return resolvedComponents; + } + + var componentTypes = key.Assemblies.SelectMany(a => a.ExportedTypes.Where(t => typeof(IComponent).IsAssignableFrom(t))); + var routeTable = Create(componentTypes); + Cache.TryAdd(key, routeTable); + return routeTable; + } + + internal static LegacyRouteTable Create(IEnumerable componentTypes) + { + var templatesByHandler = new Dictionary(); + foreach (var componentType in componentTypes) + { + // We're deliberately using inherit = false here. + // + // RouteAttribute is defined as non-inherited, because inheriting a route attribute always causes an + // ambiguity. You end up with two components (base class and derived class) with the same route. + var routeAttributes = componentType.GetCustomAttributes(inherit: false); + + var templates = routeAttributes.Select(t => t.Template).ToArray(); + templatesByHandler.Add(componentType, templates); + } + return Create(templatesByHandler); + } + + internal static LegacyRouteTable Create(Dictionary templatesByHandler) + { + var routes = new List(); + foreach (var keyValuePair in templatesByHandler) + { + var parsedTemplates = keyValuePair.Value.Select(v => LegacyTemplateParser.ParseTemplate(v)).ToArray(); + var allRouteParameterNames = parsedTemplates + .SelectMany(GetParameterNames) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + foreach (var parsedTemplate in parsedTemplates) + { + var unusedRouteParameterNames = allRouteParameterNames + .Except(GetParameterNames(parsedTemplate), StringComparer.OrdinalIgnoreCase) + .ToArray(); + var entry = new LegacyRouteEntry(parsedTemplate, keyValuePair.Key, unusedRouteParameterNames); + routes.Add(entry); + } + } + + return new LegacyRouteTable(routes.OrderBy(id => id, RoutePrecedence).ToArray()); + } + + private static string[] GetParameterNames(LegacyRouteTemplate routeTemplate) + { + return routeTemplate.Segments + .Where(s => s.IsParameter) + .Select(s => s.Value) + .ToArray(); + } + + /// + /// Route precedence algorithm. + /// We collect all the routes and sort them from most specific to + /// less specific. The specificity of a route is given by the specificity + /// of its segments and the position of those segments in the route. + /// * A literal segment is more specific than a parameter segment. + /// * A parameter segment with more constraints is more specific than one with fewer constraints + /// * Segment earlier in the route are evaluated before segments later in the route. + /// For example: + /// /Literal is more specific than /Parameter + /// /Route/With/{parameter} is more specific than /{multiple}/With/{parameters} + /// /Product/{id:int} is more specific than /Product/{id} + /// + /// Routes can be ambiguous if: + /// They are composed of literals and those literals have the same values (case insensitive) + /// They are composed of a mix of literals and parameters, in the same relative order and the + /// literals have the same values. + /// For example: + /// * /literal and /Literal + /// /{parameter}/literal and /{something}/literal + /// /{parameter:constraint}/literal and /{something:constraint}/literal + /// + /// To calculate the precedence we sort the list of routes as follows: + /// * Shorter routes go first. + /// * A literal wins over a parameter in precedence. + /// * For literals with different values (case insensitive) we choose the lexical order + /// * For parameters with different numbers of constraints, the one with more wins + /// If we get to the end of the comparison routing we've detected an ambiguous pair of routes. + /// + internal static int RouteComparison(LegacyRouteEntry x, LegacyRouteEntry y) + { + if (ReferenceEquals(x, y)) + { + return 0; + } + + var xTemplate = x.Template; + var yTemplate = y.Template; + if (xTemplate.Segments.Length != y.Template.Segments.Length) + { + return xTemplate.Segments.Length < y.Template.Segments.Length ? -1 : 1; + } + else + { + for (var i = 0; i < xTemplate.Segments.Length; i++) + { + var xSegment = xTemplate.Segments[i]; + var ySegment = yTemplate.Segments[i]; + if (!xSegment.IsParameter && ySegment.IsParameter) + { + return -1; + } + if (xSegment.IsParameter && !ySegment.IsParameter) + { + return 1; + } + + if (xSegment.IsParameter) + { + // Always favor non-optional parameters over optional ones + if (!xSegment.IsOptional && ySegment.IsOptional) + { + return -1; + } + + if (xSegment.IsOptional && !ySegment.IsOptional) + { + return 1; + } + + if (xSegment.Constraints.Length > ySegment.Constraints.Length) + { + return -1; + } + else if (xSegment.Constraints.Length < ySegment.Constraints.Length) + { + return 1; + } + } + else + { + var comparison = string.Compare(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase); + if (comparison != 0) + { + return comparison; + } + } + } + + throw new InvalidOperationException($@"The following routes are ambiguous: +'{x.Template.TemplateText}' in '{x.Handler.FullName}' +'{y.Template.TemplateText}' in '{y.Handler.FullName}' +"); + } + } + + private readonly struct Key : IEquatable + { + public readonly Assembly[] Assemblies; + + public Key(Assembly[] assemblies) + { + Assemblies = assemblies; + } + + public override bool Equals(object? obj) + { + return obj is Key other ? base.Equals(other) : false; + } + + public bool Equals(Key other) + { + if (Assemblies == null && other.Assemblies == null) + { + return true; + } + else if ((Assemblies == null) || (other.Assemblies == null)) + { + return false; + } + else if (Assemblies.Length != other.Assemblies.Length) + { + return false; + } + + for (var i = 0; i < Assemblies.Length; i++) + { + if (!Assemblies[i].Equals(other.Assemblies[i])) + { + return false; + } + } + + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + + if (Assemblies != null) + { + for (var i = 0; i < Assemblies.Length; i++) + { + hash.Add(Assemblies[i]); + } + } + + return hash.ToHashCode(); + } + } + } +} diff --git a/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTemplate.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTemplate.cs new file mode 100644 index 0000000000..a033d8ddff --- /dev/null +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTemplate.cs @@ -0,0 +1,29 @@ +// 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.Diagnostics; +using System.Linq; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + [DebuggerDisplay("{TemplateText}")] + internal class LegacyRouteTemplate + { + public LegacyRouteTemplate(string templateText, LegacyTemplateSegment[] segments) + { + TemplateText = templateText; + Segments = segments; + OptionalSegmentsCount = segments.Count(template => template.IsOptional); + ContainsCatchAllSegment = segments.Any(template => template.IsCatchAll); + } + + public string TemplateText { get; } + + public LegacyTemplateSegment[] Segments { get; } + + public int OptionalSegmentsCount { get; } + + public bool ContainsCatchAllSegment { get; } + } +} diff --git a/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateParser.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateParser.cs new file mode 100644 index 0000000000..c9312947f2 --- /dev/null +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateParser.cs @@ -0,0 +1,115 @@ +// 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; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + // This implementation is temporary, in the future we'll want to have + // a more performant/properly designed routing set of abstractions. + // To be more precise these are some things we are scoping out: + // * We are not doing link generation. + // * We are not supporting all the route constraint formats supported by ASP.NET server-side routing. + // The class in here just takes care of parsing a route and extracting + // simple parameters from it. + // Some differences with ASP.NET Core routes are: + // * We don't support complex segments. + // The things that we support are: + // * Literal path segments. (Like /Path/To/Some/Page) + // * Parameter path segments (Like /Customer/{Id}/Orders/{OrderId}) + // * Catch-all parameters (Like /blog/{*slug}) + internal class LegacyTemplateParser + { + public static readonly char[] InvalidParameterNameCharacters = + new char[] { '{', '}', '=', '.' }; + + internal static LegacyRouteTemplate ParseTemplate(string template) + { + var originalTemplate = template; + template = template.Trim('/'); + if (template == string.Empty) + { + // Special case "/"; + return new LegacyRouteTemplate("/", Array.Empty()); + } + + var segments = template.Split('/'); + var templateSegments = new LegacyTemplateSegment[segments.Length]; + for (int i = 0; i < segments.Length; i++) + { + var segment = segments[i]; + if (string.IsNullOrEmpty(segment)) + { + throw new InvalidOperationException( + $"Invalid template '{template}'. Empty segments are not allowed."); + } + + if (segment[0] != '{') + { + if (segment[segment.Length - 1] == '}') + { + throw new InvalidOperationException( + $"Invalid template '{template}'. Missing '{{' in parameter segment '{segment}'."); + } + templateSegments[i] = new LegacyTemplateSegment(originalTemplate, segment, isParameter: false); + } + else + { + if (segment[segment.Length - 1] != '}') + { + throw new InvalidOperationException( + $"Invalid template '{template}'. Missing '}}' in parameter segment '{segment}'."); + } + + if (segment.Length < 3) + { + throw new InvalidOperationException( + $"Invalid template '{template}'. Empty parameter name in segment '{segment}' is not allowed."); + } + + var invalidCharacter = segment.IndexOfAny(InvalidParameterNameCharacters, 1, segment.Length - 2); + if (invalidCharacter != -1) + { + throw new InvalidOperationException( + $"Invalid template '{template}'. The character '{segment[invalidCharacter]}' in parameter segment '{segment}' is not allowed."); + } + + templateSegments[i] = new LegacyTemplateSegment(originalTemplate, segment.Substring(1, segment.Length - 2), isParameter: true); + } + } + + for (int i = 0; i < templateSegments.Length; i++) + { + var currentSegment = templateSegments[i]; + + if (currentSegment.IsCatchAll && i != templateSegments.Length - 1) + { + throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter can only appear as the last segment of the route template."); + } + + if (!currentSegment.IsParameter) + { + continue; + } + + for (int j = i + 1; j < templateSegments.Length; j++) + { + var nextSegment = templateSegments[j]; + + if (currentSegment.IsOptional && !nextSegment.IsOptional) + { + throw new InvalidOperationException($"Invalid template '{template}'. Non-optional parameters or literal routes cannot appear after optional parameters."); + } + + if (string.Equals(currentSegment.Value, nextSegment.Value, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Invalid template '{template}'. The parameter '{currentSegment}' appears multiple times."); + } + } + } + + return new LegacyRouteTemplate(template, templateSegments); + } + } +} diff --git a/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateSegment.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateSegment.cs new file mode 100644 index 0000000000..afe93a840f --- /dev/null +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateSegment.cs @@ -0,0 +1,123 @@ +// 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; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + internal class LegacyTemplateSegment + { + public LegacyTemplateSegment(string template, string segment, bool isParameter) + { + IsParameter = isParameter; + + IsCatchAll = segment.StartsWith('*'); + + if (IsCatchAll) + { + // Only one '*' currently allowed + Value = segment.Substring(1); + + var invalidCharacter = Value.IndexOf('*'); + if (Value.IndexOf('*') != -1) + { + throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter may only have one '*' at the beginning of the segment."); + } + } + else + { + Value = segment; + } + + // Process segments that are not parameters or do not contain + // a token separating a type constraint. + if (!isParameter || Value.IndexOf(':') < 0) + { + // Set the IsOptional flag to true for segments that contain + // a parameter with no type constraints but optionality set + // via the '?' token. + if (Value.IndexOf('?') == Value.Length - 1) + { + IsOptional = true; + Value = Value.Substring(0, Value.Length - 1); + } + // If the `?` optional marker shows up in the segment but not at the very end, + // then throw an error. + else if (Value.IndexOf('?') >= 0 && Value.IndexOf('?') != Value.Length - 1) + { + throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name."); + } + + Constraints = Array.Empty(); + } + else + { + var tokens = Value.Split(':'); + if (tokens[0].Length == 0) + { + throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list."); + } + + // Set the IsOptional flag to true if any type constraints + // for this parameter are designated as optional. + IsOptional = tokens.Skip(1).Any(token => token.EndsWith("?")); + + Value = tokens[0]; + Constraints = tokens.Skip(1) + .Select(token => LegacyRouteConstraint.Parse(template, segment, token)) + .ToArray(); + } + + if (IsParameter) + { + if (IsOptional && IsCatchAll) + { + throw new InvalidOperationException($"Invalid segment '{segment}' in route '{template}'. A catch-all parameter cannot be marked optional."); + } + + // Moving the check for this here instead of TemplateParser so we can allow catch-all. + // We checked for '*' up above specifically for catch-all segments, this one checks for all others + if (Value.IndexOf('*') != -1) + { + throw new InvalidOperationException($"Invalid template '{template}'. The character '*' in parameter segment '{{{segment}}}' is not allowed."); + } + } + } + + // The value of the segment. The exact text to match when is a literal. + // The parameter name when its a segment + public string Value { get; } + + public bool IsParameter { get; } + + public bool IsOptional { get; } + + public bool IsCatchAll { get; } + + public LegacyRouteConstraint[] Constraints { get; } + + public bool Match(string pathSegment, out object? matchedParameterValue) + { + if (IsParameter) + { + matchedParameterValue = pathSegment; + + foreach (var constraint in Constraints) + { + if (!constraint.Match(pathSegment, out matchedParameterValue)) + { + return false; + } + } + + return true; + } + else + { + matchedParameterValue = null; + return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase); + } + } + } +} diff --git a/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTypeRouteConstraint.cs b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTypeRouteConstraint.cs new file mode 100644 index 0000000000..47a9777a45 --- /dev/null +++ b/src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTypeRouteConstraint.cs @@ -0,0 +1,37 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + /// + /// A route constraint that requires the value to be parseable as a specified type. + /// + /// The type to which the value must be parseable. + internal class LegacyTypeRouteConstraint : LegacyRouteConstraint + { + public delegate bool LegacyTryParseDelegate(string str, [MaybeNullWhen(false)] out T result); + + private readonly LegacyTryParseDelegate _parser; + + public LegacyTypeRouteConstraint(LegacyTryParseDelegate parser) + { + _parser = parser; + } + + public override bool Match(string pathSegment, out object? convertedValue) + { + if (_parser(pathSegment, out var result)) + { + convertedValue = result; + return true; + } + else + { + convertedValue = null; + return false; + } + } + } +} diff --git a/src/Components/Components/src/Routing/RouteConstraint.cs b/src/Components/Components/src/Routing/RouteConstraint.cs index 97ae19580f..babfaf64dd 100644 --- a/src/Components/Components/src/Routing/RouteConstraint.cs +++ b/src/Components/Components/src/Routing/RouteConstraint.cs @@ -51,9 +51,6 @@ namespace Microsoft.AspNetCore.Components.Routing /// Creates a structured RouteConstraint object given a string that contains /// the route constraint. A constraint is the place after the colon in a /// parameter definition, for example `{age:int?}`. - /// - /// If the constraint denotes an optional, this method will return an - /// which handles the appropriate checks. /// /// String representation of the constraint /// Type-specific RouteConstraint object @@ -63,48 +60,26 @@ namespace Microsoft.AspNetCore.Components.Routing { case "bool": return new TypeRouteConstraint(bool.TryParse); - case "bool?": - return new OptionalTypeRouteConstraint(bool.TryParse); case "datetime": return new TypeRouteConstraint((string str, out DateTime result) => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result)); - case "datetime?": - return new OptionalTypeRouteConstraint((string str, out DateTime result) - => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result)); case "decimal": return new TypeRouteConstraint((string str, out decimal result) => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); - case "decimal?": - return new OptionalTypeRouteConstraint((string str, out decimal result) - => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); case "double": return new TypeRouteConstraint((string str, out double result) => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); - case "double?": - return new OptionalTypeRouteConstraint((string str, out double result) - => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); case "float": return new TypeRouteConstraint((string str, out float result) => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); - case "float?": - return new OptionalTypeRouteConstraint((string str, out float result) - => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result)); case "guid": return new TypeRouteConstraint(Guid.TryParse); - case "guid?": - return new OptionalTypeRouteConstraint(Guid.TryParse); case "int": return new TypeRouteConstraint((string str, out int result) => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)); - case "int?": - return new OptionalTypeRouteConstraint((string str, out int result) - => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)); case "long": return new TypeRouteConstraint((string str, out long result) => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)); - case "long?": - return new OptionalTypeRouteConstraint((string str, out long result) - => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)); default: return null; } diff --git a/src/Components/Components/src/Routing/RouteEntry.cs b/src/Components/Components/src/Routing/RouteEntry.cs index e18b71f0ef..e25ef985d0 100644 --- a/src/Components/Components/src/Routing/RouteEntry.cs +++ b/src/Components/Components/src/Routing/RouteEntry.cs @@ -29,116 +29,122 @@ namespace Microsoft.AspNetCore.Components.Routing internal void Match(RouteContext context) { - string? catchAllValue = null; - - // If this template contains a catch-all parameter, we can concatenate the pathSegments - // at and beyond the catch-all segment's position. For example: - // Template: /foo/bar/{*catchAll} - // PathSegments: /foo/bar/one/two/three - if (Template.ContainsCatchAllSegment && context.Segments.Length >= Template.Segments.Length) - { - catchAllValue = string.Join('/', context.Segments[Range.StartAt(Template.Segments.Length - 1)]); - } - // If there are no optional segments on the route and the length of the route - // and the template do not match, then there is no chance of this matching and - // we can bail early. - else if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length) - { - return; - } - - // Parameters will be lazily initialized. + var pathIndex = 0; + var templateIndex = 0; Dictionary parameters = null; - var numMatchingSegments = 0; - for (var i = 0; i < Template.Segments.Length; i++) + // We will iterate over the path segments and the template segments until we have consumed + // one of them. + // There are three cases we need to account here for: + // * Path is shorter than template -> + // * This can match only if we have t-p optional parameters at the end. + // * Path and template have the same number of segments + // * This can happen when the catch-all segment matches 1 segment + // * This can happen when an optional parameter has been specified. + // * This can happen when the route only contains literals and parameters. + // * Path is longer than template -> This can only match if the parameter has a catch-all at the end. + // * We still need to iterate over all the path segments if the catch-all is constrained. + // * We still need to iterate over all the template/path segments before the catch-all + while (pathIndex < context.Segments.Length && templateIndex < Template.Segments.Length) { - var segment = Template.Segments[i]; + var pathSegment = context.Segments[pathIndex]; + var templateSegment = Template.Segments[templateIndex]; - if (segment.IsCatchAll) - { - numMatchingSegments += 1; - parameters ??= new Dictionary(StringComparer.Ordinal); - parameters[segment.Value] = catchAllValue; - break; - } - - // If the template contains more segments than the path, then - // we may need to break out of this for-loop. This can happen - // in one of two cases: - // - // (1) If we are comparing a literal route with a literal template - // and the route is shorter than the template. - // (2) If we are comparing a template where the last value is an optional - // parameter that the route does not provide. - if (i >= context.Segments.Length) - { - // If we are under condition (1) above then we can stop evaluating - // matches on the rest of this template. - if (!segment.IsParameter && !segment.IsOptional) - { - break; - } - } - - string pathSegment = null; - if (i < context.Segments.Length) - { - pathSegment = context.Segments[i]; - } - - if (!segment.Match(pathSegment, out var matchedParameterValue)) + var matches = templateSegment.Match(pathSegment, out var match); + if (!matches) { + // A constraint or literal didn't match return; } + + if (!templateSegment.IsCatchAll) + { + // We were dealing with a literal or a parameter, so just advance both cursors. + pathIndex++; + templateIndex++; + + if (templateSegment.IsParameter) + { + parameters ??= new(StringComparer.OrdinalIgnoreCase); + parameters[templateSegment.Value] = match; + } + } else { - numMatchingSegments++; - if (segment.IsParameter) + if (templateSegment.Constraints.Length == 0) { - parameters ??= new Dictionary(StringComparer.Ordinal); - parameters[segment.Value] = matchedParameterValue; + + // Unconstrained catch all, we can stop early + parameters ??= new(StringComparer.OrdinalIgnoreCase); + parameters[templateSegment.Value] = string.Join('/', context.Segments, pathIndex, context.Segments.Length - pathIndex); + + // Mark the remaining segments as consumed. + pathIndex = context.Segments.Length; + + // Catch-alls are always last. + templateIndex++; + + // We are done, so break out of the loop. + break; + } + else + { + // For constrained catch-alls, we advance the path index but keep the template index on the catch-all. + pathIndex++; + if (pathIndex == context.Segments.Length) + { + parameters ??= new(StringComparer.OrdinalIgnoreCase); + parameters[templateSegment.Value] = string.Join('/', context.Segments, templateIndex, context.Segments.Length - templateIndex); + + // This is important to signal that we consumed the entire template. + templateIndex++; + } } } } - // In addition to extracting parameter values from the URL, each route entry - // also knows which other parameters should be supplied with null values. These - // are parameters supplied by other route entries matching the same handler. - if (!Template.ContainsCatchAllSegment && UnusedRouteParameterNames.Length > 0) + var hasRemainingOptionalSegments = templateIndex < Template.Segments.Length && + RemainingSegmentsAreOptional(pathIndex, Template.Segments); + + if ((pathIndex == context.Segments.Length && templateIndex == Template.Segments.Length) || hasRemainingOptionalSegments) { - parameters ??= new Dictionary(StringComparer.Ordinal); - for (var i = 0; i < UnusedRouteParameterNames.Length; i++) + if (hasRemainingOptionalSegments) { - parameters[UnusedRouteParameterNames[i]] = null; + parameters ??= new Dictionary(StringComparer.Ordinal); + AddDefaultValues(parameters, templateIndex, Template.Segments); + } + if (UnusedRouteParameterNames?.Length > 0) + { + parameters ??= new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < UnusedRouteParameterNames.Length; i++) + { + parameters[UnusedRouteParameterNames[i]] = null; + } + } + context.Handler = Handler; + context.Parameters = parameters; + } + } + + private void AddDefaultValues(Dictionary parameters, int templateIndex, TemplateSegment[] segments) + { + for (var i = templateIndex; i < segments.Length; i++) + { + var currentSegment = segments[i]; + parameters[currentSegment.Value] = null; + } + } + + private bool RemainingSegmentsAreOptional(int index, TemplateSegment[] segments) + { + for (var i = index; index < segments.Length - 1; index++) + { + if (!segments[i].IsOptional) + { + return false; } } - // We track the number of segments in the template that matched - // against this particular route then only select the route that - // matches the most number of segments on the route that was passed. - // This check is an exactness check that favors the more precise of - // two templates in the event that the following route table exists. - // Route 1: /{anythingGoes} - // Route 2: /users/{id:int} - // And the provided route is `/users/1`. We want to choose Route 2 - // over Route 1. - // Furthermore, literal routes are preferred over parameterized routes. - // If the two routes below are registered in the route table. - // Route 1: /users/1 - // Route 2: /users/{id:int} - // And the provided route is `/users/1`. We want to choose Route 1 over - // Route 2. - var allRouteSegmentsMatch = numMatchingSegments >= context.Segments.Length; - // Checking that all route segments have been matches does not suffice if we are - // comparing literal templates with literal routes. For example, the template - // `/this/is/a/template` and the route `/this/`. In that case, we want to ensure - // that all non-optional segments have matched as well. - var allNonOptionalSegmentsMatch = numMatchingSegments >= (Template.Segments.Length - Template.OptionalSegmentsCount); - if (Template.ContainsCatchAllSegment || (allRouteSegmentsMatch && allNonOptionalSegmentsMatch)) - { - context.Parameters = parameters; - context.Handler = Handler; - } + return segments[^1].IsOptional || segments[^1].IsCatchAll; } } } diff --git a/src/Components/Components/src/Routing/RouteTable.cs b/src/Components/Components/src/Routing/RouteTable.cs index 029bc47657..0daa00ec02 100644 --- a/src/Components/Components/src/Routing/RouteTable.cs +++ b/src/Components/Components/src/Routing/RouteTable.cs @@ -3,7 +3,7 @@ namespace Microsoft.AspNetCore.Components.Routing { - internal class RouteTable + internal class RouteTable : IRouteTable { public RouteTable(RouteEntry[] routes) { @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Components.Routing public RouteEntry[] Routes { get; } - internal void Route(RouteContext routeContext) + public void Route(RouteContext routeContext) { for (var i = 0; i < Routes.Length; i++) { diff --git a/src/Components/Components/src/Routing/RouteTableFactory.cs b/src/Components/Components/src/Routing/RouteTableFactory.cs index 4f66d660f7..8b92d7d22a 100644 --- a/src/Components/Components/src/Routing/RouteTableFactory.cs +++ b/src/Components/Components/src/Routing/RouteTableFactory.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using Microsoft.AspNetCore.Components.Routing; -using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Components { @@ -121,62 +120,63 @@ namespace Microsoft.AspNetCore.Components var xTemplate = x.Template; var yTemplate = y.Template; - if (xTemplate.Segments.Length != y.Template.Segments.Length) + var minSegments = Math.Min(xTemplate.Segments.Length, yTemplate.Segments.Length); + var currentResult = 0; + for (var i = 0; i < minSegments; i++) { - return xTemplate.Segments.Length < y.Template.Segments.Length ? -1 : 1; - } - else - { - for (var i = 0; i < xTemplate.Segments.Length; i++) + var xSegment = xTemplate.Segments[i]; + var ySegment = yTemplate.Segments[i]; + + var xRank = GetRank(xSegment); + var yRank = GetRank(ySegment); + + currentResult = xRank.CompareTo(yRank); + + // If they are both literals we can disambiguate + if ((xRank, yRank) == (0, 0)) { - var xSegment = xTemplate.Segments[i]; - var ySegment = yTemplate.Segments[i]; - if (!xSegment.IsParameter && ySegment.IsParameter) - { - return -1; - } - if (xSegment.IsParameter && !ySegment.IsParameter) - { - return 1; - } - - if (xSegment.IsParameter) - { - // Always favor non-optional parameters over optional ones - if (!xSegment.IsOptional && ySegment.IsOptional) - { - return -1; - } - - if (xSegment.IsOptional && !ySegment.IsOptional) - { - return 1; - } - - if (xSegment.Constraints.Length > ySegment.Constraints.Length) - { - return -1; - } - else if (xSegment.Constraints.Length < ySegment.Constraints.Length) - { - return 1; - } - } - else - { - var comparison = string.Compare(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase); - if (comparison != 0) - { - return comparison; - } - } + currentResult = StringComparer.OrdinalIgnoreCase.Compare(xSegment.Value, ySegment.Value); } + if (currentResult != 0) + { + break; + } + } + + if (currentResult == 0) + { + currentResult = xTemplate.Segments.Length.CompareTo(yTemplate.Segments.Length); + } + + if (currentResult == 0) + { throw new InvalidOperationException($@"The following routes are ambiguous: '{x.Template.TemplateText}' in '{x.Handler.FullName}' '{y.Template.TemplateText}' in '{y.Handler.FullName}' "); } + + return currentResult; + } + + private static int GetRank(TemplateSegment xSegment) + { + return xSegment switch + { + // Literal + { IsParameter: false } => 0, + // Parameter with constraints + { IsParameter: true, IsCatchAll: false, Constraints: { Length: > 0 } } => 1, + // Parameter without constraints + { IsParameter: true, IsCatchAll: false, Constraints: { Length: 0 } } => 2, + // Catch all parameter with constraints + { IsParameter: true, IsCatchAll: true, Constraints: { Length: > 0 } } => 3, + // Catch all parameter without constraints + { IsParameter: true, IsCatchAll: true, Constraints: { Length: 0 } } => 4, + // The segment is not correct + _ => throw new InvalidOperationException($"Unknown segment definition '{xSegment}.") + }; } private readonly struct Key : IEquatable diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index 569a7061b1..efee3c4e76 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -12,6 +12,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Components.LegacyRouteMatching; namespace Microsoft.AspNetCore.Components.Routing { @@ -75,7 +76,20 @@ namespace Microsoft.AspNetCore.Components.Routing /// [Parameter] public EventCallback OnNavigateAsync { get; set; } - private RouteTable Routes { get; set; } + /// + /// Gets or sets a flag to indicate whether route matching should prefer exact matches + /// over wildcards. + /// + /// + /// + /// Important: all applications should explicitly set this to true. The option to set it to false + /// (or leave unset, which defaults to false) is only provided for backward compatibility. + /// In .NET 6, this option will be removed and the router will always prefer exact matches. + /// + /// + [Parameter] public bool PreferExactMatches { get; set; } + + private IRouteTable Routes { get; set; } /// public void Attach(RenderHandle renderHandle) @@ -142,7 +156,9 @@ namespace Microsoft.AspNetCore.Components.Routing if (!_assemblies.SetEquals(assembliesSet)) { - Routes = RouteTableFactory.Create(assemblies); + Routes = PreferExactMatches + ? RouteTableFactory.Create(assemblies) + : LegacyRouteTableFactory.Create(assemblies); _assemblies.Clear(); _assemblies.UnionWith(assembliesSet); } diff --git a/src/Components/Components/src/Routing/TemplateParser.cs b/src/Components/Components/src/Routing/TemplateParser.cs index 41bc8ede28..1f4f1bbb1f 100644 --- a/src/Components/Components/src/Routing/TemplateParser.cs +++ b/src/Components/Components/src/Routing/TemplateParser.cs @@ -50,6 +50,11 @@ namespace Microsoft.AspNetCore.Components.Routing throw new InvalidOperationException( $"Invalid template '{template}'. Missing '{{' in parameter segment '{segment}'."); } + if (segment[^1] == '?') + { + throw new InvalidOperationException( + $"Invalid template '{template}'. '?' is not allowed in literal segment '{segment}'."); + } templateSegments[i] = new TemplateSegment(originalTemplate, segment, isParameter: false); } else @@ -95,7 +100,7 @@ namespace Microsoft.AspNetCore.Components.Routing { var nextSegment = templateSegments[j]; - if (currentSegment.IsOptional && !nextSegment.IsOptional) + if (currentSegment.IsOptional && !nextSegment.IsOptional && !nextSegment.IsCatchAll) { throw new InvalidOperationException($"Invalid template '{template}'. Non-optional parameters or literal routes cannot appear after optional parameters."); } diff --git a/src/Components/Components/src/Routing/TemplateSegment.cs b/src/Components/Components/src/Routing/TemplateSegment.cs index c4d3519951..fb2c03ec5c 100644 --- a/src/Components/Components/src/Routing/TemplateSegment.cs +++ b/src/Components/Components/src/Routing/TemplateSegment.cs @@ -12,15 +12,15 @@ namespace Microsoft.AspNetCore.Components.Routing { IsParameter = isParameter; - IsCatchAll = segment.StartsWith('*'); + IsCatchAll = isParameter && segment.StartsWith('*'); if (IsCatchAll) { // Only one '*' currently allowed - Value = segment.Substring(1); + Value = segment[1..]; - var invalidCharacter = Value.IndexOf('*'); - if (Value.IndexOf('*') != -1) + var invalidCharacterIndex = Value.IndexOf('*'); + if (invalidCharacterIndex != -1) { throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter may only have one '*' at the beginning of the segment."); } @@ -30,43 +30,55 @@ namespace Microsoft.AspNetCore.Components.Routing Value = segment; } - // Process segments that are not parameters or do not contain - // a token separating a type constraint. - if (!isParameter || Value.IndexOf(':') < 0) + // Process segments that parameters that do not contain a token separating a type constraint. + if (IsParameter) { - // Set the IsOptional flag to true for segments that contain - // a parameter with no type constraints but optionality set - // via the '?' token. - if (Value.IndexOf('?') == Value.Length - 1) + if (Value.IndexOf(':') < 0) { - IsOptional = true; - Value = Value.Substring(0, Value.Length - 1); - } - // If the `?` optional marker shows up in the segment but not at the very end, - // then throw an error. - else if (Value.IndexOf('?') >= 0 && Value.IndexOf('?') != Value.Length - 1) - { - throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name."); - } - Constraints = Array.Empty(); + // Set the IsOptional flag to true for segments that contain + // a parameter with no type constraints but optionality set + // via the '?' token. + var questionMarkIndex = Value.IndexOf('?'); + if (questionMarkIndex == Value.Length - 1) + { + IsOptional = true; + Value = Value[0..^1]; + } + // If the `?` optional marker shows up in the segment but not at the very end, + // then throw an error. + else if (questionMarkIndex >= 0) + { + throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name."); + } + + Constraints = Array.Empty(); + } + else + { + var tokens = Value.Split(':'); + if (tokens[0].Length == 0) + { + throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list."); + } + + Value = tokens[0]; + IsOptional = tokens[^1].EndsWith("?"); + if (IsOptional) + { + tokens[^1] = tokens[^1][0..^1]; + } + + Constraints = new RouteConstraint[tokens.Length - 1]; + for (var i = 1; i < tokens.Length; i++) + { + Constraints[i - 1] = RouteConstraint.Parse(template, segment, tokens[i]); + } + } } else { - var tokens = Value.Split(':'); - if (tokens[0].Length == 0) - { - throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list."); - } - - // Set the IsOptional flag to true if any type constraints - // for this parameter are designated as optional. - IsOptional = tokens.Skip(1).Any(token => token.EndsWith("?")); - - Value = tokens[0]; - Constraints = tokens.Skip(1) - .Select(token => RouteConstraint.Parse(template, segment, token)) - .ToArray(); + Constraints = Array.Empty(); } if (IsParameter) @@ -91,7 +103,7 @@ namespace Microsoft.AspNetCore.Components.Routing public bool IsParameter { get; } - public bool IsOptional { get; } + public bool IsOptional { get; } public bool IsCatchAll { get; } @@ -119,5 +131,17 @@ namespace Microsoft.AspNetCore.Components.Routing return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase); } } + + public override string ToString() => this switch + { + { IsParameter: true, IsOptional: false, IsCatchAll: false, Constraints: { Length: 0 } } => $"{{{Value}}}", + { IsParameter: true, IsOptional: false, IsCatchAll: false, Constraints: { Length: > 0 } } => $"{{{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}}}", + { IsParameter: true, IsOptional: true, Constraints: { Length: 0 } } => $"{{{Value}?}}", + { IsParameter: true, IsOptional: true, Constraints: { Length: > 0 } } => $"{{{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}?}}", + { IsParameter: true, IsCatchAll: true, Constraints: { Length: 0 } } => $"{{*{Value}}}", + { IsParameter: true, IsCatchAll: true, Constraints: { Length: > 0 } } => $"{{*{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}?}}", + { IsParameter: false } => Value, + _ => throw new InvalidOperationException("Invalid template segment.") + }; } } diff --git a/src/Components/Components/src/Routing/TypeRouteConstraint.cs b/src/Components/Components/src/Routing/TypeRouteConstraint.cs index 11aa9f749b..1e2f9d3c17 100644 --- a/src/Components/Components/src/Routing/TypeRouteConstraint.cs +++ b/src/Components/Components/src/Routing/TypeRouteConstraint.cs @@ -1,6 +1,7 @@ // 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.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Components.Routing @@ -33,5 +34,18 @@ namespace Microsoft.AspNetCore.Components.Routing return false; } } + + public override string ToString() => typeof(T) switch + { + var x when x == typeof(bool) => "bool", + var x when x == typeof(DateTime) => "datetime", + var x when x == typeof(decimal) => "decimal", + var x when x == typeof(double) => "double", + var x when x == typeof(float) => "float", + var x when x == typeof(Guid) => "guid", + var x when x == typeof(int) => "int", + var x when x == typeof(long) => "long", + var x => x.Name.ToLowerInvariant() + }; } } diff --git a/src/Components/Components/test/LegacyRouteMatching/LegacyRouteConstraintTest.cs b/src/Components/Components/test/LegacyRouteMatching/LegacyRouteConstraintTest.cs new file mode 100644 index 0000000000..501f2ea1d8 --- /dev/null +++ b/src/Components/Components/test/LegacyRouteMatching/LegacyRouteConstraintTest.cs @@ -0,0 +1,46 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + public class LegacyRouteConstraintTest + { + [Fact] + public void Parse_CreatesDifferentConstraints_ForDifferentKinds() + { + // Arrange + var original = LegacyRouteConstraint.Parse("ignore", "ignore", "int"); + + // Act + var another = LegacyRouteConstraint.Parse("ignore", "ignore", "guid"); + + // Assert + Assert.NotSame(original, another); + } + + [Fact] + public void Parse_CachesCreatedConstraint_ForSameKind() + { + // Arrange + var original = LegacyRouteConstraint.Parse("ignore", "ignore", "int"); + + // Act + var another = LegacyRouteConstraint.Parse("ignore", "ignore", "int"); + + // Assert + Assert.Same(original, another); + } + + [Fact] + public void Parse_DoesNotThrowIfOptionalConstraint() + { + // Act + var exceptions = Record.Exception(() => LegacyRouteConstraint.Parse("ignore", "ignore", "int?")); + + // Assert + Assert.Null(exceptions); + } + } +} diff --git a/src/Components/Components/test/LegacyRouteMatching/LegacyRouteTableFactoryTests.cs b/src/Components/Components/test/LegacyRouteMatching/LegacyRouteTableFactoryTests.cs new file mode 100644 index 0000000000..48e3931d5d --- /dev/null +++ b/src/Components/Components/test/LegacyRouteMatching/LegacyRouteTableFactoryTests.cs @@ -0,0 +1,740 @@ +// 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 Xunit; + +// Avoid referencing the whole Microsoft.AspNetCore.Components.Routing namespace to +// avoid the risk of accidentally relying on the non-legacy types in the legacy fork +using RouteContext = Microsoft.AspNetCore.Components.Routing.RouteContext; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + public class LegacyRouteTableFactoryTests + { + [Fact] + public void CanCacheRouteTable() + { + // Arrange + var routes1 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, }); + + // Act + var routes2 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, }); + + // Assert + Assert.Same(routes1, routes2); + } + + [Fact] + public void CanCacheRouteTableWithDifferentAssembliesAndOrder() + { + // Arrange + var routes1 = LegacyRouteTableFactory.Create(new[] { typeof(object).Assembly, GetType().Assembly, }); + + // Act + var routes2 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, typeof(object).Assembly, }); + + // Assert + Assert.Same(routes1, routes2); + } + + [Fact] + public void DoesNotCacheRouteTableForDifferentAssemblies() + { + // Arrange + var routes1 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, }); + + // Act + var routes2 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, typeof(object).Assembly, }); + + // Assert + Assert.NotSame(routes1, routes2); + } + + [Fact] + public void CanDiscoverRoute() + { + // Arrange & Act + var routes = LegacyRouteTableFactory.Create(new[] { typeof(MyComponent), }); + + // Assert + Assert.Equal("Test1", Assert.Single(routes.Routes).Template.TemplateText); + } + + [Route("Test1")] + private class MyComponent : ComponentBase + { + } + + [Fact] + public void CanDiscoverRoutes_WithInheritance() + { + // Arrange & Act + var routes = LegacyRouteTableFactory.Create(new[] { typeof(MyComponent), typeof(MyInheritedComponent), }); + + // Assert + Assert.Collection( + routes.Routes.OrderBy(r => r.Template.TemplateText), + r => Assert.Equal("Test1", r.Template.TemplateText), + r => Assert.Equal("Test2", r.Template.TemplateText)); + } + + [Route("Test2")] + private class MyInheritedComponent : MyComponent + { + } + + [Fact] + public void CanMatchRootTemplate() + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/").Build(); + var context = new RouteContext("/"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + } + + [Fact] + public void CanMatchLiteralTemplate() + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/literal").Build(); + var context = new RouteContext("/literal/"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + } + + [Fact] + public void CanMatchTemplateWithMultipleLiterals() + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/some/awesome/route/").Build(); + var context = new RouteContext("/some/awesome/route"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + } + + [Fact] + public void RouteMatchingIsCaseInsensitive() + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/some/AWESOME/route/").Build(); + var context = new RouteContext("/Some/awesome/RouTe"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + } + + [Fact] + public void CanMatchEncodedSegments() + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/some/ünicõdē/🛣/").Build(); + var context = new RouteContext("/some/%C3%BCnic%C3%B5d%C4%93/%F0%9F%9B%A3"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + } + + [Fact] + public void DoesNotMatchIfSegmentsDontMatch() + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/some/AWESOME/route/").Build(); + var context = new RouteContext("/some/brilliant/route"); + + // Act + routeTable.Route(context); + + // Assert + Assert.Null(context.Handler); + } + + [Theory] + [InlineData("/{value:bool}", "/maybe")] + [InlineData("/{value:datetime}", "/1955-01-32")] + [InlineData("/{value:decimal}", "/hello")] + [InlineData("/{value:double}", "/0.1.2")] + [InlineData("/{value:float}", "/0.1.2")] + [InlineData("/{value:guid}", "/not-a-guid")] + [InlineData("/{value:int}", "/3.141")] + [InlineData("/{value:long}", "/3.141")] + public void DoesNotMatchIfConstraintDoesNotMatch(string template, string contextUrl) + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute(template).Build(); + var context = new RouteContext(contextUrl); + + // Act + routeTable.Route(context); + + // Assert + Assert.Null(context.Handler); + } + + [Theory] + [InlineData("/some")] + [InlineData("/some/awesome/route/with/extra/segments")] + public void DoesNotMatchIfDifferentNumberOfSegments(string path) + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/some/awesome/route/").Build(); + var context = new RouteContext(path); + + // Act + routeTable.Route(context); + + // Assert + Assert.Null(context.Handler); + } + + [Theory] + [InlineData("/value1", "value1")] + [InlineData("/value2/", "value2")] + [InlineData("/d%C3%A9j%C3%A0%20vu", "déjà vu")] + [InlineData("/d%C3%A9j%C3%A0%20vu/", "déjà vu")] + [InlineData("/d%C3%A9j%C3%A0+vu", "déjà+vu")] + public void CanMatchParameterTemplate(string path, string expectedValue) + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/{parameter}").Build(); + var context = new RouteContext(path); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Single(context.Parameters, p => p.Key == "parameter" && (string)p.Value == expectedValue); + } + + [Theory] + [InlineData("/blog/value1", "value1")] + [InlineData("/blog/value1/foo%20bar", "value1/foo bar")] + public void CanMatchCatchAllParameterTemplate(string path, string expectedValue) + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/blog/{*parameter}").Build(); + var context = new RouteContext(path); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Single(context.Parameters, p => p.Key == "parameter" && (string)p.Value == expectedValue); + } + + [Fact] + public void CanMatchTemplateWithMultipleParameters() + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/{some}/awesome/{route}/").Build(); + var context = new RouteContext("/an/awesome/path"); + + var expectedParameters = new Dictionary + { + ["some"] = "an", + ["route"] = "path" + }; + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Equal(expectedParameters, context.Parameters); + } + + + [Fact] + public void CanMatchTemplateWithMultipleParametersAndCatchAllParameter() + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute("/{some}/awesome/{route}/with/{*catchAll}").Build(); + var context = new RouteContext("/an/awesome/path/with/some/catch/all/stuff"); + + var expectedParameters = new Dictionary + { + ["some"] = "an", + ["route"] = "path", + ["catchAll"] = "some/catch/all/stuff" + }; + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Equal(expectedParameters, context.Parameters); + } + + public static IEnumerable CanMatchParameterWithConstraintCases() => new object[][] + { + new object[] { "/{value:bool}", "/true", true }, + new object[] { "/{value:bool}", "/false", false }, + new object[] { "/{value:datetime}", "/1955-01-30", new DateTime(1955, 1, 30) }, + new object[] { "/{value:decimal}", "/5.3", 5.3m }, + new object[] { "/{value:double}", "/0.1", 0.1d }, + new object[] { "/{value:float}", "/0.1", 0.1f }, + new object[] { "/{value:guid}", "/1FCEF085-884F-416E-B0A1-71B15F3E206B", Guid.Parse("1FCEF085-884F-416E-B0A1-71B15F3E206B") }, + new object[] { "/{value:int}", "/123", 123 }, + new object[] { "/{value:int}", "/-123", -123}, + new object[] { "/{value:long}", "/9223372036854775807", long.MaxValue }, + new object[] { "/{value:long}", $"/-9223372036854775808", long.MinValue }, + }; + + [Theory] + [MemberData(nameof(CanMatchParameterWithConstraintCases))] + public void CanMatchParameterWithConstraint(string template, string contextUrl, object convertedValue) + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute(template).Build(); + var context = new RouteContext(contextUrl); + + // Act + routeTable.Route(context); + + // Assert + if (context.Handler == null) + { + // Make it easier to track down failing tests when using MemberData + throw new InvalidOperationException($"Failed to match template '{template}'."); + } + Assert.Equal(new Dictionary + { + { "value", convertedValue } + }, context.Parameters); + } + + [Fact] + public void CanMatchOptionalParameterWithoutConstraints() + { + // Arrange + var template = "/optional/{value?}"; + var contextUrl = "/optional/"; + string convertedValue = null; + + var routeTable = new TestRouteTableBuilder().AddRoute(template).Build(); + var context = new RouteContext(contextUrl); + + // Act + routeTable.Route(context); + + // Assert + if (context.Handler == null) + { + // Make it easier to track down failing tests when using MemberData + throw new InvalidOperationException($"Failed to match template '{template}'."); + } + Assert.Equal(new Dictionary + { + { "value", convertedValue } + }, context.Parameters); + } + + public static IEnumerable CanMatchOptionalParameterWithConstraintCases() => new object[][] +{ + new object[] { "/optional/{value:bool?}", "/optional/", null }, + new object[] { "/optional/{value:datetime?}", "/optional/", null }, + new object[] { "/optional/{value:decimal?}", "/optional/", null }, +}; + + [Theory] + [MemberData(nameof(CanMatchOptionalParameterWithConstraintCases))] + public void CanMatchOptionalParameterWithConstraint(string template, string contextUrl, object convertedValue) + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute(template).Build(); + var context = new RouteContext(contextUrl); + + // Act + routeTable.Route(context); + + // Assert + if (context.Handler == null) + { + // Make it easier to track down failing tests when using MemberData + throw new InvalidOperationException($"Failed to match template '{template}'."); + } + Assert.Equal(new Dictionary + { + { "value", convertedValue } + }, context.Parameters); + } + + [Fact] + public void CanMatchMultipleOptionalParameterWithConstraint() + { + // Arrange + var template = "/optional/{value:datetime?}/{value2:datetime?}"; + var contextUrl = "/optional//"; + object convertedValue = null; + + var routeTable = new TestRouteTableBuilder().AddRoute(template).Build(); + var context = new RouteContext(contextUrl); + + // Act + routeTable.Route(context); + + // Assert + if (context.Handler == null) + { + // Make it easier to track down failing tests when using MemberData + throw new InvalidOperationException($"Failed to match template '{template}'."); + } + Assert.Equal(new Dictionary + { + { "value", convertedValue }, + { "value2", convertedValue } + }, context.Parameters); + } + + public static IEnumerable CanMatchSegmentWithMultipleConstraintsCases() => new object[][] +{ + new object[] { "/{value:double:int}/", "/15", 15 }, + new object[] { "/{value:double?:int?}/", "/", null }, +}; + + [Theory] + [MemberData(nameof(CanMatchSegmentWithMultipleConstraintsCases))] + public void CanMatchSegmentWithMultipleConstraints(string template, string contextUrl, object convertedValue) + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute(template).Build(); + var context = new RouteContext(contextUrl); + + // Act + routeTable.Route(context); + + // Assert + Assert.Equal(new Dictionary + { + { "value", convertedValue } + }, context.Parameters); + } + + [Fact] + public void PrefersLiteralTemplateOverTemplateWithParameters() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/an/awesome/path", typeof(TestHandler1)) + .AddRoute("/{some}/awesome/{route}/", typeof(TestHandler2)) + .Build(); + var context = new RouteContext("/an/awesome/path"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Null(context.Parameters); + } + + [Fact] + public void PrefersLiteralTemplateOverTemplateWithOptionalParameters() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/users/1", typeof(TestHandler1)) + .AddRoute("/users/{id?}", typeof(TestHandler2)) + .Build(); + var context = new RouteContext("/users/1"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Null(context.Parameters); + } + + [Fact] + public void PrefersOptionalParamsOverNonOptionalParams() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/users/{id}", typeof(TestHandler1)) + .AddRoute("/users/{id?}", typeof(TestHandler2)) + .Build(); + var contextWithParam = new RouteContext("/users/1"); + var contextWithoutParam = new RouteContext("/users/"); + + // Act + routeTable.Route(contextWithParam); + routeTable.Route(contextWithoutParam); + + // Assert + Assert.NotNull(contextWithParam.Handler); + Assert.Equal(typeof(TestHandler1), contextWithParam.Handler); + + Assert.NotNull(contextWithoutParam.Handler); + Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler); + } + + [Fact] + public void PrefersOptionalParamsOverNonOptionalParamsReverseOrder() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/users/{id}", typeof(TestHandler1)) + .AddRoute("/users/{id?}", typeof(TestHandler2)) + .Build(); + var contextWithParam = new RouteContext("/users/1"); + var contextWithoutParam = new RouteContext("/users/"); + + // Act + routeTable.Route(contextWithParam); + routeTable.Route(contextWithoutParam); + + // Assert + Assert.NotNull(contextWithParam.Handler); + Assert.Equal(typeof(TestHandler1), contextWithParam.Handler); + + Assert.NotNull(contextWithoutParam.Handler); + Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler); + } + + + [Fact] + public void PrefersLiteralTemplateOverParameterizedTemplates() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/users/1/friends", typeof(TestHandler1)) + .AddRoute("/users/{id}/{location}", typeof(TestHandler2)) + .AddRoute("/users/1/{location}", typeof(TestHandler2)) + .Build(); + var context = new RouteContext("/users/1/friends"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Equal(typeof(TestHandler1), context.Handler); + Assert.Null(context.Parameters); + } + + [Fact] + public void PrefersShorterRoutesOverLongerRoutes() + { + // Arrange & Act + var handler = typeof(int); + var routeTable = new TestRouteTableBuilder() + .AddRoute("/an/awesome/path") + .AddRoute("/an/awesome/", handler).Build(); + + // Act + Assert.Equal("an/awesome", routeTable.Routes[0].Template.TemplateText); + } + + [Fact] + public void PrefersMoreConstraintsOverFewer() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/products/{id}") + .AddRoute("/products/{id:int}").Build(); + var context = new RouteContext("/products/456"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Equal(context.Parameters, new Dictionary + { + { "id", 456 } + }); + } + + [Fact] + public void PrefersRoutesThatMatchMoreSegments() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/{anythingGoes}", typeof(TestHandler1)) + .AddRoute("/users/{id?}", typeof(TestHandler2)) + .Build(); + var context = new RouteContext("/users/1"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Equal(typeof(TestHandler2), context.Handler); + Assert.NotNull(context.Parameters); + } + + [Fact] + public void ProducesAStableOrderForNonAmbiguousRoutes() + { + // Arrange & Act + var handler = typeof(int); + var routeTable = new TestRouteTableBuilder() + .AddRoute("/an/awesome/", handler) + .AddRoute("/a/brilliant/").Build(); + + // Act + Assert.Equal("a/brilliant", routeTable.Routes[0].Template.TemplateText); + } + + [Fact] + public void DoesNotThrowIfStableSortComparesRouteWithItself() + { + // Test for https://github.com/dotnet/aspnetcore/issues/13313 + // Arrange & Act + var builder = new TestRouteTableBuilder(); + builder.AddRoute("r16"); + builder.AddRoute("r05"); + builder.AddRoute("r09"); + builder.AddRoute("r00"); + builder.AddRoute("r13"); + builder.AddRoute("r02"); + builder.AddRoute("r03"); + builder.AddRoute("r10"); + builder.AddRoute("r15"); + builder.AddRoute("r14"); + builder.AddRoute("r12"); + builder.AddRoute("r07"); + builder.AddRoute("r11"); + builder.AddRoute("r08"); + builder.AddRoute("r06"); + builder.AddRoute("r04"); + builder.AddRoute("r01"); + + var routeTable = builder.Build(); + + // Act + Assert.Equal(17, routeTable.Routes.Length); + for (var i = 0; i < 17; i++) + { + var templateText = "r" + i.ToString().PadLeft(2, '0'); + Assert.Equal(templateText, routeTable.Routes[i].Template.TemplateText); + } + } + + [Theory] + [InlineData("/literal", "/Literal/")] + [InlineData("/{parameter}", "/{parameter}/")] + [InlineData("/literal/{parameter}", "/Literal/{something}")] + [InlineData("/{parameter}/literal/{something}", "{param}/Literal/{else}")] + public void DetectsAmbiguousRoutes(string left, string right) + { + // Arrange + var expectedMessage = $@"The following routes are ambiguous: +'{left.Trim('/')}' in '{typeof(object).FullName}' +'{right.Trim('/')}' in '{typeof(object).FullName}' +"; + // Act + var exception = Assert.Throws(() => new TestRouteTableBuilder() + .AddRoute(left) + .AddRoute(right).Build()); + + Assert.Equal(expectedMessage, exception.Message); + } + + [Fact] + public void SuppliesNullForUnusedHandlerParameters() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/", typeof(TestHandler1)) + .AddRoute("/products/{param1:int}", typeof(TestHandler1)) + .AddRoute("/products/{param2}/{PaRam1}", typeof(TestHandler1)) + .AddRoute("/{unrelated}", typeof(TestHandler2)) + .Build(); + var context = new RouteContext("/products/456"); + + // Act + routeTable.Route(context); + + // Assert + Assert.Collection(routeTable.Routes, + route => + { + Assert.Same(typeof(TestHandler1), route.Handler); + Assert.Equal("/", route.Template.TemplateText); + Assert.Equal(new[] { "param1", "param2" }, route.UnusedRouteParameterNames); + }, + route => + { + Assert.Same(typeof(TestHandler2), route.Handler); + Assert.Equal("{unrelated}", route.Template.TemplateText); + Assert.Equal(Array.Empty(), route.UnusedRouteParameterNames); + }, + route => + { + Assert.Same(typeof(TestHandler1), route.Handler); + Assert.Equal("products/{param1:int}", route.Template.TemplateText); + Assert.Equal(new[] { "param2" }, route.UnusedRouteParameterNames); + }, + route => + { + Assert.Same(typeof(TestHandler1), route.Handler); + Assert.Equal("products/{param2}/{PaRam1}", route.Template.TemplateText); + Assert.Equal(Array.Empty(), route.UnusedRouteParameterNames); + }); + Assert.Same(typeof(TestHandler1), context.Handler); + Assert.Equal(new Dictionary + { + { "param1", 456 }, + { "param2", null }, + }, context.Parameters); + } + + private class TestRouteTableBuilder + { + IList<(string Template, Type Handler)> _routeTemplates = new List<(string, Type)>(); + Type _handler = typeof(object); + + public TestRouteTableBuilder AddRoute(string template, Type handler = null) + { + _routeTemplates.Add((template, handler ?? _handler)); + return this; + } + + public LegacyRouteTable Build() + { + try + { + var templatesByHandler = _routeTemplates + .GroupBy(rt => rt.Handler) + .ToDictionary(group => group.Key, group => group.Select(g => g.Template).ToArray()); + return LegacyRouteTableFactory.Create(templatesByHandler); + } + catch (InvalidOperationException ex) when (ex.InnerException is InvalidOperationException) + { + // ToArray() will wrap our exception in its own. + throw ex.InnerException; + } + } + } + + class TestHandler1 { } + class TestHandler2 { } + } +} diff --git a/src/Components/Components/test/LegacyRouteMatching/LegacyTemplateParserTests.cs b/src/Components/Components/test/LegacyRouteMatching/LegacyTemplateParserTests.cs new file mode 100644 index 0000000000..d38b7403a7 --- /dev/null +++ b/src/Components/Components/test/LegacyRouteMatching/LegacyTemplateParserTests.cs @@ -0,0 +1,295 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Components.LegacyRouteMatching +{ + public class LegacyTemplateParserTests + { + [Fact] + public void Parse_SingleLiteral() + { + // Arrange + var expected = new ExpectedTemplateBuilder().Literal("awesome"); + + // Act + var actual = LegacyTemplateParser.ParseTemplate("awesome"); + + // Assert + Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance); + } + + [Fact] + public void Parse_SingleParameter() + { + // Arrange + var template = "{p}"; + + var expected = new ExpectedTemplateBuilder().Parameter("p"); + + // Act + var actual = LegacyTemplateParser.ParseTemplate(template); + + // Assert + Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance); + } + + [Fact] + public void Parse_MultipleLiterals() + { + // Arrange + var template = "awesome/cool/super"; + + var expected = new ExpectedTemplateBuilder().Literal("awesome").Literal("cool").Literal("super"); + + // Act + var actual = LegacyTemplateParser.ParseTemplate(template); + + // Assert + Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance); + } + + [Fact] + public void Parse_MultipleParameters() + { + // Arrange + var template = "{p1}/{p2}/{p3}"; + + var expected = new ExpectedTemplateBuilder().Parameter("p1").Parameter("p2").Parameter("p3"); + + // Act + var actual = LegacyTemplateParser.ParseTemplate(template); + + // Assert + Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance); + } + + [Fact] + public void Parse_MultipleOptionalParameters() + { + // Arrange + var template = "{p1?}/{p2?}/{p3?}"; + + var expected = new ExpectedTemplateBuilder().Parameter("p1?").Parameter("p2?").Parameter("p3?"); + + // Act + var actual = LegacyTemplateParser.ParseTemplate(template); + + // Assert + Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance); + } + + [Fact] + public void Parse_SingleCatchAllParameter() + { + // Arrange + var expected = new ExpectedTemplateBuilder().Parameter("p"); + + // Act + var actual = LegacyTemplateParser.ParseTemplate("{*p}"); + + // Assert + Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance); + } + + [Fact] + public void Parse_MixedLiteralAndCatchAllParameter() + { + // Arrange + var expected = new ExpectedTemplateBuilder().Literal("awesome").Literal("wow").Parameter("p"); + + // Act + var actual = LegacyTemplateParser.ParseTemplate("awesome/wow/{*p}"); + + // Assert + Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance); + } + + [Fact] + public void Parse_MixedLiteralParameterAndCatchAllParameter() + { + // Arrange + var expected = new ExpectedTemplateBuilder().Literal("awesome").Parameter("p1").Parameter("p2"); + + // Act + var actual = LegacyTemplateParser.ParseTemplate("awesome/{p1}/{*p2}"); + + // Assert + Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance); + } + + [Fact] + public void InvalidTemplate_WithRepeatedParameter() + { + var ex = Assert.Throws( + () => LegacyTemplateParser.ParseTemplate("{p1}/literal/{p1}")); + + var expectedMessage = "Invalid template '{p1}/literal/{p1}'. The parameter 'Microsoft.AspNetCore.Components.LegacyRouteMatching.LegacyTemplateSegment' appears multiple times."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Theory] + [InlineData("p}", "Invalid template 'p}'. Missing '{' in parameter segment 'p}'.")] + [InlineData("{p", "Invalid template '{p'. Missing '}' in parameter segment '{p'.")] + [InlineData("Literal/p}", "Invalid template 'Literal/p}'. Missing '{' in parameter segment 'p}'.")] + [InlineData("Literal/{p", "Invalid template 'Literal/{p'. Missing '}' in parameter segment '{p'.")] + [InlineData("p}/Literal", "Invalid template 'p}/Literal'. Missing '{' in parameter segment 'p}'.")] + [InlineData("{p/Literal", "Invalid template '{p/Literal'. Missing '}' in parameter segment '{p'.")] + [InlineData("Another/p}/Literal", "Invalid template 'Another/p}/Literal'. Missing '{' in parameter segment 'p}'.")] + [InlineData("Another/{p/Literal", "Invalid template 'Another/{p/Literal'. Missing '}' in parameter segment '{p'.")] + + public void InvalidTemplate_WithMismatchedBraces(string template, string expectedMessage) + { + var ex = Assert.Throws( + () => LegacyTemplateParser.ParseTemplate(template)); + + Assert.Equal(expectedMessage, ex.Message); + } + + [Theory] + // * is only allowed at beginning for catch-all parameters + [InlineData("{p*}", "Invalid template '{p*}'. The character '*' in parameter segment '{p*}' is not allowed.")] + [InlineData("{{}", "Invalid template '{{}'. The character '{' in parameter segment '{{}' is not allowed.")] + [InlineData("{}}", "Invalid template '{}}'. The character '}' in parameter segment '{}}' is not allowed.")] + [InlineData("{=}", "Invalid template '{=}'. The character '=' in parameter segment '{=}' is not allowed.")] + [InlineData("{.}", "Invalid template '{.}'. The character '.' in parameter segment '{.}' is not allowed.")] + public void ParseRouteParameter_ThrowsIf_ParameterContainsSpecialCharacters(string template, string expectedMessage) + { + // Act & Assert + var ex = Assert.Throws(() => LegacyTemplateParser.ParseTemplate(template)); + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_InvalidParameterNameWithEmptyNameThrows() + { + var ex = Assert.Throws(() => LegacyTemplateParser.ParseTemplate("{a}/{}/{z}")); + + var expectedMessage = "Invalid template '{a}/{}/{z}'. Empty parameter name in segment '{}' is not allowed."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_ConsecutiveSeparatorsSlashSlashThrows() + { + var ex = Assert.Throws(() => LegacyTemplateParser.ParseTemplate("{a}//{z}")); + + var expectedMessage = "Invalid template '{a}//{z}'. Empty segments are not allowed."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_LiteralAfterOptionalParam() + { + var ex = Assert.Throws(() => LegacyTemplateParser.ParseTemplate("/test/{a?}/test")); + + var expectedMessage = "Invalid template 'test/{a?}/test'. Non-optional parameters or literal routes cannot appear after optional parameters."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_NonOptionalParamAfterOptionalParam() + { + var ex = Assert.Throws(() => LegacyTemplateParser.ParseTemplate("/test/{a?}/{b}")); + + var expectedMessage = "Invalid template 'test/{a?}/{b}'. Non-optional parameters or literal routes cannot appear after optional parameters."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_CatchAllParamWithMultipleAsterisks() + { + var ex = Assert.Throws(() => LegacyTemplateParser.ParseTemplate("/test/{a}/{**b}")); + + var expectedMessage = "Invalid template '/test/{a}/{**b}'. A catch-all parameter may only have one '*' at the beginning of the segment."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_CatchAllParamNotLast() + { + var ex = Assert.Throws(() => LegacyTemplateParser.ParseTemplate("/test/{*a}/{b}")); + + var expectedMessage = "Invalid template 'test/{*a}/{b}'. A catch-all parameter can only appear as the last segment of the route template."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_BadOptionalCharacterPosition() + { + var ex = Assert.Throws(() => LegacyTemplateParser.ParseTemplate("/test/{a?bc}/{b}")); + + var expectedMessage = "Malformed parameter 'a?bc' in route '/test/{a?bc}/{b}'. '?' character can only appear at the end of parameter name."; + + Assert.Equal(expectedMessage, ex.Message); + } + + private class ExpectedTemplateBuilder + { + public IList Segments { get; set; } = new List(); + + public ExpectedTemplateBuilder Literal(string value) + { + Segments.Add(new LegacyTemplateSegment("testtemplate", value, isParameter: false)); + return this; + } + + public ExpectedTemplateBuilder Parameter(string value) + { + Segments.Add(new LegacyTemplateSegment("testtemplate", value, isParameter: true)); + return this; + } + + public LegacyRouteTemplate Build() => new LegacyRouteTemplate(string.Join('/', Segments), Segments.ToArray()); + + public static implicit operator LegacyRouteTemplate(ExpectedTemplateBuilder builder) => builder.Build(); + } + + private class LegacyRouteTemplateTestComparer : IEqualityComparer + { + public static LegacyRouteTemplateTestComparer Instance { get; } = new LegacyRouteTemplateTestComparer(); + + public bool Equals(LegacyRouteTemplate x, LegacyRouteTemplate y) + { + if (x.Segments.Length != y.Segments.Length) + { + return false; + } + + for (var i = 0; i < x.Segments.Length; i++) + { + var xSegment = x.Segments[i]; + var ySegment = y.Segments[i]; + if (xSegment.IsParameter != ySegment.IsParameter) + { + return false; + } + if (xSegment.IsOptional != ySegment.IsOptional) + { + return false; + } + if (!string.Equals(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + public int GetHashCode(LegacyRouteTemplate obj) => 0; + } + } +} diff --git a/src/Components/Components/test/Routing/RouteConstraintTest.cs b/src/Components/Components/test/Routing/RouteConstraintTest.cs index 15216503e7..34889f03dd 100644 --- a/src/Components/Components/test/Routing/RouteConstraintTest.cs +++ b/src/Components/Components/test/Routing/RouteConstraintTest.cs @@ -32,15 +32,5 @@ namespace Microsoft.AspNetCore.Components.Routing // Assert Assert.Same(original, another); } - - [Fact] - public void Parse_DoesNotThrowIfOptionalConstraint() - { - // Act - var exceptions = Record.Exception(() => RouteConstraint.Parse("ignore", "ignore", "int?")); - - // Assert - Assert.Null(exceptions); - } } } diff --git a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs index 3a4887fba0..53fa772b40 100644 --- a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs +++ b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs @@ -325,6 +325,253 @@ namespace Microsoft.AspNetCore.Components.Test.Routing }, context.Parameters); } + [Fact] + public void MoreSpecificRoutesPrecedeMoreGeneralRoutes() + { + // Arrange + + // Routes are added in reverse precedence order + var builder = new TestRouteTableBuilder() + .AddRoute("/{*last}") + .AddRoute("/{*last:int}") + .AddRoute("/{last}") + .AddRoute("/{last:int}") + .AddRoute("/literal") + .AddRoute("/literal/{*last}") + .AddRoute("/literal/{*last:int}") + .AddRoute("/literal/{last}") + .AddRoute("/literal/{last:int}") + .AddRoute("/literal/literal"); + + var expectedOrder = new[] + { + "literal", + "literal/literal", + "literal/{last:int}", + "literal/{last}", + "literal/{*last:int}", + "literal/{*last}", + "{last:int}", + "{last}", + "{*last:int}", + "{*last}", + }; + + // Act + var table = builder.Build(); + + // Assert + var tableTemplates = table.Routes.Select(p => p.Template.TemplateText).ToArray(); + Assert.Equal(expectedOrder, tableTemplates); + } + + [Theory] + [InlineData("literal", null, "literal", "literal/{parameter?}", typeof(TestHandler1))] + [InlineData("literal/value", "value", "literal", "literal/{parameter?}", typeof(TestHandler2))] + [InlineData("literal", null, "literal/{parameter?}", "literal/{*parameter}", typeof(TestHandler1))] + [InlineData("literal/value", "value", "literal/{parameter?}", "literal/{*parameter}", typeof(TestHandler1))] + [InlineData("literal/value/other", "value/other", "literal /{parameter?}", "literal/{*parameter}", typeof(TestHandler2))] + public void CorrectlyMatchesVariableLengthSegments(string path, string expectedValue, string first, string second, Type handler) + { + // Arrange + + // Routes are added in reverse precedence order + var table = new TestRouteTableBuilder() + .AddRoute(first, typeof(TestHandler1)) + .AddRoute(second, typeof(TestHandler2)) + .Build(); + + var context = new RouteContext(path); + + // Act + table.Route(context); + + // Assert + Assert.Equal(handler, context.Handler); + var value = expectedValue != null ? Assert.Single(context.Parameters, p => p.Key == "parameter").Value : null; + Assert.Equal(expectedValue, value?.ToString()); + } + + [Theory] + [InlineData("/values/{*values:int}", "values/1/2/3/4/5")] + [InlineData("/{*values:int}", "1/2/3/4/5")] + public void CanMatchCatchAllParametersWithConstraints(string template, string path) + { + // Arrange + + // Routes are added in reverse precedence order + var table = new TestRouteTableBuilder() + .AddRoute(template) + .Build(); + + var context = new RouteContext(path); + + // Act + table.Route(context); + + // Assert + Assert.True(context.Parameters.TryGetValue("values", out var values)); + Assert.Equal("1/2/3/4/5", values); + } + + + [Fact] + public void CatchAllEmpty() + { + // Arrange + + // Routes are added in reverse precedence order + var table = new TestRouteTableBuilder() + .AddRoute("{*catchall}") + .Build(); + + var context = new RouteContext("/"); + + // Act + table.Route(context); + + // Assert + Assert.True(context.Parameters.TryGetValue("catchall", out var values)); + Assert.Null(values); + } + + [Fact] + public void OptionalParameterEmpty() + { + // Arrange + + // Routes are added in reverse precedence order + var table = new TestRouteTableBuilder() + .AddRoute("{parameter?}") + .Build(); + + var context = new RouteContext("/"); + + // Act + table.Route(context); + + // Assert + Assert.True(context.Parameters.TryGetValue("parameter", out var values)); + Assert.Null(values); + } + + [Theory] + [InlineData("", 0)] + [InlineData("1", 1)] + [InlineData("1/2", 2)] + [InlineData("1/2/3", 3)] + public void MultipleOptionalParameters(string path, int segments) + { + // Arrange + + // Routes are added in reverse precedence order + var table = new TestRouteTableBuilder() + .AddRoute("{param1?}/{param2?}/{param3?}") + .Build(); + + var context = new RouteContext(path); + + // Act + table.Route(context); + + // Assert + for (int i = 1; i <= segments; i++) + { + // Segments present in the path have the corresponding value. + Assert.True(context.Parameters.TryGetValue($"param{i}", out var value)); + Assert.Equal(i.ToString(), value); + } + for (int i = segments + 1; i <= 3; i++) + { + // Segments omitted in the path have the default null value. + Assert.True(context.Parameters.TryGetValue($"param{i}", out var value)); + Assert.Null(value); + } + } + + [Theory] + [InlineData("prefix/", 0)] + [InlineData("prefix/1", 1)] + [InlineData("prefix/1/2", 2)] + [InlineData("prefix/1/2/3", 3)] + public void MultipleOptionalParametersWithPrefix(string path, int segments) + { + // Arrange + + // Routes are added in reverse precedence order + var table = new TestRouteTableBuilder() + .AddRoute("prefix/{param1?}/{param2?}/{param3?}") + .Build(); + + var context = new RouteContext(path); + + // Act + table.Route(context); + + // Assert + for (int i = 1; i <= segments; i++) + { + // Segments present in the path have the corresponding value. + Assert.True(context.Parameters.TryGetValue($"param{i}", out var value)); + Assert.Equal(i.ToString(), value); + } + for (int i = segments + 1; i <= 3; i++) + { + // Segments omitted in the path have the default null value. + Assert.True(context.Parameters.TryGetValue($"param{i}", out var value)); + Assert.Null(value); + } + } + + [Theory] + [InlineData("/{parameter?}/{*catchAll}", "/", null, null)] + [InlineData("/{parameter?}/{*catchAll}", "/parameter", "parameter", null)] + [InlineData("/{parameter?}/{*catchAll}", "/value/1", "value", "1")] + [InlineData("/{parameter?}/{*catchAll}", "/value/1/2/3/4/5", "value", "1/2/3/4/5")] + [InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/", null, null)] + [InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/parameter", "parameter", null)] + [InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/value/1", "value", "1")] + [InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/value/1/2/3/4/5", "value", "1/2/3/4/5")] + public void OptionalParameterPlusCatchAllRoute(string template, string path, string parameterValue, string catchAllValue) + { + // Arrange + + // Routes are added in reverse precedence order + var table = new TestRouteTableBuilder() + .AddRoute(template) + .Build(); + + var context = new RouteContext(path); + + // Act + table.Route(context); + + // Assert + Assert.True(context.Parameters.TryGetValue("parameter", out var parameter)); + Assert.True(context.Parameters.TryGetValue("catchAll", out var catchAll)); + Assert.Equal(parameterValue, parameter); + Assert.Equal(catchAllValue, catchAll); + } + + [Fact] + public void CanMatchCatchAllParametersWithConstraints_NotMatchingRoute() + { + // Arrange + + // Routes are added in reverse precedence order + var table = new TestRouteTableBuilder() + .AddRoute("/values/{*values:int}") + .Build(); + + var context = new RouteContext("values/1/2/3/4/5/A"); + + // Act + table.Route(context); + + // Assert + Assert.Null(context.Handler); + } + [Fact] public void CanMatchOptionalParameterWithoutConstraints() { @@ -411,7 +658,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing public static IEnumerable CanMatchSegmentWithMultipleConstraintsCases() => new object[][] { new object[] { "/{value:double:int}/", "/15", 15 }, - new object[] { "/{value:double?:int?}/", "/", null }, + new object[] { "/{value:double:int?}/", "/", null }, }; [Theory] @@ -469,52 +716,111 @@ namespace Microsoft.AspNetCore.Components.Test.Routing } [Fact] - public void PrefersOptionalParamsOverNonOptionalParams() + public void ThrowsForOptionalParametersAndNonOptionalParameters() { - // Arrange - var routeTable = new TestRouteTableBuilder() + // Arrange, act & assert + Assert.Throws(() => new TestRouteTableBuilder() .AddRoute("/users/{id}", typeof(TestHandler1)) .AddRoute("/users/{id?}", typeof(TestHandler2)) - .Build(); - var contextWithParam = new RouteContext("/users/1"); - var contextWithoutParam = new RouteContext("/users/"); + .Build()); + } + + [Theory] + [InlineData("{*catchall}/literal")] + [InlineData("{*catchall}/{parameter}")] + [InlineData("{*catchall}/{parameter?}")] + [InlineData("{*catchall}/{*other}")] + [InlineData("prefix/{*catchall}/literal")] + [InlineData("prefix/{*catchall}/{parameter}")] + [InlineData("prefix/{*catchall}/{parameter?}")] + [InlineData("prefix/{*catchall}/{*other}")] + public void ThrowsWhenCatchAllIsNotTheLastSegment(string template) + { + // Arrange, act & assert + Assert.Throws(() => new TestRouteTableBuilder() + .AddRoute(template) + .Build()); + } + + [Theory] + [InlineData("{optional?}/literal")] + [InlineData("{optional?}/{parameter}")] + [InlineData("{optional?}/{parameter:int}")] + [InlineData("prefix/{optional?}/literal")] + [InlineData("prefix/{optional?}/{parameter}")] + [InlineData("prefix/{optional?}/{parameter:int}")] + public void ThrowsForOptionalParametersFollowedByNonOptionalParameters(string template) + { + // Arrange, act & assert + Assert.Throws(() => new TestRouteTableBuilder() + .AddRoute(template) + .Build()); + } + + [Theory] + [InlineData("{parameter}", "{parameter?}")] + [InlineData("{parameter:int}", "{parameter:bool?}")] + public void ThrowsForAmbiguousRoutes(string first, string second) + { + // Arrange, act & assert + var exception = Assert.Throws(() => new TestRouteTableBuilder() + .AddRoute(first, typeof(TestHandler1)) + .AddRoute(second, typeof(TestHandler2)) + .Build()); + + exception.Message.Contains("The following routes are ambiguous"); + } + + // It's important the precedence is inverted here to also validate that + // the precedence is correct in these cases + [Theory] + [InlineData("{optional?}", "/")] + [InlineData("{optional?}", "literal")] + [InlineData("{optional?}", "{optional:int?}")] + [InlineData("{*catchAll:int}", "{optional?}")] + [InlineData("{*catchAll}", "{optional?}")] + [InlineData("literal/{optional?}", "/")] + [InlineData("literal/{optional?}", "literal")] + [InlineData("literal/{optional?}", "literal/{optional:int?}")] + [InlineData("literal/{*catchAll:int}", "literal/{optional?}")] + [InlineData("literal/{*catchAll}", "literal/{optional?}")] + [InlineData("{param}/{optional?}", "/")] + [InlineData("{param}/{optional?}", "{param}")] + [InlineData("{param}/{optional?}", "{param}/{optional:int?}")] + [InlineData("{param}/{*catchAll:int}", "{param}/{optional?}")] + [InlineData("{param}/{*catchAll}", "{param}/{optional?}")] + [InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "/")] + [InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "{param1?}/{param2?}/{param3?}/{optional:int?}")] + [InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "{param1?}/{param2?}/{param3:int?}/{optional?}")] + [InlineData("{param1?}/{param2?}/{param3:int?}/{optional?}", "{param1?}/{param2?}")] + [InlineData("{param1?}/{param2?}/{param3?}/{*catchAll:int}", "{param1?}/{param2?}/{param3?}/{optional?}")] + [InlineData("{param1?}/{param2?}/{param3?}/{*catchAll}", "{param1?}/{param2?}/{param3?}/{optional?}")] + public void DoesNotThrowForNonAmbiguousRoutes(string first, string second) + { + // Arrange + var builder = new TestRouteTableBuilder() + .AddRoute(first, typeof(TestHandler1)) + .AddRoute(second, typeof(TestHandler2)); + + var expectedOrder = new[] { second, first }; // Act - routeTable.Route(contextWithParam); - routeTable.Route(contextWithoutParam); + var table = builder.Build(); // Assert - Assert.NotNull(contextWithParam.Handler); - Assert.Equal(typeof(TestHandler1), contextWithParam.Handler); - - Assert.NotNull(contextWithoutParam.Handler); - Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler); + var tableTemplates = table.Routes.Select(p => p.Template.TemplateText).ToArray(); + Assert.Equal(expectedOrder, tableTemplates); } [Fact] - public void PrefersOptionalParamsOverNonOptionalParamsReverseOrder() + public void ThrowsForLiteralWithQuestionMark() { - // Arrange - var routeTable = new TestRouteTableBuilder() - .AddRoute("/users/{id}", typeof(TestHandler1)) - .AddRoute("/users/{id?}", typeof(TestHandler2)) - .Build(); - var contextWithParam = new RouteContext("/users/1"); - var contextWithoutParam = new RouteContext("/users/"); - - // Act - routeTable.Route(contextWithParam); - routeTable.Route(contextWithoutParam); - - // Assert - Assert.NotNull(contextWithParam.Handler); - Assert.Equal(typeof(TestHandler1), contextWithParam.Handler); - - Assert.NotNull(contextWithoutParam.Handler); - Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler); + // Arrange, act & assert + Assert.Throws(() => new TestRouteTableBuilder() + .AddRoute("literal?") + .Build()); } - [Fact] public void PrefersLiteralTemplateOverParameterizedTemplates() { @@ -660,10 +966,10 @@ namespace Microsoft.AspNetCore.Components.Test.Routing { // Arrange var routeTable = new TestRouteTableBuilder() - .AddRoute("/", typeof(TestHandler1)) - .AddRoute("/products/{param1:int}", typeof(TestHandler1)) - .AddRoute("/products/{param2}/{PaRam1}", typeof(TestHandler1)) .AddRoute("/{unrelated}", typeof(TestHandler2)) + .AddRoute("/products/{param2}/{PaRam1}", typeof(TestHandler1)) + .AddRoute("/products/{param1:int}", typeof(TestHandler1)) + .AddRoute("/", typeof(TestHandler1)) .Build(); var context = new RouteContext("/products/456"); @@ -676,26 +982,27 @@ namespace Microsoft.AspNetCore.Components.Test.Routing { Assert.Same(typeof(TestHandler1), route.Handler); Assert.Equal("/", route.Template.TemplateText); - Assert.Equal(new[] { "param1", "param2" }, route.UnusedRouteParameterNames); - }, - route => - { - Assert.Same(typeof(TestHandler2), route.Handler); - Assert.Equal("{unrelated}", route.Template.TemplateText); - Assert.Equal(Array.Empty(), route.UnusedRouteParameterNames); + Assert.Equal(new[] { "PaRam1", "param2" }, route.UnusedRouteParameterNames.OrderBy(id => id).ToArray()); }, route => { Assert.Same(typeof(TestHandler1), route.Handler); Assert.Equal("products/{param1:int}", route.Template.TemplateText); - Assert.Equal(new[] { "param2" }, route.UnusedRouteParameterNames); + Assert.Equal(new[] { "param2" }, route.UnusedRouteParameterNames.OrderBy(id => id).ToArray()); }, route => { Assert.Same(typeof(TestHandler1), route.Handler); Assert.Equal("products/{param2}/{PaRam1}", route.Template.TemplateText); - Assert.Equal(Array.Empty(), route.UnusedRouteParameterNames); + Assert.Equal(Array.Empty(), route.UnusedRouteParameterNames.OrderBy(id => id).ToArray()); + }, + route => + { + Assert.Same(typeof(TestHandler2), route.Handler); + Assert.Equal("{unrelated}", route.Template.TemplateText); + Assert.Equal(Array.Empty(), route.UnusedRouteParameterNames.OrderBy(id => id).ToArray()); }); + Assert.Same(typeof(TestHandler1), context.Handler); Assert.Equal(new Dictionary { diff --git a/src/Components/Components/test/Routing/RouterTest.cs b/src/Components/Components/test/Routing/RouterTest.cs index 29da11476b..fd56868d73 100644 --- a/src/Components/Components/test/Routing/RouterTest.cs +++ b/src/Components/Components/test/Routing/RouterTest.cs @@ -2,9 +2,12 @@ // 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.Reflection; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.Extensions.DependencyInjection; @@ -17,13 +20,15 @@ namespace Microsoft.AspNetCore.Components.Test.Routing public class RouterTest { private readonly Router _router; + private readonly TestNavigationManager _navigationManager; private readonly TestRenderer _renderer; public RouterTest() { var services = new ServiceCollection(); + _navigationManager = new TestNavigationManager(); services.AddSingleton(NullLoggerFactory.Instance); - services.AddSingleton(); + services.AddSingleton(_navigationManager); services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); @@ -31,7 +36,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing _renderer.ShouldHandleExceptions = true; _router = (Router)_renderer.InstantiateComponent(); _router.AppAssembly = Assembly.GetExecutingAssembly(); - _router.Found = routeData => (builder) => builder.AddContent(0, "Rendering route..."); + _router.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}"); _renderer.AssignRootComponentId(_router); } @@ -177,12 +182,65 @@ namespace Microsoft.AspNetCore.Components.Test.Routing await feb; } + [Fact] + public async Task UsesLegacyRouteMatchingByDefault() + { + // Arrange + // Legacy routing prefers {*someWildcard} over any other pattern than has more segments, + // even if the other pattern is an exact match + _navigationManager.NotifyLocationChanged("https://www.example.com/subdir/a/b", false); + var parameters = new Dictionary + { + { nameof(Router.AppAssembly), typeof(RouterTest).Assembly }, + { nameof(Router.NotFound), (RenderFragment)(builder => { }) }, + }; + + // Act + await _renderer.Dispatcher.InvokeAsync(() => + _router.SetParametersAsync(ParameterView.FromDictionary(parameters))); + + // Assert + var renderedFrame = _renderer.Batches.First().ReferenceFrames.First(); + Assert.Equal(RenderTreeFrameType.Text, renderedFrame.FrameType); + Assert.Equal($"Rendering route matching {typeof(MatchAnythingComponent)}", renderedFrame.TextContent); + } + + [Fact] + public async Task UsesCurrentRouteMatchingIfSpecified() + { + // Arrange + // Current routing prefers exactly-matched patterns over {*someWildcard}, no matter + // how many segments are in the exact match + _navigationManager.NotifyLocationChanged("https://www.example.com/subdir/a/b", false); + var parameters = new Dictionary + { + { nameof(Router.AppAssembly), typeof(RouterTest).Assembly }, + { nameof(Router.NotFound), (RenderFragment)(builder => { }) }, + { nameof(Router.PreferExactMatches), true }, + }; + + // Act + await _renderer.Dispatcher.InvokeAsync(() => + _router.SetParametersAsync(ParameterView.FromDictionary(parameters))); + + // Assert + var renderedFrame = _renderer.Batches.First().ReferenceFrames.First(); + Assert.Equal(RenderTreeFrameType.Text, renderedFrame.FrameType); + Assert.Equal($"Rendering route matching {typeof(MultiSegmentRouteComponent)}", renderedFrame.TextContent); + } + internal class TestNavigationManager : NavigationManager { public TestNavigationManager() => Initialize("https://www.example.com/subdir/", "https://www.example.com/subdir/jan"); protected override void NavigateToCore(string uri, bool forceLoad) => throw new NotImplementedException(); + + public void NotifyLocationChanged(string uri, bool intercepted) + { + Uri = uri; + NotifyLocationChanged(intercepted); + } } internal sealed class TestNavigationInterception : INavigationInterception @@ -200,5 +258,11 @@ namespace Microsoft.AspNetCore.Components.Test.Routing [Route("jan")] public class JanComponent : ComponentBase { } + + [Route("{*matchAnything}")] + public class MatchAnythingComponent : ComponentBase { } + + [Route("a/b")] + public class MultiSegmentRouteComponent : ComponentBase { } } } diff --git a/src/Components/Components/test/Routing/TemplateParserTests.cs b/src/Components/Components/test/Routing/TemplateParserTests.cs index 1cd8ab88bf..82e7ad72fa 100644 --- a/src/Components/Components/test/Routing/TemplateParserTests.cs +++ b/src/Components/Components/test/Routing/TemplateParserTests.cs @@ -128,7 +128,7 @@ namespace Microsoft.AspNetCore.Components.Routing var ex = Assert.Throws( () => TemplateParser.ParseTemplate("{p1}/literal/{p1}")); - var expectedMessage = "Invalid template '{p1}/literal/{p1}'. The parameter 'Microsoft.AspNetCore.Components.Routing.TemplateSegment' appears multiple times."; + var expectedMessage = "Invalid template '{p1}/literal/{p1}'. The parameter '{p1}' appears multiple times."; Assert.Equal(expectedMessage, ex.Message); } diff --git a/src/Components/Samples/BlazorServerApp/App.razor b/src/Components/Samples/BlazorServerApp/App.razor index 1c360b7121..9dcf59800a 100644 --- a/src/Components/Samples/BlazorServerApp/App.razor +++ b/src/Components/Samples/BlazorServerApp/App.razor @@ -1,4 +1,4 @@ - + diff --git a/src/Components/WebAssembly/Sdk/testassets/blazorwasm-minimal/App.razor b/src/Components/WebAssembly/Sdk/testassets/blazorwasm-minimal/App.razor index eba23da9b5..13f3043f0c 100644 --- a/src/Components/WebAssembly/Sdk/testassets/blazorwasm-minimal/App.razor +++ b/src/Components/WebAssembly/Sdk/testassets/blazorwasm-minimal/App.razor @@ -1,4 +1,4 @@ - + diff --git a/src/Components/WebAssembly/Sdk/testassets/blazorwasm/App.razor b/src/Components/WebAssembly/Sdk/testassets/blazorwasm/App.razor index eba23da9b5..13f3043f0c 100644 --- a/src/Components/WebAssembly/Sdk/testassets/blazorwasm/App.razor +++ b/src/Components/WebAssembly/Sdk/testassets/blazorwasm/App.razor @@ -1,4 +1,4 @@ - + diff --git a/src/Components/WebAssembly/testassets/StandaloneApp/App.razor b/src/Components/WebAssembly/testassets/StandaloneApp/App.razor index 614e22ceb4..8ace1f34be 100644 --- a/src/Components/WebAssembly/testassets/StandaloneApp/App.razor +++ b/src/Components/WebAssembly/testassets/StandaloneApp/App.razor @@ -1,4 +1,4 @@ - + diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/App.razor b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/App.razor index a298515d5a..01446d9d71 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/App.razor +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/App.razor @@ -1,5 +1,5 @@  - + diff --git a/src/Components/benchmarkapps/BlazingPizza.Server/App.razor b/src/Components/benchmarkapps/BlazingPizza.Server/App.razor index 33fa47ea70..fad248f325 100644 --- a/src/Components/benchmarkapps/BlazingPizza.Server/App.razor +++ b/src/Components/benchmarkapps/BlazingPizza.Server/App.razor @@ -1,4 +1,4 @@ - + Page not found diff --git a/src/Components/benchmarkapps/Wasm.Performance/TestApp/App.razor b/src/Components/benchmarkapps/Wasm.Performance/TestApp/App.razor index 406b00a1f0..66a1f641be 100644 --- a/src/Components/benchmarkapps/Wasm.Performance/TestApp/App.razor +++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/App.razor @@ -1,4 +1,4 @@ - + diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor index c6a3a8fe9d..d1e07e0f3b 100644 --- a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor @@ -9,7 +9,7 @@ and @page authorization rules. *@ - + Authorizing... diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor index 1506433462..5d2fe94ca6 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor @@ -1,5 +1,5 @@ @using Microsoft.AspNetCore.Components.Routing - + diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithAdditionalAssembly.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithAdditionalAssembly.razor index 7e18c960c7..b41085551e 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithAdditionalAssembly.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithAdditionalAssembly.razor @@ -1,5 +1,5 @@ @using Microsoft.AspNetCore.Components.Routing - + diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithLazyAssembly.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithLazyAssembly.razor index ccdd22f2b5..2c4edfc337 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithLazyAssembly.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithLazyAssembly.razor @@ -4,7 +4,7 @@ @inject LazyAssemblyLoader lazyLoader - +

Loading the requested page...

diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithOnNavigate.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithOnNavigate.razor index 933512d2bf..51e6f5ac98 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithOnNavigate.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithOnNavigate.razor @@ -4,7 +4,7 @@ - +

Loading the requested page...

diff --git a/src/Components/test/testassets/ComponentsApp.App/App.razor b/src/Components/test/testassets/ComponentsApp.App/App.razor index 128b9d99fc..e29d2cfddb 100644 --- a/src/Components/test/testassets/ComponentsApp.App/App.razor +++ b/src/Components/test/testassets/ComponentsApp.App/App.razor @@ -1,6 +1,6 @@ @using Microsoft.AspNetCore.Components; - + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/App.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/App.razor index 7b58ea096f..a7210c9197 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/App.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/App.razor @@ -1,5 +1,5 @@ @*#if (NoAuth) - + @@ -11,7 +11,7 @@ #else - + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/App.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/App.razor index 48da6e96c9..f0d75aaa2a 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/App.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/App.razor @@ -1,5 +1,5 @@ @*#if (NoAuth) - + @@ -11,7 +11,7 @@ #else - +