Skip to content

Commit a57943a

Browse files
authored
Add support for optional parameters in Blazor routes (#19733)
1 parent 619e202 commit a57943a

13 files changed

+480
-24
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
/// A route constraint that allows the value to be null or parseable as the specified
8+
/// type.
9+
/// </summary>
10+
/// <typeparam name="T">The type to which the value must be parseable.</typeparam>
11+
internal class OptionalTypeRouteConstraint<T> : RouteConstraint
12+
{
13+
public delegate bool TryParseDelegate(string str, out T result);
14+
15+
private readonly TryParseDelegate _parser;
16+
17+
public OptionalTypeRouteConstraint(TryParseDelegate parser)
18+
{
19+
_parser = parser;
20+
}
21+
22+
public override bool Match(string pathSegment, out object convertedValue)
23+
{
24+
// Unset values are set to null in the Parameters object created in
25+
// the RouteContext. To match this pattern, unset optional parmeters
26+
// are converted to null.
27+
if (string.IsNullOrEmpty(pathSegment))
28+
{
29+
convertedValue = null;
30+
return true;
31+
}
32+
33+
if (_parser(pathSegment, out var result))
34+
{
35+
convertedValue = result;
36+
return true;
37+
}
38+
else
39+
{
40+
convertedValue = null;
41+
return false;
42+
}
43+
}
44+
}
45+
}

