Skip to content

Commit 0889a62

Browse files
authored
Fix #18539 - add Blazor catch-all route parameter (#24038)
* Fix #18539 - add Blazor catch-all route parameter * Add E2E tests for catch-all parameter * Adjust E2E test for catch-all params * Remove ** scenarios for catch-all params * Fix typo causing test failure
1 parent 5aeac39 commit 0889a62

File tree

8 files changed

+193
-17
lines changed

8 files changed

+193
-17
lines changed

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,20 @@ public RouteEntry(RouteTemplate template, Type handler, string[] unusedRoutePara
2929

3030
internal void Match(RouteContext context)
3131
{
32+
string? catchAllValue = null;
33+
34+
// If this template contains a catch-all parameter, we can concatenate the pathSegments
35+
// at and beyond the catch-all segment's position. For example:
36+
// Template: /foo/bar/{*catchAll}
37+
// PathSegments: /foo/bar/one/two/three
38+
if (Template.ContainsCatchAllSegment && context.Segments.Length >= Template.Segments.Length)
39+
{
40+
catchAllValue = string.Join('/', context.Segments[Range.StartAt(Template.Segments.Length - 1)]);
41+
}
3242
// If there are no optional segments on the route and the length of the route
3343
// and the template do not match, then there is no chance of this matching and
3444
// we can bail early.
35-
if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
45+
else if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
3646
{
3747
return;
3848
}
@@ -43,7 +53,15 @@ internal void Match(RouteContext context)
4353
for (var i = 0; i < Template.Segments.Length; i++)
4454
{
4555
var segment = Template.Segments[i];
46-
56+
57+
if (segment.IsCatchAll)
58+
{
59+
numMatchingSegments += 1;
60+
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
61+
parameters[segment.Value] = catchAllValue;
62+
break;
63+
}
64+
4765
// If the template contains more segments than the path, then
4866
// we may need to break out of this for-loop. This can happen
4967
// in one of two cases:
@@ -86,7 +104,7 @@ internal void Match(RouteContext context)
86104
// In addition to extracting parameter values from the URL, each route entry
87105
// also knows which other parameters should be supplied with null values. These
88106
// are parameters supplied by other route entries matching the same handler.
89-
if (UnusedRouteParameterNames.Length > 0)
107+
if (!Template.ContainsCatchAllSegment && UnusedRouteParameterNames.Length > 0)
90108
{
91109
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
92110
for (var i = 0; i < UnusedRouteParameterNames.Length; i++)
@@ -116,7 +134,7 @@ internal void Match(RouteContext context)
116134
// `/this/is/a/template` and the route `/this/`. In that case, we want to ensure
117135
// that all non-optional segments have matched as well.
118136
var allNonOptionalSegmentsMatch = numMatchingSegments >= (Template.Segments.Length - Template.OptionalSegmentsCount);
119-
if (allRouteSegmentsMatch && allNonOptionalSegmentsMatch)
137+
if (Template.ContainsCatchAllSegment || (allRouteSegmentsMatch && allNonOptionalSegmentsMatch))
120138
{
121139
context.Parameters = parameters;
122140
context.Handler = Handler;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@ public RouteTemplate(string templateText, TemplateSegment[] segments)
1515
TemplateText = templateText;
1616
Segments = segments;
1717
OptionalSegmentsCount = segments.Count(template => template.IsOptional);
18+
ContainsCatchAllSegment = segments.Any(template => template.IsCatchAll);
1819
}
1920

2021
public string TemplateText { get; }
2122

2223
public TemplateSegment[] Segments { get; }
2324

2425
public int OptionalSegmentsCount { get; }
26+
27+
public bool ContainsCatchAllSegment { get; }
2528
}
2629
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ namespace Microsoft.AspNetCore.Components.Routing
1212
// The class in here just takes care of parsing a route and extracting
1313
// simple parameters from it.
1414
// Some differences with ASP.NET Core routes are:
15-
// * We don't support catch all parameter segments.
1615
// * We don't support complex segments.
1716
// The things that we support are:
1817
// * Literal path segments. (Like /Path/To/Some/Page)
1918
// * Parameter path segments (Like /Customer/{Id}/Orders/{OrderId})
19+
// * Catch-all parameters (Like /blog/{*slug})
2020
internal class TemplateParser
2121
{
2222
public static readonly char[] InvalidParameterNameCharacters =
23-
new char[] { '*', '{', '}', '=', '.' };
23+
new char[] { '{', '}', '=', '.' };
2424

2525
internal static RouteTemplate ParseTemplate(string template)
2626
{
@@ -80,6 +80,12 @@ internal static RouteTemplate ParseTemplate(string template)
8080
for (int i = 0; i < templateSegments.Length; i++)
8181
{
8282
var currentSegment = templateSegments[i];
83+
84+
if (currentSegment.IsCatchAll && i != templateSegments.Length - 1)
85+
{
86+
throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter can only appear as the last segment of the route template.");
87+
}
88+
8389
if (!currentSegment.IsParameter)
8490
{
8591
continue;

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

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,48 @@ public TemplateSegment(string template, string segment, bool isParameter)
1212
{
1313
IsParameter = isParameter;
1414

15+
IsCatchAll = segment.StartsWith('*');
16+
17+
if (IsCatchAll)
18+
{
19+
// Only one '*' currently allowed
20+
Value = segment.Substring(1);
21+
22+
var invalidCharacter = Value.IndexOf('*');
23+
if (Value.IndexOf('*') != -1)
24+
{
25+
throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter may only have one '*' at the beginning of the segment.");
26+
}
27+
}
28+
else
29+
{
30+
Value = segment;
31+
}
32+
1533
// Process segments that are not parameters or do not contain
1634
// a token separating a type constraint.
17-
if (!isParameter || segment.IndexOf(':') < 0)
35+
if (!isParameter || Value.IndexOf(':') < 0)
1836
{
1937
// Set the IsOptional flag to true for segments that contain
2038
// a parameter with no type constraints but optionality set
2139
// via the '?' token.
22-
if (segment.IndexOf('?') == segment.Length - 1)
40+
if (Value.IndexOf('?') == Value.Length - 1)
2341
{
2442
IsOptional = true;
25-
Value = segment.Substring(0, segment.Length - 1);
43+
Value = Value.Substring(0, Value.Length - 1);
2644
}
2745
// If the `?` optional marker shows up in the segment but not at the very end,
2846
// then throw an error.
29-
else if (segment.IndexOf('?') >= 0 && segment.IndexOf('?') != segment.Length - 1)
47+
else if (Value.IndexOf('?') >= 0 && Value.IndexOf('?') != Value.Length - 1)
3048
{
3149
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name.");
3250
}
33-
else
34-
{
35-
Value = segment;
36-
}
37-
51+
3852
Constraints = Array.Empty<RouteConstraint>();
3953
}
4054
else
4155
{
42-
var tokens = segment.Split(':');
56+
var tokens = Value.Split(':');
4357
if (tokens[0].Length == 0)
4458
{
4559
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
@@ -54,6 +68,21 @@ public TemplateSegment(string template, string segment, bool isParameter)
5468
.Select(token => RouteConstraint.Parse(template, segment, token))
5569
.ToArray();
5670
}
71+
72+
if (IsParameter)
73+
{
74+
if (IsOptional && IsCatchAll)
75+
{
76+
throw new InvalidOperationException($"Invalid segment '{segment}' in route '{template}'. A catch-all parameter cannot be marked optional.");
77+
}
78+
79+
// Moving the check for this here instead of TemplateParser so we can allow catch-all.
80+
// We checked for '*' up above specifically for catch-all segments, this one checks for all others
81+
if (Value.IndexOf('*') != -1)
82+
{
83+
throw new InvalidOperationException($"Invalid template '{template}'. The character '*' in parameter segment '{{{segment}}}' is not allowed.");
84+
}
85+
}
5786
}
5887

5988
// The value of the segment. The exact text to match when is a literal.
@@ -64,6 +93,8 @@ public TemplateSegment(string template, string segment, bool isParameter)
6493

6594
public bool IsOptional { get; }
6695

96+
public bool IsCatchAll { get; }
97+
6798
public RouteConstraint[] Constraints { get; }
6899

69100
public bool Match(string pathSegment, out object? matchedParameterValue)

src/Components/Components/test/Routing/RouteTableFactoryTests.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,23 @@ public void CanMatchParameterTemplate(string path, string expectedValue)
226226
Assert.Single(context.Parameters, p => p.Key == "parameter" && (string)p.Value == expectedValue);
227227
}
228228

229+
[Theory]
230+
[InlineData("/blog/value1", "value1")]
231+
[InlineData("/blog/value1/foo%20bar", "value1/foo bar")]
232+
public void CanMatchCatchAllParameterTemplate(string path, string expectedValue)
233+
{
234+
// Arrange
235+
var routeTable = new TestRouteTableBuilder().AddRoute("/blog/{*parameter}").Build();
236+
var context = new RouteContext(path);
237+
238+
// Act
239+
routeTable.Route(context);
240+
241+
// Assert
242+
Assert.NotNull(context.Handler);
243+
Assert.Single(context.Parameters, p => p.Key == "parameter" && (string)p.Value == expectedValue);
244+
}
245+
229246
[Fact]
230247
public void CanMatchTemplateWithMultipleParameters()
231248
{
@@ -247,6 +264,29 @@ public void CanMatchTemplateWithMultipleParameters()
247264
Assert.Equal(expectedParameters, context.Parameters);
248265
}
249266

267+
268+
[Fact]
269+
public void CanMatchTemplateWithMultipleParametersAndCatchAllParameter()
270+
{
271+
// Arrange
272+
var routeTable = new TestRouteTableBuilder().AddRoute("/{some}/awesome/{route}/with/{*catchAll}").Build();
273+
var context = new RouteContext("/an/awesome/path/with/some/catch/all/stuff");
274+
275+
var expectedParameters = new Dictionary<string, object>
276+
{
277+
["some"] = "an",
278+
["route"] = "path",
279+
["catchAll"] = "some/catch/all/stuff"
280+
};
281+
282+
// Act
283+
routeTable.Route(context);
284+
285+
// Assert
286+
Assert.NotNull(context.Handler);
287+
Assert.Equal(expectedParameters, context.Parameters);
288+
}
289+
250290
public static IEnumerable<object[]> CanMatchParameterWithConstraintCases() => new object[][]
251291
{
252292
new object[] { "/{value:bool}", "/true", true },

src/Components/Components/test/Routing/TemplateParserTests.cs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,45 @@ public void Parse_MultipleOptionalParameters()
8383
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
8484
}
8585

86+
[Fact]
87+
public void Parse_SingleCatchAllParameter()
88+
{
89+
// Arrange
90+
var expected = new ExpectedTemplateBuilder().Parameter("p");
91+
92+
// Act
93+
var actual = TemplateParser.ParseTemplate("{*p}");
94+
95+
// Assert
96+
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
97+
}
98+
99+
[Fact]
100+
public void Parse_MixedLiteralAndCatchAllParameter()
101+
{
102+
// Arrange
103+
var expected = new ExpectedTemplateBuilder().Literal("awesome").Literal("wow").Parameter("p");
104+
105+
// Act
106+
var actual = TemplateParser.ParseTemplate("awesome/wow/{*p}");
107+
108+
// Assert
109+
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
110+
}
111+
112+
[Fact]
113+
public void Parse_MixedLiteralParameterAndCatchAllParameter()
114+
{
115+
// Arrange
116+
var expected = new ExpectedTemplateBuilder().Literal("awesome").Parameter("p1").Parameter("p2");
117+
118+
// Act
119+
var actual = TemplateParser.ParseTemplate("awesome/{p1}/{*p2}");
120+
121+
// Assert
122+
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
123+
}
124+
86125
[Fact]
87126
public void InvalidTemplate_WithRepeatedParameter()
88127
{
@@ -113,7 +152,8 @@ public void InvalidTemplate_WithMismatchedBraces(string template, string expecte
113152
}
114153

115154
[Theory]
116-
[InlineData("{*}", "Invalid template '{*}'. The character '*' in parameter segment '{*}' is not allowed.")]
155+
// * is only allowed at beginning for catch-all parameters
156+
[InlineData("{p*}", "Invalid template '{p*}'. The character '*' in parameter segment '{p*}' is not allowed.")]
117157
[InlineData("{{}", "Invalid template '{{}'. The character '{' in parameter segment '{{}' is not allowed.")]
118158
[InlineData("{}}", "Invalid template '{}}'. The character '}' in parameter segment '{}}' is not allowed.")]
119159
[InlineData("{=}", "Invalid template '{=}'. The character '=' in parameter segment '{=}' is not allowed.")]
@@ -166,6 +206,26 @@ public void InvalidTemplate_NonOptionalParamAfterOptionalParam()
166206
Assert.Equal(expectedMessage, ex.Message);
167207
}
168208

209+
[Fact]
210+
public void InvalidTemplate_CatchAllParamWithMultipleAsterisks()
211+
{
212+
var ex = Assert.Throws<InvalidOperationException>(() => TemplateParser.ParseTemplate("/test/{a}/{**b}"));
213+
214+
var expectedMessage = "Invalid template '/test/{a}/{**b}'. A catch-all parameter may only have one '*' at the beginning of the segment.";
215+
216+
Assert.Equal(expectedMessage, ex.Message);
217+
}
218+
219+
[Fact]
220+
public void InvalidTemplate_CatchAllParamNotLast()
221+
{
222+
var ex = Assert.Throws<InvalidOperationException>(() => TemplateParser.ParseTemplate("/test/{*a}/{b}"));
223+
224+
var expectedMessage = "Invalid template 'test/{*a}/{b}'. A catch-all parameter can only appear as the last segment of the route template.";
225+
226+
Assert.Equal(expectedMessage, ex.Message);
227+
}
228+
169229
[Fact]
170230
public void InvalidTemplate_BadOptionalCharacterPosition()
171231
{

src/Components/test/E2ETest/Tests/RoutingTest.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,17 @@ public void CanArriveAtPageWithOptionalParametersNotProvided()
109109
Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text);
110110
}
111111

112+
[Fact]
113+
public void CanArriveAtPageWithCatchAllParameter()
114+
{
115+
SetUrlViaPushState("/WithCatchAllParameter/life/the/universe/and/everything%20%3D%2042");
116+
117+
var app = Browser.MountTestComponent<TestRouter>();
118+
var expected = $"The answer: life/the/universe/and/everything = 42.";
119+
120+
Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text);
121+
}
122+
112123
[Fact]
113124
public void CanArriveAtNonDefaultPage()
114125
{
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@page "/WithCatchAllParameter/{*theAnswer}"
2+
<div id="test-info">The answer: @TheAnswer.</div>
3+
4+
@code
5+
{
6+
[Parameter] public string TheAnswer { get; set; }
7+
}

0 commit comments

Comments
 (0)