[Blazor] Fixes issues with route precedence (#27907)

Description
In 5.0 we introduced two features on Blazor routing that enable users to write routing templates that match paths with variable length segments. These two features are optional parameters {parameter?} and catch all parameters {*catchall}.

Our routing system ordered the routes based on precedence and the (now false) assumption that route templates would only match paths with an equal number of segments.

The implementation that we have worked for naïve scenarios but breaks on more real world scenarios. The change here includes fixes to the way we order the routes in the route table to match the expectations as well as fixes on the route matching algorithm to ensure we match routes with variable number of segments correctly.

Customer Impact
This was reported by customers on #27250

The impact is that a route with {*catchall} will prevent more specific routes like /page/{parameter} from being accessible.

There are no workarounds since precedence is a fundamental behavior of the routing system.

Regression?
No, these Blazor features were initially added in 5.0.

Risk
Low. These two features were just introduced in 5.0 and their usage is not as prevalent as in asp.net core routing. That said, it's important to fix them as otherwise we run the risk of diverting in behavior from asp.net core routing and Blazor routing, which is not something we want to do.

We have functional tests covering the area and we've added a significant amount of unit tests to validate the changes.
This commit is contained in:
Javier Calvarro Nelson 2020-11-20 20:43:11 +01:00 committed by GitHub
parent d14b644036
commit 6bb4a3f370
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 2616 additions and 287 deletions

View File

@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool
Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void

View File

@ -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
{
/// <summary>
/// Provides an abstraction over <see cref="RouteTable"/> and <see cref="LegacyRouteMatching.LegacyRouteTable"/>.
/// This is only an internal implementation detail of <see cref="Router"/> and can be removed once
/// the legacy route matching logic is removed.
/// </summary>
internal interface IRouteTable
{
void Route(RouteContext routeContext);
}
}

View File

@ -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
{
/// <summary>
/// A route constraint that allows the value to be null or parseable as the specified
/// type.
/// </summary>
/// <typeparam name="T">The type to which the value must be parseable.</typeparam>
internal class OptionalTypeRouteConstraint<T> : RouteConstraint
internal class LegacyOptionalTypeRouteConstraint<T> : 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;
}

View File

@ -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<string, LegacyRouteConstraint> _cachedConstraints
= new ConcurrentDictionary<string, LegacyRouteConstraint>();
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}'.");
}
}
}
/// <summary>
/// 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
/// <see cref="LegacyOptionalTypeRouteConstraint{T}" /> which handles the appropriate checks.
/// </summary>
/// <param name="constraint">String representation of the constraint</param>
/// <returns>Type-specific RouteConstraint object</returns>
private static LegacyRouteConstraint? CreateRouteConstraint(string constraint)
{
switch (constraint)
{
case "bool":
return new LegacyTypeRouteConstraint<bool>(bool.TryParse);
case "bool?":
return new LegacyOptionalTypeRouteConstraint<bool>(bool.TryParse);
case "datetime":
return new LegacyTypeRouteConstraint<DateTime>((string str, out DateTime result)
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
case "datetime?":
return new LegacyOptionalTypeRouteConstraint<DateTime>((string str, out DateTime result)
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
case "decimal":
return new LegacyTypeRouteConstraint<decimal>((string str, out decimal result)
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "decimal?":
return new LegacyOptionalTypeRouteConstraint<decimal>((string str, out decimal result)
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "double":
return new LegacyTypeRouteConstraint<double>((string str, out double result)
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "double?":
return new LegacyOptionalTypeRouteConstraint<double>((string str, out double result)
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "float":
return new LegacyTypeRouteConstraint<float>((string str, out float result)
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "float?":
return new LegacyOptionalTypeRouteConstraint<float>((string str, out float result)
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "guid":
return new LegacyTypeRouteConstraint<Guid>(Guid.TryParse);
case "guid?":
return new LegacyOptionalTypeRouteConstraint<Guid>(Guid.TryParse);
case "int":
return new LegacyTypeRouteConstraint<int>((string str, out int result)
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "int?":
return new LegacyOptionalTypeRouteConstraint<int>((string str, out int result)
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "long":
return new LegacyTypeRouteConstraint<long>((string str, out long result)
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "long?":
return new LegacyOptionalTypeRouteConstraint<long>((string str, out long result)
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
default:
return null;
}
}
}
}

View File

@ -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<string, object> 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<string, object>(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<string, object>(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<string, object>(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;
}
}
}
}

View File

@ -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;
}
}
}
}
}

View File

@ -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
{
/// <summary>
/// Resolves components for an application.
/// </summary>
internal static class LegacyRouteTableFactory
{
private static readonly ConcurrentDictionary<Key, LegacyRouteTable> Cache =
new ConcurrentDictionary<Key, LegacyRouteTable>();
public static readonly IComparer<LegacyRouteEntry> RoutePrecedence = Comparer<LegacyRouteEntry>.Create(RouteComparison);
public static LegacyRouteTable Create(IEnumerable<Assembly> 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<Type> componentTypes)
{
var templatesByHandler = new Dictionary<Type, string[]>();
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<RouteAttribute>(inherit: false);
var templates = routeAttributes.Select(t => t.Template).ToArray();
templatesByHandler.Add(componentType, templates);
}
return Create(templatesByHandler);
}
internal static LegacyRouteTable Create(Dictionary<Type, string[]> templatesByHandler)
{
var routes = new List<LegacyRouteEntry>();
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();
}
/// <summary>
/// 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.
/// </summary>
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<Key>
{
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();
}
}
}
}

View File

@ -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; }
}
}

View File

@ -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<LegacyTemplateSegment>());
}
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);
}
}
}

View File

@ -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<LegacyRouteConstraint>();
}
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);
}
}
}
}

View File

@ -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
{
/// <summary>
/// A route constraint that requires the value to be parseable as a specified type.
/// </summary>
/// <typeparam name="T">The type to which the value must be parseable.</typeparam>
internal class LegacyTypeRouteConstraint<T> : 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;
}
}
}
}

