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