src/Components/Components/src/Routing/RouteConstraint.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,32 +47,64 @@ public static RouteConstraint Parse(string template, string segment, string cons
4747
}
4848
}
4949

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="OptionalTypeRouteConstraint{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>
5060
private static RouteConstraint CreateRouteConstraint(string constraint)
5161
{
5262
switch (constraint)
5363
{
5464
case "bool":
5565
return new TypeRouteConstraint<bool>(bool.TryParse);
66+
case "bool?":
67+
return new OptionalTypeRouteConstraint<bool>(bool.TryParse);
5668
case "datetime":
5769
return new TypeRouteConstraint<DateTime>((string str, out DateTime result)
5870
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
71+
case "datetime?":
72+
return new OptionalTypeRouteConstraint<DateTime>((string str, out DateTime result)
73+
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
5974
case "decimal":
6075
return new TypeRouteConstraint<decimal>((string str, out decimal result)
6176
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
77+
case "decimal?":
78+
return new OptionalTypeRouteConstraint<decimal>((string str, out decimal result)
79+
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
6280
case "double":
6381
return new TypeRouteConstraint<double>((string str, out double result)
6482
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
83+
case "double?":
84+
return new OptionalTypeRouteConstraint<double>((string str, out double result)
85+
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
6586
case "float":
6687
return new TypeRouteConstraint<float>((string str, out float result)
6788
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
89+
case "float?":
90+
return new OptionalTypeRouteConstraint<float>((string str, out float result)
91+
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
6892
case "guid":
6993
return new TypeRouteConstraint<Guid>(Guid.TryParse);
94+
case "guid?":
95+
return new OptionalTypeRouteConstraint<Guid>(Guid.TryParse);
7096
case "int":
7197
return new TypeRouteConstraint<int>((string str, out int result)
7298
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
99+
case "int?":
100+
return new OptionalTypeRouteConstraint<int>((string str, out int result)
101+
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
73102
case "long":
74103
return new TypeRouteConstraint<long>((string str, out long result)
75104
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
105+
case "long?":
106+
return new OptionalTypeRouteConstraint<long>((string str, out long result)
107+
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
76108
default:
77109
return null;
78110
}

src/Components/Components/src/Routing/RouteEntry.cs

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Linq;
56
using System.Collections.Generic;
67
using System.Diagnostics;
8+
using System.Text;
79

810
namespace Microsoft.AspNetCore.Components.Routing
911
{
@@ -25,23 +27,52 @@ public RouteEntry(RouteTemplate template, Type handler, string[] unusedRoutePara
2527

2628
internal void Match(RouteContext context)
2729
{
28-
if (Template.Segments.Length != context.Segments.Length)
30+
// If there are no optional segments on the route and the length of the route
31+
// and the template do not match, then there is no chance of this matching and
32+
// we can bail early.
33+
if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
2934
{
3035
return;
3136
}
3237

3338
// Parameters will be lazily initialized.
3439
Dictionary<string, object> parameters = null;
40+
var numMatchingSegments = 0;
3541
for (var i = 0; i < Template.Segments.Length; i++)
3642
{
3743
var segment = Template.Segments[i];
38-
var pathSegment = context.Segments[i];
44+
45+
// If the template contains more segments than the path, then
46+
// we may need to break out of this for-loop. This can happen
47+
// in one of two cases:
48+
//
49+
// (1) If we are comparing a literal route with a literal template
50+
// and the route is shorter than the template.
51+
// (2) If we are comparing a template where the last value is an optional
52+
// parameter that the route does not provide.
53+
if (i >= context.Segments.Length)
54+
{
55+
// If we are under condition (1) above then we can stop evaluating
56+
// matches on the rest of this template.
57+
if (!segment.IsParameter && !segment.IsOptional)
58+
{
59+
break;
60+
}
61+
}
62+
63+
string pathSegment = null;
64+
if (i < context.Segments.Length)
65+
{
66+
pathSegment = context.Segments[i];
67+
}
68+
3969
if (!segment.Match(pathSegment, out var matchedParameterValue))
4070
{
4171
return;
4272
}
4373
else
4474
{
75+
numMatchingSegments++;
4576
if (segment.IsParameter)
4677
{
4778
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
@@ -62,8 +93,32 @@ internal void Match(RouteContext context)
6293
}
6394
}
6495

65-
context.Parameters = parameters;
66-
context.Handler = Handler;
96+
// We track the number of segments in the template that matched
97+
// against this particular route then only select the route that
98+
// matches the most number of segments on the route that was passed.
99+
// This check is an exactness check that favors the more precise of
100+
// two templates in the event that the following route table exists.
101+
// Route 1: /{anythingGoes}
102+
// Route 2: /users/{id:int}
103+
// And the provided route is `/users/1`. We want to choose Route 2
104+
// over Route 1.
105+
// Furthermore, literal routes are preferred over parameterized routes.
106+
// If the two routes below are registered in the route table.
107+
// Route 1: /users/1
108+
// Route 2: /users/{id:int}
109+
// And the provided route is `/users/1`. We want to choose Route 1 over
110+
// Route 2.
111+
var allRouteSegmentsMatch = numMatchingSegments >= context.Segments.Length;
112+
// Checking that all route segments have been matches does not suffice if we are
113+
// comparing literal templates with literal routes. For example, the template
114+
// `/this/is/a/template` and the route `/this/`. In that case, we want to ensure
115+
// that all non-optional segments have matched as well.
116+
var allNonOptionalSegmentsMatch = numMatchingSegments >= (Template.Segments.Length - Template.OptionalSegmentsCount);
117+
if (allRouteSegmentsMatch && allNonOptionalSegmentsMatch)
118+
{
119+
context.Parameters = parameters;
120+
context.Handler = Handler;
121+
}
67122
}
68123
}
69124
}

src/Components/Components/src/Routing/RouteTableFactory.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,17 @@ internal static int RouteComparison(RouteEntry x, RouteEntry y)
142142

143143
if (xSegment.IsParameter)
144144
{
145+
// Always favor non-optional parameters over optional ones
146+
if (!xSegment.IsOptional && ySegment.IsOptional)
147+
{
148+
return -1;
149+
}
150+
151+
if (xSegment.IsOptional && !ySegment.IsOptional)
152+
{
153+
return 1;
154+
}
155+
145156
if (xSegment.Constraints.Length > ySegment.Constraints.Length)
146157
{
147158
return -1;

src/Components/Components/src/Routing/RouteTemplate.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44

55
using System.Diagnostics;
6+
using System.Linq;
67

78
namespace Microsoft.AspNetCore.Components.Routing
89
{
@@ -13,10 +14,13 @@ public RouteTemplate(string templateText, TemplateSegment[] segments)
1314
{
1415
TemplateText = templateText;
1516
Segments = segments;
17+
OptionalSegmentsCount = segments.Count(template => template.IsOptional);
1618
}
1719

1820
public string TemplateText { get; }
1921

2022
public TemplateSegment[] Segments { get; }
23+
24+
public int OptionalSegmentsCount { get; }
2125
}
2226
}

src/Components/Components/src/Routing/TemplateParser.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// 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
using System;
44

@@ -13,21 +13,20 @@ namespace Microsoft.AspNetCore.Components.Routing
1313
// simple parameters from it.
1414
// Some differences with ASP.NET Core routes are:
1515
// * We don't support catch all parameter segments.
16-
// * We don't support optional parameter segments.
1716
// * We don't support complex segments.
1817
// The things that we support are:
1918
// * Literal path segments. (Like /Path/To/Some/Page)
2019
// * Parameter path segments (Like /Customer/{Id}/Orders/{OrderId})
2120
internal class TemplateParser
2221
{
2322
public static readonly char[] InvalidParameterNameCharacters =
24-
new char[] { '*', '?', '{', '}', '=', '.' };
23+
new char[] { '*', '{', '}', '=', '.' };
2524

2625
internal static RouteTemplate ParseTemplate(string template)
2726
{
2827
var originalTemplate = template;
2928
template = template.Trim('/');
30-
if (template == "")
29+
if (template == string.Empty)
3130
{
3231
// Special case "/";
3332
return new RouteTemplate("/", Array.Empty<TemplateSegment>());
@@ -89,9 +88,10 @@ internal static RouteTemplate ParseTemplate(string template)
8988
for (int j = i + 1; j < templateSegments.Length; j++)
9089
{
9190
var nextSegment = templateSegments[j];
92-
if (!nextSegment.IsParameter)
91+
92+
if (currentSegment.IsOptional && !nextSegment.IsOptional)
9393
{
94-
continue;
94+
throw new InvalidOperationException($"Invalid template '{template}'. Non-optional parameters or literal routes cannot appear after optional parameters.");
9595
}
9696

9797
if (string.Equals(currentSegment.Value, nextSegment.Value, StringComparison.OrdinalIgnoreCase))

src/Components/Components/src/Routing/TemplateSegment.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// 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

44
using System;
@@ -12,9 +12,29 @@ public TemplateSegment(string template, string segment, bool isParameter)
1212
{
1313
IsParameter = isParameter;
1414

15+
// Process segments that are not parameters or do not contain
16+
// a token separating a type constraint.
1517
if (!isParameter || segment.IndexOf(':') < 0)
1618
{
17-
Value = segment;
19+
// Set the IsOptional flag to true for segments that contain
20+
// a parameter with no type constraints but optionality set
21+
// via the '?' token.
22+
if (segment.IndexOf('?') == segment.Length - 1)
23+
{
24+
IsOptional = true;
25+
Value = segment.Substring(0, segment.Length - 1);
26+
}
27+
// If the `?` optional marker shows up in the segment but not at the very end,
28+
// then throw an error.
29+
else if (segment.IndexOf('?') >= 0 && segment.IndexOf('?') != segment.Length - 1)
30+
{
31+
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name.");
32+
}
33+
else
34+
{
35+
Value = segment;
36+
}
37+
1838
Constraints = Array.Empty<RouteConstraint>();
1939
}
2040
else
@@ -25,6 +45,10 @@ public TemplateSegment(string template, string segment, bool isParameter)
2545
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
2646
}
2747

48+
// Set the IsOptional flag to true if any type constraints
49+
// for this parameter are designated as optional.
50+
IsOptional = tokens.Skip(1).Any(token => token.EndsWith("?"));
51+
2852
Value = tokens[0];
2953
Constraints = tokens.Skip(1)
3054
.Select(token => RouteConstraint.Parse(template, segment, token))
@@ -38,6 +62,8 @@ public TemplateSegment(string template, string segment, bool isParameter)
3862

3963
public bool IsParameter { get; }
4064

65+
public bool IsOptional { get; }
66+
4167
public RouteConstraint[] Constraints { get; }
4268

4369
public bool Match(string pathSegment, out object matchedParameterValue)

src/Components/Components/test/Routing/RouteConstraintTest.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,15 @@ public void Parse_CachesCreatedConstraint_ForSameKind()
3232
// Assert
3333
Assert.Same(original, another);
3434
}
35+
36+
[Fact]
37+
public void Parse_DoesNotThrowIfOptionalConstraint()
38+
{
39+
// Act
40+
var exceptions = Record.Exception(() => RouteConstraint.Parse("ignore", "ignore", "int?"));
41+
42+
// Assert
43+
Assert.Null(exceptions);
44+
}
3545
}
3646
}

0 commit comments

Comments
 (0)