View File

@ -51,9 +51,6 @@ namespace Microsoft.AspNetCore.Components.Routing
/// Creates a structured RouteConstraint object given a string that contains
/// the route constraint. A constraint is the place after the colon in a
/// parameter definition, for example `{age:int?}`.
///
/// If the constraint denotes an optional, this method will return an
/// <see cref="OptionalTypeRouteConstraint{T}" /> which handles the appropriate checks.
/// </summary>
/// <param name="constraint">String representation of the constraint</param>
/// <returns>Type-specific RouteConstraint object</returns>
@ -63,48 +60,26 @@ namespace Microsoft.AspNetCore.Components.Routing
{
case "bool":
return new TypeRouteConstraint<bool>(bool.TryParse);
case "bool?":
return new OptionalTypeRouteConstraint<bool>(bool.TryParse);
case "datetime":
return new TypeRouteConstraint<DateTime>((string str, out DateTime result)
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
case "datetime?":
return new OptionalTypeRouteConstraint<DateTime>((string str, out DateTime result)
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
case "decimal":
return new TypeRouteConstraint<decimal>((string str, out decimal result)
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "decimal?":
return new OptionalTypeRouteConstraint<decimal>((string str, out decimal result)
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "double":
return new TypeRouteConstraint<double>((string str, out double result)
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "double?":
return new OptionalTypeRouteConstraint<double>((string str, out double result)
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "float":
return new TypeRouteConstraint<float>((string str, out float result)
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "float?":
return new OptionalTypeRouteConstraint<float>((string str, out float result)
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "guid":
return new TypeRouteConstraint<Guid>(Guid.TryParse);
case "guid?":
return new OptionalTypeRouteConstraint<Guid>(Guid.TryParse);
case "int":
return new TypeRouteConstraint<int>((string str, out int result)
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "int?":
return new OptionalTypeRouteConstraint<int>((string str, out int result)
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "long":
return new TypeRouteConstraint<long>((string str, out long result)
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "long?":
return new OptionalTypeRouteConstraint<long>((string str, out long result)
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
default:
return null;
}

View File

@ -29,116 +29,122 @@ namespace Microsoft.AspNetCore.Components.Routing
internal void Match(RouteContext context)
{
string? catchAllValue = null;
// If this template contains a catch-all parameter, we can concatenate the pathSegments
// at and beyond the catch-all segment's position. For example:
// Template: /foo/bar/{*catchAll}
// PathSegments: /foo/bar/one/two/three
if (Template.ContainsCatchAllSegment && context.Segments.Length >= Template.Segments.Length)
{
catchAllValue = string.Join('/', context.Segments[Range.StartAt(Template.Segments.Length - 1)]);
}
// If there are no optional segments on the route and the length of the route
// and the template do not match, then there is no chance of this matching and
// we can bail early.
else if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
{
return;
}
// Parameters will be lazily initialized.
var pathIndex = 0;
var templateIndex = 0;
Dictionary<string, object> parameters = null;
var numMatchingSegments = 0;
for (var i = 0; i < Template.Segments.Length; i++)
// We will iterate over the path segments and the template segments until we have consumed
// one of them.
// There are three cases we need to account here for:
// * Path is shorter than template ->
// * This can match only if we have t-p optional parameters at the end.
// * Path and template have the same number of segments
// * This can happen when the catch-all segment matches 1 segment
// * This can happen when an optional parameter has been specified.
// * This can happen when the route only contains literals and parameters.
// * Path is longer than template -> This can only match if the parameter has a catch-all at the end.
// * We still need to iterate over all the path segments if the catch-all is constrained.
// * We still need to iterate over all the template/path segments before the catch-all
while (pathIndex < context.Segments.Length && templateIndex < Template.Segments.Length)
{
var segment = Template.Segments[i];
var pathSegment = context.Segments[pathIndex];
var templateSegment = Template.Segments[templateIndex];
if (segment.IsCatchAll)
{
numMatchingSegments += 1;
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
parameters[segment.Value] = catchAllValue;
break;
}
// If the template contains more segments than the path, then
// we may need to break out of this for-loop. This can happen
// in one of two cases:
//
// (1) If we are comparing a literal route with a literal template
// and the route is shorter than the template.
// (2) If we are comparing a template where the last value is an optional
// parameter that the route does not provide.
if (i >= context.Segments.Length)
{
// If we are under condition (1) above then we can stop evaluating
// matches on the rest of this template.
if (!segment.IsParameter && !segment.IsOptional)
{
break;
}
}
string pathSegment = null;
if (i < context.Segments.Length)
{
pathSegment = context.Segments[i];
}
if (!segment.Match(pathSegment, out var matchedParameterValue))
var matches = templateSegment.Match(pathSegment, out var match);
if (!matches)
{
// A constraint or literal didn't match
return;
}
if (!templateSegment.IsCatchAll)
{
// We were dealing with a literal or a parameter, so just advance both cursors.
pathIndex++;
templateIndex++;
if (templateSegment.IsParameter)
{
parameters ??= new(StringComparer.OrdinalIgnoreCase);
parameters[templateSegment.Value] = match;
}
}
else
{
numMatchingSegments++;
if (segment.IsParameter)
if (templateSegment.Constraints.Length == 0)
{
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
parameters[segment.Value] = matchedParameterValue;
// Unconstrained catch all, we can stop early
parameters ??= new(StringComparer.OrdinalIgnoreCase);
parameters[templateSegment.Value] = string.Join('/', context.Segments, pathIndex, context.Segments.Length - pathIndex);
// Mark the remaining segments as consumed.
pathIndex = context.Segments.Length;
// Catch-alls are always last.
templateIndex++;
// We are done, so break out of the loop.
break;
}
else
{
// For constrained catch-alls, we advance the path index but keep the template index on the catch-all.
pathIndex++;
if (pathIndex == context.Segments.Length)
{
parameters ??= new(StringComparer.OrdinalIgnoreCase);
parameters[templateSegment.Value] = string.Join('/', context.Segments, templateIndex, context.Segments.Length - templateIndex);
// This is important to signal that we consumed the entire template.
templateIndex++;
}
}
}
}
// In addition to extracting parameter values from the URL, each route entry
// also knows which other parameters should be supplied with null values. These
// are parameters supplied by other route entries matching the same handler.
if (!Template.ContainsCatchAllSegment && UnusedRouteParameterNames.Length > 0)
var hasRemainingOptionalSegments = templateIndex < Template.Segments.Length &&
RemainingSegmentsAreOptional(pathIndex, Template.Segments);
if ((pathIndex == context.Segments.Length && templateIndex == Template.Segments.Length) || hasRemainingOptionalSegments)
{
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
for (var i = 0; i < UnusedRouteParameterNames.Length; i++)
if (hasRemainingOptionalSegments)
{
parameters[UnusedRouteParameterNames[i]] = null;
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
AddDefaultValues(parameters, templateIndex, Template.Segments);
}
if (UnusedRouteParameterNames?.Length > 0)
{
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
for (var i = 0; i < UnusedRouteParameterNames.Length; i++)
{
parameters[UnusedRouteParameterNames[i]] = null;
}
}
context.Handler = Handler;
context.Parameters = parameters;
}
}
private void AddDefaultValues(Dictionary<string, object> parameters, int templateIndex, TemplateSegment[] segments)
{
for (var i = templateIndex; i < segments.Length; i++)
{
var currentSegment = segments[i];
parameters[currentSegment.Value] = null;
}
}
private bool RemainingSegmentsAreOptional(int index, TemplateSegment[] segments)
{
for (var i = index; index < segments.Length - 1; index++)
{
if (!segments[i].IsOptional)
{
return false;
}
}
// We track the number of segments in the template that matched
// against this particular route then only select the route that
// matches the most number of segments on the route that was passed.
// This check is an exactness check that favors the more precise of
// two templates in the event that the following route table exists.
// Route 1: /{anythingGoes}
// Route 2: /users/{id:int}
// And the provided route is `/users/1`. We want to choose Route 2
// over Route 1.
// Furthermore, literal routes are preferred over parameterized routes.
// If the two routes below are registered in the route table.
// Route 1: /users/1
// Route 2: /users/{id:int}
// And the provided route is `/users/1`. We want to choose Route 1 over
// Route 2.
var allRouteSegmentsMatch = numMatchingSegments >= context.Segments.Length;
// Checking that all route segments have been matches does not suffice if we are
// comparing literal templates with literal routes. For example, the template
// `/this/is/a/template` and the route `/this/`. In that case, we want to ensure
// that all non-optional segments have matched as well.
var allNonOptionalSegmentsMatch = numMatchingSegments >= (Template.Segments.Length - Template.OptionalSegmentsCount);
if (Template.ContainsCatchAllSegment || (allRouteSegmentsMatch && allNonOptionalSegmentsMatch))
{
context.Parameters = parameters;
context.Handler = Handler;
}
return segments[^1].IsOptional || segments[^1].IsCatchAll;
}
}
}

View File

@ -3,7 +3,7 @@
namespace Microsoft.AspNetCore.Components.Routing
{
internal class RouteTable
internal class RouteTable : IRouteTable
{
public RouteTable(RouteEntry[] routes)
{
@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Components.Routing
public RouteEntry[] Routes { get; }
internal void Route(RouteContext routeContext)
public void Route(RouteContext routeContext)
{
for (var i = 0; i < Routes.Length; i++)
{

View File

@ -7,7 +7,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.Extensions.Internal;
namespace Microsoft.AspNetCore.Components
{
@ -121,62 +120,63 @@ namespace Microsoft.AspNetCore.Components
var xTemplate = x.Template;
var yTemplate = y.Template;
if (xTemplate.Segments.Length != y.Template.Segments.Length)
var minSegments = Math.Min(xTemplate.Segments.Length, yTemplate.Segments.Length);
var currentResult = 0;
for (var i = 0; i < minSegments; i++)
{
return xTemplate.Segments.Length < y.Template.Segments.Length ? -1 : 1;
}
else
{
for (var i = 0; i < xTemplate.Segments.Length; i++)
var xSegment = xTemplate.Segments[i];
var ySegment = yTemplate.Segments[i];
var xRank = GetRank(xSegment);
var yRank = GetRank(ySegment);
currentResult = xRank.CompareTo(yRank);
// If they are both literals we can disambiguate
if ((xRank, yRank) == (0, 0))
{
var xSegment = xTemplate.Segments[i];
var ySegment = yTemplate.Segments[i];
if (!xSegment.IsParameter && ySegment.IsParameter)
{
return -1;
}
if (xSegment.IsParameter && !ySegment.IsParameter)
{
return 1;
}
if (xSegment.IsParameter)
{
// Always favor non-optional parameters over optional ones
if (!xSegment.IsOptional && ySegment.IsOptional)
{
return -1;
}
if (xSegment.IsOptional && !ySegment.IsOptional)
{
return 1;
}
if (xSegment.Constraints.Length > ySegment.Constraints.Length)
{
return -1;
}
else if (xSegment.Constraints.Length < ySegment.Constraints.Length)
{
return 1;
}
}
else
{
var comparison = string.Compare(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase);
if (comparison != 0)
{
return comparison;
}
}
currentResult = StringComparer.OrdinalIgnoreCase.Compare(xSegment.Value, ySegment.Value);
}
if (currentResult != 0)
{
break;
}
}
if (currentResult == 0)
{
currentResult = xTemplate.Segments.Length.CompareTo(yTemplate.Segments.Length);
}
if (currentResult == 0)
{
throw new InvalidOperationException($@"The following routes are ambiguous:
'{x.Template.TemplateText}' in '{x.Handler.FullName}'
'{y.Template.TemplateText}' in '{y.Handler.FullName}'
");
}
return currentResult;
}
private static int GetRank(TemplateSegment xSegment)
{
return xSegment switch
{
// Literal
{ IsParameter: false } => 0,
// Parameter with constraints
{ IsParameter: true, IsCatchAll: false, Constraints: { Length: > 0 } } => 1,
// Parameter without constraints
{ IsParameter: true, IsCatchAll: false, Constraints: { Length: 0 } } => 2,
// Catch all parameter with constraints
{ IsParameter: true, IsCatchAll: true, Constraints: { Length: > 0 } } => 3,
// Catch all parameter without constraints
{ IsParameter: true, IsCatchAll: true, Constraints: { Length: 0 } } => 4,
// The segment is not correct
_ => throw new InvalidOperationException($"Unknown segment definition '{xSegment}.")
};
}
private readonly struct Key : IEquatable<Key>

View File

@ -12,6 +12,7 @@ using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Components.LegacyRouteMatching;
namespace Microsoft.AspNetCore.Components.Routing
{
@ -75,7 +76,20 @@ namespace Microsoft.AspNetCore.Components.Routing
/// </summary>
[Parameter] public EventCallback<NavigationContext> OnNavigateAsync { get; set; }
private RouteTable Routes { get; set; }
/// <summary>
/// Gets or sets a flag to indicate whether route matching should prefer exact matches
/// over wildcards.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// </remarks>
[Parameter] public bool PreferExactMatches { get; set; }
private IRouteTable Routes { get; set; }
/// <inheritdoc />
public void Attach(RenderHandle renderHandle)
@ -142,7 +156,9 @@ namespace Microsoft.AspNetCore.Components.Routing
if (!_assemblies.SetEquals(assembliesSet))
{
Routes = RouteTableFactory.Create(assemblies);
Routes = PreferExactMatches
? RouteTableFactory.Create(assemblies)
: LegacyRouteTableFactory.Create(assemblies);
_assemblies.Clear();
_assemblies.UnionWith(assembliesSet);
}

View File

@ -50,6 +50,11 @@ namespace Microsoft.AspNetCore.Components.Routing
throw new InvalidOperationException(
$"Invalid template '{template}'. Missing '{{' in parameter segment '{segment}'.");
}
if (segment[^1] == '?')
{
throw new InvalidOperationException(
$"Invalid template '{template}'. '?' is not allowed in literal segment '{segment}'.");
}
templateSegments[i] = new TemplateSegment(originalTemplate, segment, isParameter: false);
}
else
@ -95,7 +100,7 @@ namespace Microsoft.AspNetCore.Components.Routing
{
var nextSegment = templateSegments[j];
if (currentSegment.IsOptional && !nextSegment.IsOptional)
if (currentSegment.IsOptional && !nextSegment.IsOptional && !nextSegment.IsCatchAll)
{
throw new InvalidOperationException($"Invalid template '{template}'. Non-optional parameters or literal routes cannot appear after optional parameters.");
}

View File

@ -12,15 +12,15 @@ namespace Microsoft.AspNetCore.Components.Routing
{
IsParameter = isParameter;
IsCatchAll = segment.StartsWith('*');
IsCatchAll = isParameter && segment.StartsWith('*');
if (IsCatchAll)
{
// Only one '*' currently allowed
Value = segment.Substring(1);
Value = segment[1..];
var invalidCharacter = Value.IndexOf('*');
if (Value.IndexOf('*') != -1)
var invalidCharacterIndex = Value.IndexOf('*');
if (invalidCharacterIndex != -1)
{
throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter may only have one '*' at the beginning of the segment.");
}
@ -30,43 +30,55 @@ namespace Microsoft.AspNetCore.Components.Routing
Value = segment;
}
// Process segments that are not parameters or do not contain
// a token separating a type constraint.
if (!isParameter || Value.IndexOf(':') < 0)
// Process segments that parameters that do not contain a token separating a type constraint.
if (IsParameter)
{
// Set the IsOptional flag to true for segments that contain
// a parameter with no type constraints but optionality set
// via the '?' token.
if (Value.IndexOf('?') == Value.Length - 1)
if (Value.IndexOf(':') < 0)
{
IsOptional = true;
Value = Value.Substring(0, Value.Length - 1);
}
// If the `?` optional marker shows up in the segment but not at the very end,
// then throw an error.
else if (Value.IndexOf('?') >= 0 && Value.IndexOf('?') != Value.Length - 1)
{
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name.");
}
Constraints = Array.Empty<RouteConstraint>();
// 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<RouteConstraint>();
}
else
{
var tokens = Value.Split(':');
if (tokens[0].Length == 0)
{
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
}
Value = tokens[0];
IsOptional = tokens[^1].EndsWith("?");
if (IsOptional)
{
tokens[^1] = tokens[^1][0..^1];
}
Constraints = new RouteConstraint[tokens.Length - 1];
for (var i = 1; i < tokens.Length; i++)
{
Constraints[i - 1] = RouteConstraint.Parse(template, segment, tokens[i]);
}
}
}
else
{
var tokens = Value.Split(':');
if (tokens[0].Length == 0)
{
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
}
// Set the IsOptional flag to true if any type constraints
// for this parameter are designated as optional.
IsOptional = tokens.Skip(1).Any(token => token.EndsWith("?"));
Value = tokens[0];
Constraints = tokens.Skip(1)
.Select(token => RouteConstraint.Parse(template, segment, token))
.ToArray();
Constraints = Array.Empty<RouteConstraint>();
}
if (IsParameter)
@ -91,7 +103,7 @@ namespace Microsoft.AspNetCore.Components.Routing
public bool IsParameter { get; }
public bool IsOptional { get; }
public bool IsOptional { get; }
public bool IsCatchAll { get; }
@ -119,5 +131,17 @@ namespace Microsoft.AspNetCore.Components.Routing
return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase);
}
}
public override string ToString() => this switch
{
{ IsParameter: true, IsOptional: false, IsCatchAll: false, Constraints: { Length: 0 } } => $"{{{Value}}}",
{ IsParameter: true, IsOptional: false, IsCatchAll: false, Constraints: { Length: > 0 } } => $"{{{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}}}",
{ IsParameter: true, IsOptional: true, Constraints: { Length: 0 } } => $"{{{Value}?}}",
{ IsParameter: true, IsOptional: true, Constraints: { Length: > 0 } } => $"{{{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}?}}",
{ IsParameter: true, IsCatchAll: true, Constraints: { Length: 0 } } => $"{{*{Value}}}",
{ IsParameter: true, IsCatchAll: true, Constraints: { Length: > 0 } } => $"{{*{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}?}}",
{ IsParameter: false } => Value,
_ => throw new InvalidOperationException("Invalid template segment.")
};
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.AspNetCore.Components.Routing
@ -33,5 +34,18 @@ namespace Microsoft.AspNetCore.Components.Routing
return false;
}
}
public override string ToString() => typeof(T) switch
{
var x when x == typeof(bool) => "bool",
var x when x == typeof(DateTime) => "datetime",
var x when x == typeof(decimal) => "decimal",
var x when x == typeof(double) => "double",
var x when x == typeof(float) => "float",
var x when x == typeof(Guid) => "guid",
var x when x == typeof(int) => "int",
var x when x == typeof(long) => "long",
var x => x.Name.ToLowerInvariant()
};
}
}

View File

@ -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);
}
}
}

View File

@ -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<string, object>
{
["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<string, object>
{
["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<object[]> 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<string, object>
{
{ "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<string, object>
{
{ "value", convertedValue }
}, context.Parameters);
}
public static IEnumerable<object[]> 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<string, object>
{
{ "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<string, object>
{
{ "value", convertedValue },
{ "value2", convertedValue }
}, context.Parameters);
}
public static IEnumerable<object[]> 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<string, object>
{
{ "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<string, object>
{
{ "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<InvalidOperationException>(() => 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<string>(), 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<string>(), route.UnusedRouteParameterNames);
});
Assert.Same(typeof(TestHandler1), context.Handler);
Assert.Equal(new Dictionary<string, object>
{
{ "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 { }
}
}

View File

@ -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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(() => LegacyTemplateParser.ParseTemplate(template));
Assert.Equal(expectedMessage, ex.Message);
}
[Fact]
public void InvalidTemplate_InvalidParameterNameWithEmptyNameThrows()
{
var ex = Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ArgumentException>(() => 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<LegacyTemplateSegment> Segments { get; set; } = new List<LegacyTemplateSegment>();
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<LegacyRouteTemplate>
{
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;
}
}
}

View File

@ -32,15 +32,5 @@ namespace Microsoft.AspNetCore.Components.Routing
// Assert
Assert.Same(original, another);
}
[Fact]
public void Parse_DoesNotThrowIfOptionalConstraint()
{
// Act
var exceptions = Record.Exception(() => RouteConstraint.Parse("ignore", "ignore", "int?"));
// Assert
Assert.Null(exceptions);
}
}
}

View File

@ -325,6 +325,253 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
}, context.Parameters);
}
[Fact]
public void MoreSpecificRoutesPrecedeMoreGeneralRoutes()
{
// Arrange
// Routes are added in reverse precedence order
var builder = new TestRouteTableBuilder()
.AddRoute("/{*last}")
.AddRoute("/{*last:int}")
.AddRoute("/{last}")
.AddRoute("/{last:int}")
.AddRoute("/literal")
.AddRoute("/literal/{*last}")
.AddRoute("/literal/{*last:int}")
.AddRoute("/literal/{last}")
.AddRoute("/literal/{last:int}")
.AddRoute("/literal/literal");
var expectedOrder = new[]
{
"literal",
"literal/literal",
"literal/{last:int}",
"literal/{last}",
"literal/{*last:int}",
"literal/{*last}",
"{last:int}",
"{last}",
"{*last:int}",
"{*last}",
};
// Act
var table = builder.Build();
// Assert
var tableTemplates = table.Routes.Select(p => p.Template.TemplateText).ToArray();
Assert.Equal(expectedOrder, tableTemplates);
}
[Theory]
[InlineData("literal", null, "literal", "literal/{parameter?}", typeof(TestHandler1))]
[InlineData("literal/value", "value", "literal", "literal/{parameter?}", typeof(TestHandler2))]
[InlineData("literal", null, "literal/{parameter?}", "literal/{*parameter}", typeof(TestHandler1))]
[InlineData("literal/value", "value", "literal/{parameter?}", "literal/{*parameter}", typeof(TestHandler1))]
[InlineData("literal/value/other", "value/other", "literal /{parameter?}", "literal/{*parameter}", typeof(TestHandler2))]
public void CorrectlyMatchesVariableLengthSegments(string path, string expectedValue, string first, string second, Type handler)
{
// Arrange
// Routes are added in reverse precedence order
var table = new TestRouteTableBuilder()
.AddRoute(first, typeof(TestHandler1))
.AddRoute(second, typeof(TestHandler2))
.Build();
var context = new RouteContext(path);
// Act
table.Route(context);
// Assert
Assert.Equal(handler, context.Handler);
var value = expectedValue != null ? Assert.Single(context.Parameters, p => p.Key == "parameter").Value : null;
Assert.Equal(expectedValue, value?.ToString());
}
[Theory]
[InlineData("/values/{*values:int}", "values/1/2/3/4/5")]
[InlineData("/{*values:int}", "1/2/3/4/5")]
public void CanMatchCatchAllParametersWithConstraints(string template, string path)
{
// Arrange
// Routes are added in reverse precedence order
var table = new TestRouteTableBuilder()
.AddRoute(template)
.Build();
var context = new RouteContext(path);
// Act
table.Route(context);
// Assert
Assert.True(context.Parameters.TryGetValue("values", out var values));
Assert.Equal("1/2/3/4/5", values);
}
[Fact]
public void CatchAllEmpty()
{
// Arrange
// Routes are added in reverse precedence order
var table = new TestRouteTableBuilder()
.AddRoute("{*catchall}")
.Build();
var context = new RouteContext("/");
// Act
table.Route(context);
// Assert
Assert.True(context.Parameters.TryGetValue("catchall", out var values));
Assert.Null(values);
}
[Fact]
public void OptionalParameterEmpty()
{
// Arrange
// Routes are added in reverse precedence order
var table = new TestRouteTableBuilder()
.AddRoute("{parameter?}")
.Build();
var context = new RouteContext("/");
// Act
table.Route(context);
// Assert
Assert.True(context.Parameters.TryGetValue("parameter", out var values));
Assert.Null(values);
}
[Theory]
[InlineData("", 0)]
[InlineData("1", 1)]
[InlineData("1/2", 2)]
[InlineData("1/2/3", 3)]
public void MultipleOptionalParameters(string path, int segments)
{
// Arrange
// Routes are added in reverse precedence order
var table = new TestRouteTableBuilder()
.AddRoute("{param1?}/{param2?}/{param3?}")
.Build();
var context = new RouteContext(path);
// Act
table.Route(context);
// Assert
for (int i = 1; i <= segments; i++)
{
// Segments present in the path have the corresponding value.
Assert.True(context.Parameters.TryGetValue($"param{i}", out var value));
Assert.Equal(i.ToString(), value);
}
for (int i = segments + 1; i <= 3; i++)
{
// Segments omitted in the path have the default null value.
Assert.True(context.Parameters.TryGetValue($"param{i}", out var value));
Assert.Null(value);
}
}
[Theory]
[InlineData("prefix/", 0)]
[InlineData("prefix/1", 1)]
[InlineData("prefix/1/2", 2)]
[InlineData("prefix/1/2/3", 3)]
public void MultipleOptionalParametersWithPrefix(string path, int segments)
{
// Arrange
// Routes are added in reverse precedence order
var table = new TestRouteTableBuilder()
.AddRoute("prefix/{param1?}/{param2?}/{param3?}")
.Build();
var context = new RouteContext(path);
// Act
table.Route(context);
// Assert
for (int i = 1; i <= segments; i++)
{
// Segments present in the path have the corresponding value.
Assert.True(context.Parameters.TryGetValue($"param{i}", out var value));
Assert.Equal(i.ToString(), value);
}
for (int i = segments + 1; i <= 3; i++)
{
// Segments omitted in the path have the default null value.
Assert.True(context.Parameters.TryGetValue($"param{i}", out var value));
Assert.Null(value);
}
}
[Theory]
[InlineData("/{parameter?}/{*catchAll}", "/", null, null)]
[InlineData("/{parameter?}/{*catchAll}", "/parameter", "parameter", null)]
[InlineData("/{parameter?}/{*catchAll}", "/value/1", "value", "1")]
[InlineData("/{parameter?}/{*catchAll}", "/value/1/2/3/4/5", "value", "1/2/3/4/5")]
[InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/", null, null)]
[InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/parameter", "parameter", null)]
[InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/value/1", "value", "1")]
[InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/value/1/2/3/4/5", "value", "1/2/3/4/5")]
public void OptionalParameterPlusCatchAllRoute(string template, string path, string parameterValue, string catchAllValue)
{
// Arrange
// Routes are added in reverse precedence order
var table = new TestRouteTableBuilder()
.AddRoute(template)
.Build();
var context = new RouteContext(path);
// Act
table.Route(context);
// Assert
Assert.True(context.Parameters.TryGetValue("parameter", out var parameter));
Assert.True(context.Parameters.TryGetValue("catchAll", out var catchAll));
Assert.Equal(parameterValue, parameter);
Assert.Equal(catchAllValue, catchAll);
}
[Fact]
public void CanMatchCatchAllParametersWithConstraints_NotMatchingRoute()
{
// Arrange
// Routes are added in reverse precedence order
var table = new TestRouteTableBuilder()
.AddRoute("/values/{*values:int}")
.Build();
var context = new RouteContext("values/1/2/3/4/5/A");
// Act
table.Route(context);
// Assert
Assert.Null(context.Handler);
}
[Fact]
public void CanMatchOptionalParameterWithoutConstraints()
{
@ -411,7 +658,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
public static IEnumerable<object[]> CanMatchSegmentWithMultipleConstraintsCases() => new object[][]
{
new object[] { "/{value:double:int}/", "/15", 15 },
new object[] { "/{value:double?:int?}/", "/", null },
new object[] { "/{value:double:int?}/", "/", null },
};
[Theory]
@ -469,52 +716,111 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
}
[Fact]
public void PrefersOptionalParamsOverNonOptionalParams()
public void ThrowsForOptionalParametersAndNonOptionalParameters()
{
// Arrange
var routeTable = new TestRouteTableBuilder()
// Arrange, act & assert
Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
.AddRoute("/users/{id}", typeof(TestHandler1))
.AddRoute("/users/{id?}", typeof(TestHandler2))
.Build();
var contextWithParam = new RouteContext("/users/1");
var contextWithoutParam = new RouteContext("/users/");
.Build());
}
[Theory]
[InlineData("{*catchall}/literal")]
[InlineData("{*catchall}/{parameter}")]
[InlineData("{*catchall}/{parameter?}")]
[InlineData("{*catchall}/{*other}")]
[InlineData("prefix/{*catchall}/literal")]
[InlineData("prefix/{*catchall}/{parameter}")]
[InlineData("prefix/{*catchall}/{parameter?}")]
[InlineData("prefix/{*catchall}/{*other}")]
public void ThrowsWhenCatchAllIsNotTheLastSegment(string template)
{
// Arrange, act & assert
Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
.AddRoute(template)
.Build());
}
[Theory]
[InlineData("{optional?}/literal")]
[InlineData("{optional?}/{parameter}")]
[InlineData("{optional?}/{parameter:int}")]
[InlineData("prefix/{optional?}/literal")]
[InlineData("prefix/{optional?}/{parameter}")]
[InlineData("prefix/{optional?}/{parameter:int}")]
public void ThrowsForOptionalParametersFollowedByNonOptionalParameters(string template)
{
// Arrange, act & assert
Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
.AddRoute(template)
.Build());
}
[Theory]
[InlineData("{parameter}", "{parameter?}")]
[InlineData("{parameter:int}", "{parameter:bool?}")]
public void ThrowsForAmbiguousRoutes(string first, string second)
{
// Arrange, act & assert
var exception = Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
.AddRoute(first, typeof(TestHandler1))
.AddRoute(second, typeof(TestHandler2))
.Build());
exception.Message.Contains("The following routes are ambiguous");
}
// It's important the precedence is inverted here to also validate that
// the precedence is correct in these cases
[Theory]
[InlineData("{optional?}", "/")]
[InlineData("{optional?}", "literal")]
[InlineData("{optional?}", "{optional:int?}")]
[InlineData("{*catchAll:int}", "{optional?}")]
[InlineData("{*catchAll}", "{optional?}")]
[InlineData("literal/{optional?}", "/")]
[InlineData("literal/{optional?}", "literal")]
[InlineData("literal/{optional?}", "literal/{optional:int?}")]
[InlineData("literal/{*catchAll:int}", "literal/{optional?}")]
[InlineData("literal/{*catchAll}", "literal/{optional?}")]
[InlineData("{param}/{optional?}", "/")]
[InlineData("{param}/{optional?}", "{param}")]
[InlineData("{param}/{optional?}", "{param}/{optional:int?}")]
[InlineData("{param}/{*catchAll:int}", "{param}/{optional?}")]
[InlineData("{param}/{*catchAll}", "{param}/{optional?}")]
[InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "/")]
[InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "{param1?}/{param2?}/{param3?}/{optional:int?}")]
[InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "{param1?}/{param2?}/{param3:int?}/{optional?}")]
[InlineData("{param1?}/{param2?}/{param3:int?}/{optional?}", "{param1?}/{param2?}")]
[InlineData("{param1?}/{param2?}/{param3?}/{*catchAll:int}", "{param1?}/{param2?}/{param3?}/{optional?}")]
[InlineData("{param1?}/{param2?}/{param3?}/{*catchAll}", "{param1?}/{param2?}/{param3?}/{optional?}")]
public void DoesNotThrowForNonAmbiguousRoutes(string first, string second)
{
// Arrange
var builder = new TestRouteTableBuilder()
.AddRoute(first, typeof(TestHandler1))
.AddRoute(second, typeof(TestHandler2));
var expectedOrder = new[] { second, first };
// Act
routeTable.Route(contextWithParam);
routeTable.Route(contextWithoutParam);
var table = builder.Build();
// Assert
Assert.NotNull(contextWithParam.Handler);
Assert.Equal(typeof(TestHandler1), contextWithParam.Handler);
Assert.NotNull(contextWithoutParam.Handler);
Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler);
var tableTemplates = table.Routes.Select(p => p.Template.TemplateText).ToArray();
Assert.Equal(expectedOrder, tableTemplates);
}
[Fact]
public void PrefersOptionalParamsOverNonOptionalParamsReverseOrder()
public void ThrowsForLiteralWithQuestionMark()
{
// Arrange
var routeTable = new TestRouteTableBuilder()
.AddRoute("/users/{id}", typeof(TestHandler1))
.AddRoute("/users/{id?}", typeof(TestHandler2))
.Build();
var contextWithParam = new RouteContext("/users/1");
var contextWithoutParam = new RouteContext("/users/");
// Act
routeTable.Route(contextWithParam);
routeTable.Route(contextWithoutParam);
// Assert
Assert.NotNull(contextWithParam.Handler);
Assert.Equal(typeof(TestHandler1), contextWithParam.Handler);
Assert.NotNull(contextWithoutParam.Handler);
Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler);
// Arrange, act & assert
Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
.AddRoute("literal?")
.Build());
}
[Fact]
public void PrefersLiteralTemplateOverParameterizedTemplates()
{
@ -660,10 +966,10 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
{
// Arrange
var routeTable = new TestRouteTableBuilder()
.AddRoute("/", typeof(TestHandler1))
.AddRoute("/products/{param1:int}", typeof(TestHandler1))
.AddRoute("/products/{param2}/{PaRam1}", typeof(TestHandler1))
.AddRoute("/{unrelated}", typeof(TestHandler2))
.AddRoute("/products/{param2}/{PaRam1}", typeof(TestHandler1))
.AddRoute("/products/{param1:int}", typeof(TestHandler1))
.AddRoute("/", typeof(TestHandler1))
.Build();
var context = new RouteContext("/products/456");
@ -676,26 +982,27 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
{
Assert.Same(typeof(TestHandler1), route.Handler);
Assert.Equal("/", route.Template.TemplateText);
Assert.Equal(new[] { "param1", "param2" }, route.UnusedRouteParameterNames);
},
route =>
{
Assert.Same(typeof(TestHandler2), route.Handler);
Assert.Equal("{unrelated}", route.Template.TemplateText);
Assert.Equal(Array.Empty<string>(), 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<string>(), route.UnusedRouteParameterNames);
Assert.Equal(Array.Empty<string>(), route.UnusedRouteParameterNames.OrderBy(id => id).ToArray());
},
route =>
{
Assert.Same(typeof(TestHandler2), route.Handler);
Assert.Equal("{unrelated}", route.Template.TemplateText);
Assert.Equal(Array.Empty<string>(), route.UnusedRouteParameterNames.OrderBy(id => id).ToArray());
});
Assert.Same(typeof(TestHandler1), context.Handler);
Assert.Equal(new Dictionary<string, object>
{

View File

@ -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<ILoggerFactory>(NullLoggerFactory.Instance);
services.AddSingleton<NavigationManager, TestNavigationManager>();
services.AddSingleton<NavigationManager>(_navigationManager);
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
var serviceProvider = services.BuildServiceProvider();
@ -31,7 +36,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
_renderer.ShouldHandleExceptions = true;
_router = (Router)_renderer.InstantiateComponent<Router>();
_router.AppAssembly = Assembly.GetExecutingAssembly();
_router.Found = routeData => (builder) => builder.AddContent(0, "Rendering route...");
_router.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}");
_renderer.AssignRootComponentId(_router);
}
@ -177,12 +182,65 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
await feb;
}
[Fact]
public async Task UsesLegacyRouteMatchingByDefault()
{
// Arrange
// Legacy routing prefers {*someWildcard} over any other pattern than has more segments,
// even if the other pattern is an exact match
_navigationManager.NotifyLocationChanged("https://www.example.com/subdir/a/b", false);
var parameters = new Dictionary<string, object>
{
{ 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<string, object>
{
{ nameof(Router.AppAssembly), typeof(RouterTest).Assembly },
{ nameof(Router.NotFound), (RenderFragment)(builder => { }) },
{ nameof(Router.PreferExactMatches), true },
};
// Act
await _renderer.Dispatcher.InvokeAsync(() =>
_router.SetParametersAsync(ParameterView.FromDictionary(parameters)));
// Assert
var renderedFrame = _renderer.Batches.First().ReferenceFrames.First();
Assert.Equal(RenderTreeFrameType.Text, renderedFrame.FrameType);
Assert.Equal($"Rendering route matching {typeof(MultiSegmentRouteComponent)}", renderedFrame.TextContent);
}
internal class TestNavigationManager : NavigationManager
{
public TestNavigationManager() =>
Initialize("https://www.example.com/subdir/", "https://www.example.com/subdir/jan");
protected override void NavigateToCore(string uri, bool forceLoad) => throw new NotImplementedException();
public void NotifyLocationChanged(string uri, bool intercepted)
{
Uri = uri;
NotifyLocationChanged(intercepted);
}
}
internal sealed class TestNavigationInterception : INavigationInterception
@ -200,5 +258,11 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
[Route("jan")]
public class JanComponent : ComponentBase { }
[Route("{*matchAnything}")]
public class MatchAnythingComponent : ComponentBase { }
[Route("a/b")]
public class MultiSegmentRouteComponent : ComponentBase { }
}
}

