Skip to content

Commit 6bb4a3f

Browse files
authored
[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.
1 parent d14b644 commit 6bb4a3f

File tree

41 files changed

+2601
-272
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2601
-272
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool
3+
Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.AspNetCore.Components.Routing
5+
{
6+
/// <summary>
7+
/// Provides an abstraction over <see cref="RouteTable"/> and <see cref="LegacyRouteMatching.LegacyRouteTable"/>.
8+
/// This is only an internal implementation detail of <see cref="Router"/> and can be removed once
9+
/// the legacy route matching logic is removed.
10+
/// </summary>
11+
internal interface IRouteTable
12+
{
13+
void Route(RouteContext routeContext);
14+
}
15+
}

src/Components/Components/src/Routing/OptionalTypeRouteConstraint.cs renamed to src/Components/Components/src/Routing/LegacyRouteMatching/LegacyOptionalTypeRouteConstraint.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4-
namespace Microsoft.AspNetCore.Components.Routing
4+
namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
55
{
66
/// <summary>
77
/// A route constraint that allows the value to be null or parseable as the specified
88
/// type.
99
/// </summary>
1010
/// <typeparam name="T">The type to which the value must be parseable.</typeparam>
11-
internal class OptionalTypeRouteConstraint<T> : RouteConstraint
11+
internal class LegacyOptionalTypeRouteConstraint<T> : LegacyRouteConstraint
1212
{
13-
public delegate bool TryParseDelegate(string str, out T result);
13+
public delegate bool LegacyTryParseDelegate(string str, out T result);
1414

15-
private readonly TryParseDelegate _parser;
15+
private readonly LegacyTryParseDelegate _parser;
1616

17-
public OptionalTypeRouteConstraint(TryParseDelegate parser)
17+
public LegacyOptionalTypeRouteConstraint(LegacyTryParseDelegate parser)
1818
{
1919
_parser = parser;
2020
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Globalization;
7+
8+
namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
9+
{
10+
internal abstract class LegacyRouteConstraint
11+
{
12+
// note: the things that prevent this cache from growing unbounded is that
13+
// we're the only caller to this code path, and the fact that there are only
14+
// 8 possible instances that we create.
15+
//
16+
// The values passed in here for parsing are always static text defined in route attributes.
17+
private static readonly ConcurrentDictionary<string, LegacyRouteConstraint> _cachedConstraints
18+
= new ConcurrentDictionary<string, LegacyRouteConstraint>();
19+
20+
public abstract bool Match(string pathSegment, out object? convertedValue);
21+
22+
public static LegacyRouteConstraint Parse(string template, string segment, string constraint)
23+
{
24+
if (string.IsNullOrEmpty(constraint))
25+
{
26+
throw new ArgumentException($"Malformed segment '{segment}' in route '{template}' contains an empty constraint.");
27+
}
28+
29+
if (_cachedConstraints.TryGetValue(constraint, out var cachedInstance))
30+
{
31+
return cachedInstance;
32+
}
33+
else
34+
{
35+
var newInstance = CreateRouteConstraint(constraint);
36+
if (newInstance != null)
37+
{
38+
// We've done to the work to create the constraint now, but it's possible
39+
// we're competing with another thread. GetOrAdd can ensure only a single
40+
// instance is returned so that any extra ones can be GC'ed.
41+
return _cachedConstraints.GetOrAdd(constraint, newInstance);
42+
}
43+
else
44+
{
45+
throw new ArgumentException($"Unsupported constraint '{constraint}' in route '{template}'.");
46+
}
47+
}
48+
}
49+
50+
/// <summary>
51+
/// Creates a structured RouteConstraint object given a string that contains
52+
/// the route constraint. A constraint is the place after the colon in a
53+
/// parameter definition, for example `{age:int?}`.
54+
///
55+
/// If the constraint denotes an optional, this method will return an
56+
/// <see cref="LegacyOptionalTypeRouteConstraint{T}" /> which handles the appropriate checks.
57+
/// </summary>
58+
/// <param name="constraint">String representation of the constraint</param>
59+
/// <returns>Type-specific RouteConstraint object</returns>
60+
private static LegacyRouteConstraint? CreateRouteConstraint(string constraint)
61+
{
62+
switch (constraint)
63+
{
64+
case "bool":
65+
return new LegacyTypeRouteConstraint<bool>(bool.TryParse);
66+
case "bool?":
67+
return new LegacyOptionalTypeRouteConstraint<bool>(bool.TryParse);
68+
case "datetime":
69+
return new LegacyTypeRouteConstraint<DateTime>((string str, out DateTime result)
70+
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
71+
case "datetime?":
72+
return new LegacyOptionalTypeRouteConstraint<DateTime>((string str, out DateTime result)
73+
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
74+
case "decimal":
75+
return new LegacyTypeRouteConstraint<decimal>((string str, out decimal result)
76+
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
77+
case "decimal?":
78+
return new LegacyOptionalTypeRouteConstraint<decimal>((string str, out decimal result)
79+
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
80+
case "double":
81+
return new LegacyTypeRouteConstraint<double>((string str, out double result)
82+
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
83+
case "double?":
84+
return new LegacyOptionalTypeRouteConstraint<double>((string str, out double result)
85+
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
86+
case "float":
87+
return new LegacyTypeRouteConstraint<float>((string str, out float result)
88+
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
89+
case "float?":
90+
return new LegacyOptionalTypeRouteConstraint<float>((string str, out float result)
91+
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
92+
case "guid":
93+
return new LegacyTypeRouteConstraint<Guid>(Guid.TryParse);
94+
case "guid?":
95+
return new LegacyOptionalTypeRouteConstraint<Guid>(Guid.TryParse);
96+
case "int":
97+
return new LegacyTypeRouteConstraint<int>((string str, out int result)
98+
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
99+
case "int?":
100+
return new LegacyOptionalTypeRouteConstraint<int>((string str, out int result)
101+
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
102+
case "long":
103+
return new LegacyTypeRouteConstraint<long>((string str, out long result)
104+
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
105+
case "long?":
106+
return new LegacyOptionalTypeRouteConstraint<long>((string str, out long result)
107+
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
108+
default:
109+
return null;
110+
}
111+
}
112+
}
113+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
#nullable disable warnings
5+
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Diagnostics;
9+
10+
// Avoid referencing the whole Microsoft.AspNetCore.Components.Routing namespace to
11+
// avoid the risk of accidentally relying on the non-legacy types in the legacy fork
12+
using RouteContext = Microsoft.AspNetCore.Components.Routing.RouteContext;
13+
14+
namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
15+
{
16+
[DebuggerDisplay("Handler = {Handler}, Template = {Template}")]
17+
internal class LegacyRouteEntry
18+
{
19+
public LegacyRouteEntry(LegacyRouteTemplate template, Type handler, string[] unusedRouteParameterNames)
20+
{
21+
Template = template;
22+
UnusedRouteParameterNames = unusedRouteParameterNames;
23+
Handler = handler;
24+
}
25+
26+
public LegacyRouteTemplate Template { get; }
27+
28+
public string[] UnusedRouteParameterNames { get; }
29+
30+
public Type Handler { get; }
31+
32+
internal void Match(RouteContext context)
33+
{
34+
string? catchAllValue = null;
35+
36+
// If this template contains a catch-all parameter, we can concatenate the pathSegments
37+
// at and beyond the catch-all segment's position. For example:
38+
// Template: /foo/bar/{*catchAll}
39+
// PathSegments: /foo/bar/one/two/three
40+
if (Template.ContainsCatchAllSegment && context.Segments.Length >= Template.Segments.Length)
41+
{
42+
catchAllValue = string.Join('/', context.Segments[Range.StartAt(Template.Segments.Length - 1)]);
43+
}
44+
// If there are no optional segments on the route and the length of the route
45+
// and the template do not match, then there is no chance of this matching and
46+
// we can bail early.
47+
else if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
48+
{
49+
return;
50+
}
51+
52+
// Parameters will be lazily initialized.
53+
Dictionary<string, object> parameters = null;
54+
var numMatchingSegments = 0;
55+
for (var i = 0; i < Template.Segments.Length; i++)
56+
{
57+
var segment = Template.Segments[i];
58+
59+
if (segment.IsCatchAll)
60+
{
61+
numMatchingSegments += 1;
62+
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
63+
parameters[segment.Value] = catchAllValue;
64+
break;
65+
}
66+
67+
// If the template contains more segments than the path, then
68+
// we may need to break out of this for-loop. This can happen
69+
// in one of two cases:
70+
//
71+
// (1) If we are comparing a literal route with a literal template
72+
// and the route is shorter than the template.
73+
// (2) If we are comparing a template where the last value is an optional
74+
// parameter that the route does not provide.
75+
if (i >= context.Segments.Length)
76+
{
77+
// If we are under condition (1) above then we can stop evaluating
78+
// matches on the rest of this template.
79+
if (!segment.IsParameter && !segment.IsOptional)
80+
{
81+
break;
82+
}
83+
}
84+
85+
string pathSegment = null;
86+
if (i < context.Segments.Length)
87+
{
88+
pathSegment = context.Segments[i];
89+
}
90+
91+
if (!segment.Match(pathSegment, out var matchedParameterValue))
92+
{
93+
return;
94+
}
95+
else
96+
{
97+
numMatchingSegments++;
98+
if (segment.IsParameter)
99+
{
100+
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
101+
parameters[segment.Value] = matchedParameterValue;
102+
}
103+
}
104+
}
105+
106+
// In addition to extracting parameter values from the URL, each route entry
107+
// also knows which other parameters should be supplied with null values. These
108+
// are parameters supplied by other route entries matching the same handler.
109+
if (!Template.ContainsCatchAllSegment && UnusedRouteParameterNames.Length > 0)
110+
{
111+
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
112+
for (var i = 0; i < UnusedRouteParameterNames.Length; i++)
113+
{
114+
parameters[UnusedRouteParameterNames[i]] = null;
115+
}
116+
}
117+
118+
// We track the number of segments in the template that matched
119+
// against this particular route then only select the route that
120+
// matches the most number of segments on the route that was passed.
121+
// This check is an exactness check that favors the more precise of
122+
// two templates in the event that the following route table exists.
123+
// Route 1: /{anythingGoes}
124+
// Route 2: /users/{id:int}
125+
// And the provided route is `/users/1`. We want to choose Route 2
126+
// over Route 1.
127+
// Furthermore, literal routes are preferred over parameterized routes.
128+
// If the two routes below are registered in the route table.
129+
// Route 1: /users/1
130+
// Route 2: /users/{id:int}
131+
// And the provided route is `/users/1`. We want to choose Route 1 over
132+
// Route 2.
133+
var allRouteSegmentsMatch = numMatchingSegments >= context.Segments.Length;
134+
// Checking that all route segments have been matches does not suffice if we are
135+
// comparing literal templates with literal routes. For example, the template
136+
// `/this/is/a/template` and the route `/this/`. In that case, we want to ensure
137+
// that all non-optional segments have matched as well.
138+
var allNonOptionalSegmentsMatch = numMatchingSegments >= (Template.Segments.Length - Template.OptionalSegmentsCount);
139+
if (Template.ContainsCatchAllSegment || (allRouteSegmentsMatch && allNonOptionalSegmentsMatch))
140+
{
141+
context.Parameters = parameters;
142+
context.Handler = Handler;
143+
}
144+
}
145+
}
146+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
// Avoid referencing the whole Microsoft.AspNetCore.Components.Routing namespace to
5+
// avoid the risk of accidentally relying on the non-legacy types in the legacy fork
6+
using RouteContext = Microsoft.AspNetCore.Components.Routing.RouteContext;
7+
8+
namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
9+
{
10+
internal class LegacyRouteTable : Routing.IRouteTable
11+
{
12+
public LegacyRouteTable(LegacyRouteEntry[] routes)
13+
{
14+
Routes = routes;
15+
}
16+
17+
public LegacyRouteEntry[] Routes { get; }
18+
19+
public void Route(RouteContext routeContext)
20+
{
21+
for (var i = 0; i < Routes.Length; i++)
22+
{
23+
Routes[i].Match(routeContext);
24+
if (routeContext.Handler != null)
25+
{
26+
return;
27+
}
28+
}
29+
}
30+
}
31+
}

0 commit comments

Comments
 (0)