diff --git a/src/Components/Components/src/Routing/OptionalTypeRouteConstraint.cs b/src/Components/Components/src/Routing/OptionalTypeRouteConstraint.cs
new file mode 100644
index 000000000000..43beb915fa34
--- /dev/null
+++ b/src/Components/Components/src/Routing/OptionalTypeRouteConstraint.cs
@@ -0,0 +1,45 @@
+// 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
+{
+ ///
+ /// A route constraint that allows the value to be null or parseable as the specified
+ /// type.
+ ///
+ /// The type to which the value must be parseable.
+ internal class OptionalTypeRouteConstraint : RouteConstraint
+ {
+ public delegate bool TryParseDelegate(string str, out T result);
+
+ private readonly TryParseDelegate _parser;
+
+ public OptionalTypeRouteConstraint(TryParseDelegate parser)
+ {
+ _parser = parser;
+ }
+
+ public override bool Match(string pathSegment, out object convertedValue)
+ {
+ // Unset values are set to null in the Parameters object created in
+ // the RouteContext. To match this pattern, unset optional parmeters
+ // are converted to null.
+ if (string.IsNullOrEmpty(pathSegment))
+ {
+ convertedValue = null;
+ return true;
+ }
+
+ if (_parser(pathSegment, out var result))
+ {
+ convertedValue = result;
+ return true;
+ }
+ else
+ {
+ convertedValue = null;
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Components/Components/src/Routing/RouteConstraint.cs b/src/Components/Components/src/Routing/RouteConstraint.cs
index c57180fb873e..4028060c766a 100644
--- a/src/Components/Components/src/Routing/RouteConstraint.cs
+++ b/src/Components/Components/src/Routing/RouteConstraint.cs
@@ -47,32 +47,64 @@ public static RouteConstraint Parse(string template, string segment, string cons
}
}
+ ///
+ /// 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
+ /// which handles the appropriate checks.
+ ///
+ /// String representation of the constraint
+ /// Type-specific RouteConstraint object
private static RouteConstraint CreateRouteConstraint(string constraint)
{
switch (constraint)
{
case "bool":
return new TypeRouteConstraint(bool.TryParse);
+ case "bool?":
+ return new OptionalTypeRouteConstraint(bool.TryParse);
case "datetime":
return new TypeRouteConstraint((string str, out DateTime result)
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
+ case "datetime?":
+ return new OptionalTypeRouteConstraint((string str, out DateTime result)
+ => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
case "decimal":
return new TypeRouteConstraint((string str, out decimal result)
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+ case "decimal?":
+ return new OptionalTypeRouteConstraint((string str, out decimal result)
+ => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "double":
return new TypeRouteConstraint((string str, out double result)
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+ case "double?":
+ return new OptionalTypeRouteConstraint((string str, out double result)
+ => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "float":
return new TypeRouteConstraint((string str, out float result)
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+ case "float?":
+ return new OptionalTypeRouteConstraint((string str, out float result)
+ => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "guid":
return new TypeRouteConstraint(Guid.TryParse);
+ case "guid?":
+ return new OptionalTypeRouteConstraint(Guid.TryParse);
case "int":
return new TypeRouteConstraint((string str, out int result)
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
+ case "int?":
+ return new OptionalTypeRouteConstraint((string str, out int result)
+ => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "long":
return new TypeRouteConstraint((string str, out long result)
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
+ case "long?":
+ return new OptionalTypeRouteConstraint((string str, out long result)
+ => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
default:
return null;
}
diff --git a/src/Components/Components/src/Routing/RouteEntry.cs b/src/Components/Components/src/Routing/RouteEntry.cs
index d2bca6d2a7ea..485c12a4d937 100644
--- a/src/Components/Components/src/Routing/RouteEntry.cs
+++ b/src/Components/Components/src/Routing/RouteEntry.cs
@@ -2,8 +2,10 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
+using System.Linq;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Text;
namespace Microsoft.AspNetCore.Components.Routing
{
@@ -25,23 +27,52 @@ public RouteEntry(RouteTemplate template, Type handler, string[] unusedRoutePara
internal void Match(RouteContext context)
{
- if (Template.Segments.Length != context.Segments.Length)
+ // 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)
{
return;
}
// Parameters will be lazily initialized.
Dictionary parameters = null;
+ var numMatchingSegments = 0;
for (var i = 0; i < Template.Segments.Length; i++)
{
var segment = Template.Segments[i];
- var pathSegment = context.Segments[i];
+
+ // 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(StringComparer.Ordinal);
@@ -62,8 +93,32 @@ internal void Match(RouteContext context)
}
}
- context.Parameters = parameters;
- context.Handler = Handler;
+ // 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 (allRouteSegmentsMatch && allNonOptionalSegmentsMatch)
+ {
+ context.Parameters = parameters;
+ context.Handler = Handler;
+ }
}
}
}
diff --git a/src/Components/Components/src/Routing/RouteTableFactory.cs b/src/Components/Components/src/Routing/RouteTableFactory.cs
index de6fe31c1d36..c8da2329bcc0 100644
--- a/src/Components/Components/src/Routing/RouteTableFactory.cs
+++ b/src/Components/Components/src/Routing/RouteTableFactory.cs
@@ -142,6 +142,17 @@ internal static int RouteComparison(RouteEntry x, RouteEntry y)
if (xSegment.IsParameter)
{
+ // Always favor non-optional parameters over optional ones
+ if (!xSegment.IsOptional && ySegment.IsOptional)
+ {
+ return -1;
+ }
+
+ if (xSegment.IsOptional && !ySegment.IsOptional)
+ {
+ return 1;
+ }
+
if (xSegment.Constraints.Length > ySegment.Constraints.Length)
{
return -1;
diff --git a/src/Components/Components/src/Routing/RouteTemplate.cs b/src/Components/Components/src/Routing/RouteTemplate.cs
index a79f0f911a33..6f4541e896a4 100644
--- a/src/Components/Components/src/Routing/RouteTemplate.cs
+++ b/src/Components/Components/src/Routing/RouteTemplate.cs
@@ -3,6 +3,7 @@
using System.Diagnostics;
+using System.Linq;
namespace Microsoft.AspNetCore.Components.Routing
{
@@ -13,10 +14,13 @@ public RouteTemplate(string templateText, TemplateSegment[] segments)
{
TemplateText = templateText;
Segments = segments;
+ OptionalSegmentsCount = segments.Count(template => template.IsOptional);
}
public string TemplateText { get; }
public TemplateSegment[] Segments { get; }
+
+ public int OptionalSegmentsCount { get; }
}
}
diff --git a/src/Components/Components/src/Routing/TemplateParser.cs b/src/Components/Components/src/Routing/TemplateParser.cs
index d4563ebf7fd7..3f9d5e1459c4 100644
--- a/src/Components/Components/src/Routing/TemplateParser.cs
+++ b/src/Components/Components/src/Routing/TemplateParser.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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;
@@ -13,7 +13,6 @@ namespace Microsoft.AspNetCore.Components.Routing
// simple parameters from it.
// Some differences with ASP.NET Core routes are:
// * We don't support catch all parameter segments.
- // * We don't support optional parameter segments.
// * We don't support complex segments.
// The things that we support are:
// * Literal path segments. (Like /Path/To/Some/Page)
@@ -21,13 +20,13 @@ namespace Microsoft.AspNetCore.Components.Routing
internal class TemplateParser
{
public static readonly char[] InvalidParameterNameCharacters =
- new char[] { '*', '?', '{', '}', '=', '.' };
+ new char[] { '*', '{', '}', '=', '.' };
internal static RouteTemplate ParseTemplate(string template)
{
var originalTemplate = template;
template = template.Trim('/');
- if (template == "")
+ if (template == string.Empty)
{
// Special case "/";
return new RouteTemplate("/", Array.Empty());
@@ -89,9 +88,10 @@ internal static RouteTemplate ParseTemplate(string template)
for (int j = i + 1; j < templateSegments.Length; j++)
{
var nextSegment = templateSegments[j];
- if (!nextSegment.IsParameter)
+
+ if (currentSegment.IsOptional && !nextSegment.IsOptional)
{
- continue;
+ throw new InvalidOperationException($"Invalid template '{template}'. Non-optional parameters or literal routes cannot appear after optional parameters.");
}
if (string.Equals(currentSegment.Value, nextSegment.Value, StringComparison.OrdinalIgnoreCase))
diff --git a/src/Components/Components/src/Routing/TemplateSegment.cs b/src/Components/Components/src/Routing/TemplateSegment.cs
index 9f732598dbbe..bcc2e27843d7 100644
--- a/src/Components/Components/src/Routing/TemplateSegment.cs
+++ b/src/Components/Components/src/Routing/TemplateSegment.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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;
@@ -12,9 +12,29 @@ public TemplateSegment(string template, string segment, bool isParameter)
{
IsParameter = isParameter;
+ // Process segments that are not parameters or do not contain
+ // a token separating a type constraint.
if (!isParameter || segment.IndexOf(':') < 0)
{
- Value = segment;
+ // 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)
+ {
+ IsOptional = true;
+ Value = segment.Substring(0, segment.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)
+ {
+ 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();
}
else
@@ -25,6 +45,10 @@ public TemplateSegment(string template, string segment, bool isParameter)
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
}
+ // Set the IsOptional flag to true if any type constraints
+ // for this parameter are designated as optional.
+ IsOptional = tokens.Skip(1).Any(token => token.EndsWith("?"));
+
Value = tokens[0];
Constraints = tokens.Skip(1)
.Select(token => RouteConstraint.Parse(template, segment, token))
@@ -38,6 +62,8 @@ public TemplateSegment(string template, string segment, bool isParameter)
public bool IsParameter { get; }
+ public bool IsOptional { get; }
+
public RouteConstraint[] Constraints { get; }
public bool Match(string pathSegment, out object matchedParameterValue)
diff --git a/src/Components/Components/test/Routing/RouteConstraintTest.cs b/src/Components/Components/test/Routing/RouteConstraintTest.cs
index 34889f03dd46..15216503e7e5 100644
--- a/src/Components/Components/test/Routing/RouteConstraintTest.cs
+++ b/src/Components/Components/test/Routing/RouteConstraintTest.cs
@@ -32,5 +32,15 @@ public void Parse_CachesCreatedConstraint_ForSameKind()
// Assert
Assert.Same(original, another);
}
+
+ [Fact]
+ public void Parse_DoesNotThrowIfOptionalConstraint()
+ {
+ // Act
+ var exceptions = Record.Exception(() => RouteConstraint.Parse("ignore", "ignore", "int?"));
+
+ // Assert
+ Assert.Null(exceptions);
+ }
}
}
diff --git a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs
index e596f2795678..ec5370d578e6 100644
--- a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs
+++ b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs
@@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Components.Routing;
+using Microsoft.Extensions.DependencyModel;
using Xunit;
namespace Microsoft.AspNetCore.Components.Test.Routing
@@ -278,28 +279,117 @@ public void CanMatchParameterWithConstraint(string template, string contextUrl,
// Make it easier to track down failing tests when using MemberData
throw new InvalidOperationException($"Failed to match template '{template}'.");
}
- Assert.Equal(context.Parameters, new Dictionary
+ Assert.Equal(new Dictionary
{
{ "value", convertedValue }
- });
+ }, context.Parameters);
}
[Fact]
- public void CanMatchSegmentWithMultipleConstraints()
+ public void CanMatchOptionalParameterWithoutConstraints()
{
// Arrange
- var routeTable = new TestRouteTableBuilder().AddRoute("/{value:double:int}/").Build();
- var context = new RouteContext("/15");
+ var template = "/optional/{value?}";
+ var contextUrl = "/optional/";
+ string convertedValue = null;
+
+ var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
+ var context = new RouteContext(contextUrl);
// Act
routeTable.Route(context);
// Assert
- Assert.NotNull(context.Handler);
- Assert.Equal(context.Parameters, new Dictionary
+ if (context.Handler == null)
{
- { "value", 15 } // Final constraint's convertedValue is used
- });
+ // Make it easier to track down failing tests when using MemberData
+ throw new InvalidOperationException($"Failed to match template '{template}'.");
+ }
+ Assert.Equal(new Dictionary
+ {
+ { "value", convertedValue }
+ }, context.Parameters);
+ }
+
+ public static IEnumerable