View File

@ -128,7 +128,7 @@ namespace Microsoft.AspNetCore.Components.Routing
var ex = Assert.Throws<InvalidOperationException>(
() => 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);
}

View File

@ -1,4 +1,4 @@
<Router AppAssembly="@typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>

View File

@ -1,4 +1,4 @@
<Router AppAssembly="@typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData"/>
</Found>

View File

@ -1,4 +1,4 @@
<Router AppAssembly="@typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData"/>
</Found>

View File

@ -1,4 +1,4 @@
<Router AppAssembly=typeof(StandaloneApp.Program).Assembly>
<Router AppAssembly="@typeof(StandaloneApp.Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>

View File

@ -1,5 +1,5 @@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>

View File

@ -1,4 +1,4 @@
<Router AppAssembly="typeof(Program).Assembly">
<Router AppAssembly="typeof(Program).Assembly" PreferExactMatches="true">
<NotFound>Page not found</NotFound>
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)"></RouteView>

View File

@ -1,4 +1,4 @@
<Router AppAssembly=typeof(Program).Assembly>
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>

View File

@ -9,7 +9,7 @@
and @page authorization rules.
*@
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly">
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(AuthRouterLayout)">
<Authorizing>Authorizing...</Authorizing>

View File

@ -1,5 +1,5 @@
@using Microsoft.AspNetCore.Components.Routing
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly">
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>

