diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..79ec685f40d9 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 000000000000..15f5e51dddcf --- /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 dcd0238ea4ff..74cde946ab85 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 000000000000..caad4b906862 --- /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 000000000000..9aefb6876afa --- /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 000000000000..de8fc9ef9048 --- /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 000000000000..a87f5adc9d1e --- /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 000000000000..a033d8ddff3b --- /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 000000000000..c9312947f2ce --- /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 000000000000..afe93a840f89 --- /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 000000000000..47a9777a459a --- /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 97ae19580f49..babfaf64dd3e 100644 --- a/src/Components/Components/src/Routing/RouteConstraint.cs +++ b/src/Components/Components/src/Routing/RouteConstraint.cs @@ -51,9 +51,6 @@ public static RouteConstraint Parse(string template, string segment, string cons /// 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 @@ public static RouteConstraint Parse(string template, string segment, string cons { 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 e18b71f0ef9c..e25ef985d068 100644 --- a/src/Components/Components/src/Routing/RouteEntry.cs +++ b/src/Components/Components/src/Routing/RouteEntry.cs @@ -29,116 +29,122 @@ public RouteEntry(RouteTemplate template, Type handler, string[] unusedRoutePara 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) + var matches = templateSegment.Match(pathSegment, out var match); + if (!matches) { - numMatchingSegments += 1; - parameters ??= new Dictionary(StringComparer.Ordinal); - parameters[segment.Value] = catchAllValue; - break; + // A constraint or literal didn't match + return; } - // 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 (!templateSegment.IsCatchAll) { - // If we are under condition (1) above then we can stop evaluating - // matches on the rest of this template. - if (!segment.IsParameter && !segment.IsOptional) + // We were dealing with a literal or a parameter, so just advance both cursors. + pathIndex++; + templateIndex++; + + if (templateSegment.IsParameter) { - break; + parameters ??= new(StringComparer.OrdinalIgnoreCase); + parameters[templateSegment.Value] = match; } } - - string pathSegment = null; - if (i < context.Segments.Length) + else { - pathSegment = context.Segments[i]; + if (templateSegment.Constraints.Length == 0) + { + + // 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++; + } + } } + } + + var hasRemainingOptionalSegments = templateIndex < Template.Segments.Length && + RemainingSegmentsAreOptional(pathIndex, Template.Segments); - if (!segment.Match(pathSegment, out var matchedParameterValue)) + if ((pathIndex == context.Segments.Length && templateIndex == Template.Segments.Length) || hasRemainingOptionalSegments) + { + if (hasRemainingOptionalSegments) { - return; + parameters ??= new Dictionary(StringComparer.Ordinal); + AddDefaultValues(parameters, templateIndex, Template.Segments); } - else + if (UnusedRouteParameterNames?.Length > 0) { - numMatchingSegments++; - if (segment.IsParameter) + parameters ??= new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < UnusedRouteParameterNames.Length; i++) { - parameters ??= new Dictionary(StringComparer.Ordinal); - parameters[segment.Value] = matchedParameterValue; + parameters[UnusedRouteParameterNames[i]] = null; } } + context.Handler = Handler; + context.Parameters = parameters; } + } - // 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) + private void AddDefaultValues(Dictionary parameters, int templateIndex, TemplateSegment[] segments) + { + for (var i = templateIndex; i < segments.Length; i++) { - parameters ??= new Dictionary(StringComparer.Ordinal); - for (var i = 0; i < UnusedRouteParameterNames.Length; i++) - { - parameters[UnusedRouteParameterNames[i]] = null; - } + var currentSegment = segments[i]; + parameters[currentSegment.Value] = 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)) + private bool RemainingSegmentsAreOptional(int index, TemplateSegment[] segments) + { + for (var i = index; index < segments.Length - 1; index++) { - context.Parameters = parameters; - context.Handler = Handler; + if (!segments[i].IsOptional) + { + return false; + } } + + 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 029bc476577a..0daa00ec02ce 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 @@ public RouteTable(RouteEntry[] routes) 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 4f66d660f7b0..8b92d7d22a8b 100644 --- a/src/Components/Components/src/Routing/RouteTableFactory.cs +++ b/src/Components/Components/src/Routing/RouteTableFactory.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Reflection; using Microsoft.AspNetCore.Components.Routing; -using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Components { @@ -121,62 +120,63 @@ internal static int RouteComparison(RouteEntry x, RouteEntry y) 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; - } + currentResult = StringComparer.OrdinalIgnoreCase.Compare(xSegment.Value, ySegment.Value); + } - 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; - } - } + 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 569a7061b182..efee3c4e767b 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -12,6 +12,7 @@ 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 @@ static readonly ReadOnlyDictionary _emptyParametersDictionary /// [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 @@ private void RefreshRouteTable() 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 41bc8ede28d8..1f4f1bbb1f89 100644 --- a/src/Components/Components/src/Routing/TemplateParser.cs +++ b/src/Components/Components/src/Routing/TemplateParser.cs @@ -50,6 +50,11 @@ internal static RouteTemplate ParseTemplate(string template) 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 @@ internal static RouteTemplate ParseTemplate(string template) { 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 c4d351995199..fb2c03ec5ca2 100644 --- a/src/Components/Components/src/Routing/TemplateSegment.cs +++ b/src/Components/Components/src/Routing/TemplateSegment.cs @@ -12,15 +12,15 @@ public TemplateSegment(string template, string segment, bool isParameter) { 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 @@ public TemplateSegment(string template, string segment, bool isParameter) 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); + + // 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(); } - // 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) + else { - throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name."); - } + 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."); + } - Constraints = Array.Empty(); + 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 @@ public TemplateSegment(string template, string segment, bool isParameter) public bool IsParameter { get; } - public bool IsOptional { get; } + public bool IsOptional { get; } public bool IsCatchAll { get; } @@ -119,5 +131,17 @@ public bool Match(string pathSegment, out object? matchedParameterValue) 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 11aa9f749b51..1e2f9d3c1795 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 @@ public override bool Match(string pathSegment, out object? convertedValue) 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 000000000000..501f2ea1d8a1 --- /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 000000000000..48e3931d5d14 --- /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 000000000000..d38b7403a7b6 --- /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 15216503e7e5..34889f03dd46 100644 --- a/src/Components/Components/test/Routing/RouteConstraintTest.cs +++ b/src/Components/Components/test/Routing/RouteConstraintTest.cs @@ -32,15 +32,5 @@ public void Parse_CachesCreatedConstraint_ForSameKind() // 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 3a4887fba090..53fa772b400f 100644 --- a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs +++ b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs @@ -325,6 +325,253 @@ public void CanMatchParameterWithConstraint(string template, string contextUrl, }, 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 @@ public void CanMatchMultipleOptionalParameterWithConstraint() 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,51 +716,110 @@ public void PrefersLiteralTemplateOverTemplateWithOptionalParameters() } [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()); + } - // Act - routeTable.Route(contextWithParam); - routeTable.Route(contextWithoutParam); + [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()); + } - // Assert - Assert.NotNull(contextWithParam.Handler); - Assert.Equal(typeof(TestHandler1), contextWithParam.Handler); + [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()); + } - Assert.NotNull(contextWithoutParam.Handler); - Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler); + [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"); } - [Fact] - public void PrefersOptionalParamsOverNonOptionalParamsReverseOrder() + // 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 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/"); + 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 ThrowsForLiteralWithQuestionMark() + { + // Arrange, act & assert + Assert.Throws(() => new TestRouteTableBuilder() + .AddRoute("literal?") + .Build()); + } [Fact] public void PrefersLiteralTemplateOverParameterizedTemplates() @@ -660,10 +966,10 @@ 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)) + .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 @@ public void SuppliesNullForUnusedHandlerParameters() { 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 29da11476bf9..fd56868d738a 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 @@ public RouterTest() _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 @@ public async Task RefreshesOnceOnCancelledOnNavigateAsync() 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 @@ public class FebComponent : ComponentBase { } [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 1cd8ab88bf47..82e7ad72fae6 100644 --- a/src/Components/Components/test/Routing/TemplateParserTests.cs +++ b/src/Components/Components/test/Routing/TemplateParserTests.cs @@ -128,7 +128,7 @@ public void InvalidTemplate_WithRepeatedParameter() 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 1c360b7121a7..9dcf59800af3 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 eba23da9b5ae..13f3043f0c49 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 eba23da9b5ae..13f3043f0c49 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 614e22ceb44a..8ace1f34be9e 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 a298515d5ae6..01446d9d713a 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 33fa47ea70c8..fad248f32518 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 406b00a1f095..66a1f641be13 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 c6a3a8fe9d1c..d1e07e0f3b8b 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 1506433462e2..5d2fe94ca636 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 7e18c960c716..b41085551e64 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 ccdd22f2b5d4..2c4edfc337dd 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 933512d2bf5b..51e6f5ac98eb 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 128b9d99fcc0..e29d2cfddb98 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 7b58ea096f72..a7210c919736 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 48da6e96c988..f0d75aaa2aae 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 - +