-
Notifications
You must be signed in to change notification settings - Fork 10.4k
[Blazor] Fixes issues with route precedence #27907
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
cff5f02
Fix routing precedence
javiercn 4820921
Fix optional followed by catch-all
javiercn 3875111
Code review comments and more tests
javiercn 40e6246
Additional tests
javiercn 9a9dd80
Fix typo in test
SteveSandersonMS 66a4a15
Add "legacy" fork of the route matching logic taken from release/5.0
SteveSandersonMS ee87925
Add unit tests from release/5.0 for the legacy route matching code
SteveSandersonMS b5dca66
Update <Router> to use legacy routing by default, but opt into curren…
SteveSandersonMS ada9038
Unit test to show the router can switch between the two routing modes
SteveSandersonMS 02bac7b
Use PreferExactMatches in all our E2E tests/samples
SteveSandersonMS ef258d9
Tweak XML doc format
SteveSandersonMS d932914
Use PreferExactMatches on project templates
SteveSandersonMS 3b648d4
Merge branch 'release/5.0' into javiercn/blazor-routing
mkArtakMSFT File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
#nullable enable | ||
Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool | ||
Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
10 changes: 5 additions & 5 deletions
10
...rc/Routing/OptionalTypeRouteConstraint.cs → ...hing/LegacyOptionalTypeRouteConstraint.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
113 changes: 113 additions & 0 deletions
113
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteConstraint.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} | ||
} | ||
} |
146 changes: 146 additions & 0 deletions
146
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteEntry.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} | ||
} | ||
} |
31 changes: 31 additions & 0 deletions
31
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTable.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.