View File

@ -1,5 +1,5 @@
@using Microsoft.AspNetCore.Components.Routing
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@(new[] { typeof(TestContentPackage.RouteableComponentFromPackage).Assembly, })">
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@(new[] { typeof(TestContentPackage.RouteableComponentFromPackage).Assembly, })" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>

View File

@ -4,7 +4,7 @@
@inject LazyAssemblyLoader lazyLoader
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@lazyLoadedAssemblies" OnNavigateAsync="@OnNavigateAsync">
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@lazyLoadedAssemblies" OnNavigateAsync="@OnNavigateAsync" PreferExactMatches="true">
<Navigating>
<div style="padding: 20px;background-color:blue;color:white;" id="loading-banner">
<p>Loading the requested page...</p>

View File

@ -4,7 +4,7 @@
<button @onclick="TriggerRerender" id="trigger-rerender">Trigger Rerender</button>
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" OnNavigateAsync="@OnNavigateAsync">
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" OnNavigateAsync="@OnNavigateAsync" PreferExactMatches="true">
<Navigating>
<div style="padding: 20px;background-color:blue;color:white;" id="loading-banner">
<p>Loading the requested page...</p>

View File

@ -1,6 +1,6 @@
@using Microsoft.AspNetCore.Components;
<CascadingValue Value="Name" Name="Name" IsFixed=true>
<Router AppAssembly="@typeof(ComponentsApp.App.App).Assembly">
<Router AppAssembly="@typeof(ComponentsApp.App.App).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>

View File

@ -1,5 +1,5 @@
@*#if (NoAuth)
<Router AppAssembly="@typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
@ -11,7 +11,7 @@
</Router>
#else
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>

View File

@ -1,5 +1,5 @@
@*#if (NoAuth)
<Router AppAssembly="@typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
@ -11,7 +11,7 @@
</Router>
#else
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>