Skip to content

Add support for optional parameters in Blazor routes #19733

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 1 commit into from
Mar 30, 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
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// A route constraint that allows the value to be null or parseable as the specified
/// type.
/// </summary>
/// <typeparam name="T">The type to which the value must be parseable.</typeparam>
internal class OptionalTypeRouteConstraint<T> : 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;
}
}
}
}
32 changes: 32 additions & 0 deletions src/Components/Components/src/Routing/RouteConstraint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,32 +47,64 @@ public static RouteConstraint Parse(string template, string segment, string cons
}
}

/// <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="OptionalTypeRouteConstraint{T}" /> which handles the appropriate checks.
/// </summary>
/// <param name="constraint">String representation of the constraint</param>
/// <returns>Type-specific RouteConstraint object</returns>
private static RouteConstraint CreateRouteConstraint(string constraint)
{
switch (constraint)
{
case "bool":
return new TypeRouteConstraint<bool>(bool.TryParse);
case "bool?":
return new OptionalTypeRouteConstraint<bool>(bool.TryParse);
case "datetime":
return new TypeRouteConstraint<DateTime>((string str, out DateTime result)
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
case "datetime?":
return new OptionalTypeRouteConstraint<DateTime>((string str, out DateTime result)
=> DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
case "decimal":
return new TypeRouteConstraint<decimal>((string str, out decimal result)
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "decimal?":
return new OptionalTypeRouteConstraint<decimal>((string str, out decimal result)
=> decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "double":
return new TypeRouteConstraint<double>((string str, out double result)
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "double?":
return new OptionalTypeRouteConstraint<double>((string str, out double result)
=> double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "float":
return new TypeRouteConstraint<float>((string str, out float result)
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "float?":
return new OptionalTypeRouteConstraint<float>((string str, out float result)
=> float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
case "guid":
return new TypeRouteConstraint<Guid>(Guid.TryParse);
case "guid?":
return new OptionalTypeRouteConstraint<Guid>(Guid.TryParse);
case "int":
return new TypeRouteConstraint<int>((string str, out int result)
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "int?":
return new OptionalTypeRouteConstraint<int>((string str, out int result)
=> int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "long":
return new TypeRouteConstraint<long>((string str, out long result)
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
case "long?":
return new OptionalTypeRouteConstraint<long>((string str, out long result)
=> long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
default:
return null;
}
Expand Down
63 changes: 59 additions & 4 deletions src/Components/Components/src/Routing/RouteEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<string, object> 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<string, object>(StringComparer.Ordinal);
Expand All @@ -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;
}
}
}
}
11 changes: 11 additions & 0 deletions src/Components/Components/src/Routing/RouteTableFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/Components/Components/src/Routing/RouteTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@


using System.Diagnostics;
using System.Linq;

namespace Microsoft.AspNetCore.Components.Routing
{
Expand All @@ -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; }
}
}
12 changes: 6 additions & 6 deletions src/Components/Components/src/Routing/TemplateParser.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -13,21 +13,20 @@ 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)
// * Parameter path segments (Like /Customer/{Id}/Orders/{OrderId})
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<TemplateSegment>());
Expand Down Expand Up @@ -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))
Expand Down
30 changes: 28 additions & 2 deletions src/Components/Components/src/Routing/TemplateSegment.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<RouteConstraint>();
}
else
Expand All @@ -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))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this already works just fine, but I suspect you might have to trim the trailing ? before passing it through. This is incredibly rare, but you could do {id:int:long?} which basically treats the entire segment as optional, not just the long? Would the :int constraint get treated as non-optional?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constraint itself wouldn't be treated as optional, but the segment would be. The result of this is as follows:

  • If the user has a route like /a/{id:int:long?}/literal, then it will through an error because they have provided a non-optional segment after an optional one. It makes sense to me to throw an error in this case but let me know if you think otherwise.
  • When matching it is not required that a match be found for this segment since it contains an optional component.

You'd still get two separate constraints to match with, one optional and one not.

Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/Components/Components/test/Routing/RouteConstraintTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading