Skip to content

Fix #18539 - add Blazor catch-all route parameter #24038

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 5 commits into from
Jul 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/Components/Components/src/Routing/RouteEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,20 @@ public RouteEntry(RouteTemplate template, Type handler, string[] unusedRoutePara

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.
if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
else if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
{
return;
}
Expand All @@ -43,7 +53,15 @@ internal void Match(RouteContext context)
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:
Expand Down Expand Up @@ -86,7 +104,7 @@ internal void Match(RouteContext context)
// 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 (UnusedRouteParameterNames.Length > 0)
if (!Template.ContainsCatchAllSegment && UnusedRouteParameterNames.Length > 0)
{
parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
for (var i = 0; i < UnusedRouteParameterNames.Length; i++)
Expand Down Expand Up @@ -116,7 +134,7 @@ internal void Match(RouteContext context)
// `/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 (allRouteSegmentsMatch && allNonOptionalSegmentsMatch)
if (Template.ContainsCatchAllSegment || (allRouteSegmentsMatch && allNonOptionalSegmentsMatch))
{
context.Parameters = parameters;
context.Handler = Handler;
Expand Down
3 changes: 3 additions & 0 deletions src/Components/Components/src/Routing/RouteTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ public RouteTemplate(string templateText, TemplateSegment[] segments)
TemplateText = templateText;
Segments = segments;
OptionalSegmentsCount = segments.Count(template => template.IsOptional);
ContainsCatchAllSegment = segments.Any(template => template.IsCatchAll);
}

public string TemplateText { get; }

public TemplateSegment[] Segments { get; }

public int OptionalSegmentsCount { get; }

public bool ContainsCatchAllSegment { get; }
}
}
10 changes: 8 additions & 2 deletions src/Components/Components/src/Routing/TemplateParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ namespace Microsoft.AspNetCore.Components.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 catch all parameter segments.
// * 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 TemplateParser
{
public static readonly char[] InvalidParameterNameCharacters =
new char[] { '*', '{', '}', '=', '.' };
new char[] { '{', '}', '=', '.' };

internal static RouteTemplate ParseTemplate(string template)
{
Expand Down Expand Up @@ -80,6 +80,12 @@ internal static RouteTemplate ParseTemplate(string template)
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;
Expand Down
51 changes: 41 additions & 10 deletions src/Components/Components/src/Routing/TemplateSegment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,48 @@ public TemplateSegment(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 || segment.IndexOf(':') < 0)
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 (segment.IndexOf('?') == segment.Length - 1)
if (Value.IndexOf('?') == Value.Length - 1)
{
IsOptional = true;
Value = segment.Substring(0, segment.Length - 1);
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 (segment.IndexOf('?') >= 0 && segment.IndexOf('?') != segment.Length - 1)
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.");
}
else
{
Value = segment;
}


Constraints = Array.Empty<RouteConstraint>();
}
else
{
var tokens = segment.Split(':');
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.");
Expand All @@ -54,6 +68,21 @@ public TemplateSegment(string template, string segment, bool isParameter)
.Select(token => RouteConstraint.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.
Expand All @@ -64,6 +93,8 @@ public TemplateSegment(string template, string segment, bool isParameter)

public bool IsOptional { get; }

public bool IsCatchAll { get; }

public RouteConstraint[] Constraints { get; }

public bool Match(string pathSegment, out object? matchedParameterValue)
Expand Down
40 changes: 40 additions & 0 deletions src/Components/Components/test/Routing/RouteTableFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,23 @@ public void CanMatchParameterTemplate(string path, string expectedValue)
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()
{
Expand All @@ -247,6 +264,29 @@ public void CanMatchTemplateWithMultipleParameters()
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 },
Expand Down
62 changes: 61 additions & 1 deletion src/Components/Components/test/Routing/TemplateParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,45 @@ public void Parse_MultipleOptionalParameters()
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
}

[Fact]
public void Parse_SingleCatchAllParameter()
{
// Arrange
var expected = new ExpectedTemplateBuilder().Parameter("p");

// Act
var actual = TemplateParser.ParseTemplate("{*p}");

// Assert
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
}

[Fact]
public void Parse_MixedLiteralAndCatchAllParameter()
{
// Arrange
var expected = new ExpectedTemplateBuilder().Literal("awesome").Literal("wow").Parameter("p");

// Act
var actual = TemplateParser.ParseTemplate("awesome/wow/{*p}");

// Assert
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
}

[Fact]
public void Parse_MixedLiteralParameterAndCatchAllParameter()
{
// Arrange
var expected = new ExpectedTemplateBuilder().Literal("awesome").Parameter("p1").Parameter("p2");

// Act
var actual = TemplateParser.ParseTemplate("awesome/{p1}/{*p2}");

// Assert
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
}

[Fact]
public void InvalidTemplate_WithRepeatedParameter()
{
Expand Down Expand Up @@ -113,7 +152,8 @@ public void InvalidTemplate_WithMismatchedBraces(string template, string expecte
}

[Theory]
[InlineData("{*}", "Invalid template '{*}'. The character '*' in parameter segment '{*}' is not allowed.")]
// * 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.")]
Expand Down Expand Up @@ -166,6 +206,26 @@ public void InvalidTemplate_NonOptionalParamAfterOptionalParam()
Assert.Equal(expectedMessage, ex.Message);
}

[Fact]
public void InvalidTemplate_CatchAllParamWithMultipleAsterisks()
{
var ex = Assert.Throws<InvalidOperationException>(() => TemplateParser.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>(() => TemplateParser.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()
{
Expand Down
11 changes: 11 additions & 0 deletions src/Components/test/E2ETest/Tests/RoutingTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ public void CanArriveAtPageWithOptionalParametersNotProvided()
Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text);
}

[Fact]
public void CanArriveAtPageWithCatchAllParameter()
{
SetUrlViaPushState("/WithCatchAllParameter/life/the/universe/and/everything%20%3D%2042");

var app = Browser.MountTestComponent<TestRouter>();
var expected = $"The answer: life/the/universe/and/everything = 42.";

Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text);
}

[Fact]
public void CanArriveAtNonDefaultPage()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@page "/WithCatchAllParameter/{*theAnswer}"
<div id="test-info">The answer: @TheAnswer.</div>

@code
{
[Parameter] public string TheAnswer { get; set; }
}