Support route constraints of the form ":type" (e.g, ":int", ":guid", etc.)

This commit is contained in:
Steve Sanderson 2018-03-19 15:26:19 +00:00
parent c58df0b739
commit a88ab0db49
11 changed files with 271 additions and 33 deletions

View File

@ -26,7 +26,7 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
/// <summary>
/// Gets or sets the parameters to pass to the page.
/// </summary>
public IDictionary<string, string> PageParameters { get; set; }
public IDictionary<string, object> PageParameters { get; set; }
/// <inheritdoc />
public void Init(RenderHandle renderHandle)

View File

@ -0,0 +1,74 @@
// 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;
using System.Collections.Generic;
using System.Globalization;
namespace Microsoft.AspNetCore.Blazor.Routing
{
internal abstract class RouteConstraint
{
private static readonly IDictionary<string, RouteConstraint> _cachedConstraints
= new Dictionary<string, RouteConstraint>();
public abstract bool Match(string pathSegment, out object convertedValue);
public static RouteConstraint Parse(string template, string segment, string constraint)
{
if (string.IsNullOrEmpty(constraint))
{
throw new ArgumentException($"Malformed segment '{segment}' in route '{template}' contains an empty constraint.");
}
if (_cachedConstraints.TryGetValue(constraint, out var cachedInstance))
{
return cachedInstance;
}
else
{
var newInstance = CreateRouteConstraint(constraint);
if (newInstance != null)
{
_cachedConstraints[constraint] = newInstance;
return newInstance;
}
else
{
throw new ArgumentException($"Unsupported constraint '{constraint}' in route '{template}'.");
}
}
}
private static RouteConstraint CreateRouteConstraint(string constraint)
{
switch (constraint)
{
case "bool":
return new TypeRouteConstraint<bool>(bool.TryParse);
case "datetime":
return new TypeRouteConstraint<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 "double":
return new TypeRouteConstraint<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 "guid":
return new TypeRouteConstraint<Guid>(Guid.TryParse);
case "int":
return new TypeRouteConstraint<int>((string str, out int result)
=> int.TryParse(str, NumberStyles.None, CultureInfo.InvariantCulture, out result));
case "long":
return new TypeRouteConstraint<long>((string str, out long result)
=> long.TryParse(str, NumberStyles.None, CultureInfo.InvariantCulture, out result));
default:
return null;
}
}
}
}

View File

@ -21,6 +21,6 @@ namespace Microsoft.AspNetCore.Blazor.Routing
public Type Handler { get; set; }
public IDictionary<string, string> Parameters { get; set; }
public IDictionary<string, object> Parameters { get; set; }
}
}

View File

@ -26,12 +26,12 @@ namespace Microsoft.AspNetCore.Blazor.Routing
}
// Parameters will be lazily initialized.
IDictionary<string, string> parameters = null;
IDictionary<string, object> parameters = null;
for (int i = 0; i < Template.Segments.Length; i++)
{
var segment = Template.Segments[i];
var pathSegment = context.Segments[i];
if (!segment.Match(pathSegment))
if (!segment.Match(pathSegment, out var matchedParameterValue))
{
return;
}
@ -39,7 +39,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
{
if (segment.IsParameter)
{
GetParameters()[segment.Value] = pathSegment;
GetParameters()[segment.Value] = matchedParameterValue;
}
}
}
@ -47,11 +47,11 @@ namespace Microsoft.AspNetCore.Blazor.Routing
context.Parameters = parameters;
context.Handler = Handler;
IDictionary<string, string> GetParameters()
IDictionary<string, object> GetParameters()
{
if (parameters == null)
{
parameters = new Dictionary<string, string>();
parameters = new Dictionary<string, object>();
}
return parameters;

View File

@ -43,10 +43,12 @@ namespace Microsoft.AspNetCore.Blazor.Routing
/// less specific. The specificity of a route is given by the specificity
/// of its segments and the position of those segments in the route.
/// * A literal segment is more specific than a parameter segment.
/// * A parameter segment with more constraints is more specific than one with fewer constraints
/// * Segment earlier in the route are evaluated before segments later in the route.
/// For example:
/// /Literal is more specific than /Parameter
/// /Route/With/{parameter} is more specific than /{multiple}/With/{parameters}
/// /Product/{id:int} is more specific than /Product/{id}
///
/// Routes can be ambigous if:
/// They are composed of literals and those literals have the same values (case insensitive)
@ -55,11 +57,13 @@ namespace Microsoft.AspNetCore.Blazor.Routing
/// For example:
/// * /literal and /Literal
/// /{parameter}/literal and /{something}/literal
/// /{parameter:constraint}/literal and /{something:constraint}/literal
///
/// To calculate the precedence we sort the list of routes as follows:
/// * Shorter routes go first.
/// * A literal wins over a parameter in precedence.
/// * For literals with different values (case insenitive) we choose the lexical order
/// * For parameters with different numbers of constraints, the one with more wins
/// If we get to the end of the comparison routing we've detected an ambigous pair of routes.
internal static int RouteComparison(RouteEntry x, RouteEntry y)
{
@ -83,22 +87,19 @@ namespace Microsoft.AspNetCore.Blazor.Routing
{
return 1;
}
}
for (int i = 0; i < xTemplate.Segments.Length; i++)
{
var xSegment = xTemplate.Segments[i];
var ySegment = yTemplate.Segments[i];
if (!xSegment.IsParameter && ySegment.IsParameter)
if (xSegment.IsParameter)
{
return -1;
if (xSegment.Constraints.Length > ySegment.Constraints.Length)
{
return -1;
}
else if (xSegment.Constraints.Length < ySegment.Constraints.Length)
{
return 1;
}
}
if (xSegment.IsParameter && !ySegment.IsParameter)
{
return 1;
}
if (!xSegment.IsParameter)
else
{
var comparison = string.Compare(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase);
if (comparison != 0)

View File

@ -65,7 +65,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
: str.Substring(0, firstIndex);
}
protected virtual void Render(RenderTreeBuilder builder, Type handler, IDictionary<string, string> parameters)
protected virtual void Render(RenderTreeBuilder builder, Type handler, IDictionary<string, object> parameters)
{
builder.OpenComponent(0, typeof(LayoutDisplay));
builder.AddAttribute(1, nameof(LayoutDisplay.Page), handler);

View File

@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
// a more performant/properly designed routing set of abstractions.
// To be more precise these are some things we are scoping out:
// * We are not doing link generation.
// * We are not supporting route constraints.
// * We are not supporting all the route constraint formats supported by ASP.NET server-side 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:
@ -21,10 +21,11 @@ namespace Microsoft.AspNetCore.Blazor.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 == "")
{
@ -50,7 +51,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
throw new InvalidOperationException(
$"Invalid template '{template}'. Missing '{{' in parameter segment '{segment}'.");
}
templateSegments[i] = new TemplateSegment(segment, isParameter: false);
templateSegments[i] = new TemplateSegment(originalTemplate, segment, isParameter: false);
}
else
{
@ -73,7 +74,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
$"Invalid template '{template}'. The character '{segment[invalidCharacter]}' in parameter segment '{segment}' is not allowed.");
}
templateSegments[i] = new TemplateSegment(segment.Substring(1, segment.Length - 2), isParameter: true);
templateSegments[i] = new TemplateSegment(originalTemplate, segment.Substring(1, segment.Length - 2), isParameter: true);
}
}

View File

@ -2,15 +2,34 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Linq;
namespace Microsoft.AspNetCore.Blazor.Routing
{
internal class TemplateSegment
{
public TemplateSegment(string segment, bool isParameter)
public TemplateSegment(string template, string segment, bool isParameter)
{
Value = segment;
IsParameter = isParameter;
if (!isParameter || segment.IndexOf(':') < 0)
{
Value = segment;
Constraints = Array.Empty<RouteConstraint>();
}
else
{
var tokens = segment.Split(':');
if (tokens[0].Length == 0)
{
throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
}
Value = tokens[0];
Constraints = tokens.Skip(1)
.Select(token => RouteConstraint.Parse(template, segment, token))
.ToArray();
}
}
// The value of the segment. The exact text to match when is a literal.
@ -19,14 +38,27 @@ namespace Microsoft.AspNetCore.Blazor.Routing
public bool IsParameter { get; }
public bool Match(string pathSegment)
public RouteConstraint[] Constraints { get; }
public bool Match(string pathSegment, out object matchedParameterValue)
{
if (IsParameter)
{
matchedParameterValue = pathSegment;
foreach (var constraint in Constraints)
{
if (!constraint.Match(pathSegment, out matchedParameterValue))
{
return false;
}
}
return true;
}
else
{
matchedParameterValue = null;
return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase);
}
}

View File

@ -0,0 +1,35 @@
// 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.Blazor.Routing
{
/// <summary>
/// A route constraint that requires the value to be parseable as a specified type.
/// </summary>
/// <typeparam name="T">The type to which the value must be parseable.</typeparam>
internal class TypeRouteConstraint<T> : RouteConstraint
{
public delegate bool TryParseDelegate(string str, out T result);
private readonly TryParseDelegate _parser;
public TypeRouteConstraint(TryParseDelegate parser)
{
_parser = parser;
}
public override bool Match(string pathSegment, out object convertedValue)
{
if (_parser(pathSegment, out var result))
{
convertedValue = result;
return true;
}
else
{
convertedValue = null;
return false;
}
}
}
}

View File

@ -81,6 +81,28 @@ namespace Microsoft.AspNetCore.Blazor.Test.Routing
Assert.Null(context.Handler);
}
[Theory]
[InlineData("/{value:bool}", "/maybe")]
[InlineData("/{value:datetime}", "/1955-01-32")]
[InlineData("/{value:decimal}", "/hello")]
[InlineData("/{value:double}", "/0.1.2")]
[InlineData("/{value:float}", "/0.1.2")]
[InlineData("/{value:guid}", "/not-a-guid")]
[InlineData("/{value:int}", "/3.141")]
[InlineData("/{value:long}", "/3.141")]
public void DoesNotMatchIfConstraintDoesNotMatch(string template, string contextUrl)
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
var context = new RouteContext(contextUrl);
// Act
routeTable.Route(context);
// Assert
Assert.Null(context.Handler);
}
[Theory]
[InlineData("/some")]
[InlineData("/some/awesome/route/with/extra/segments")]
@ -111,7 +133,7 @@ namespace Microsoft.AspNetCore.Blazor.Test.Routing
// Assert
Assert.NotNull(context.Handler);
Assert.Single(context.Parameters, p => p.Key == "parameter" && p.Value == expectedValue);
Assert.Single(context.Parameters, p => p.Key == "parameter" && (string)p.Value == expectedValue);
}
[Fact]
@ -121,7 +143,7 @@ namespace Microsoft.AspNetCore.Blazor.Test.Routing
var routeTable = new TestRouteTableBuilder().AddRoute("/{some}/awesome/{route}/").Build();
var context = new RouteContext("/an/awesome/path");
var expectedParameters = new Dictionary<string, string>
var expectedParameters = new Dictionary<string, object>
{
["some"] = "an",
["route"] = "path"
@ -135,6 +157,60 @@ namespace Microsoft.AspNetCore.Blazor.Test.Routing
Assert.Equal(expectedParameters, context.Parameters);
}
public static IEnumerable<object[]> CanMatchParameterWithConstraintCases() => new object[][]
{
new object[] { "/{value:bool}", "/true", true },
new object[] { "/{value:bool}", "/false", false },
new object[] { "/{value:datetime}", "/1955-01-30", new DateTime(1955, 1, 30) },
new object[] { "/{value:decimal}", "/5.3", 5.3m },
new object[] { "/{value:double}", "/0.1", 0.1d },
new object[] { "/{value:float}", "/0.1", 0.1f },
new object[] { "/{value:guid}", "/1FCEF085-884F-416E-B0A1-71B15F3E206B", Guid.Parse("1FCEF085-884F-416E-B0A1-71B15F3E206B") },
new object[] { "/{value:int}", "/123", 123 },
new object[] { "/{value:long}", "/9223372036854775807", long.MaxValue },
};
[Theory]
[MemberData(nameof(CanMatchParameterWithConstraintCases))]
public void CanMatchParameterWithConstraint(string template, string contextUrl, object convertedValue)
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
var context = new RouteContext(contextUrl);
// Act
routeTable.Route(context);
// Assert
if (context.Handler == null)
{
// 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<string, object>
{
{ "value", convertedValue }
});
}
[Fact]
public void CanMatchSegmentWithMultipleConstraints()
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/{value:double:int}/").Build();
var context = new RouteContext("/15");
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
Assert.Equal(context.Parameters, new Dictionary<string, object>
{
{ "value", 15 } // Final constraint's convertedValue is used
});
}
[Fact]
public void PrefersLiteralTemplateOverTemplateWithParameters()
{
@ -165,6 +241,26 @@ namespace Microsoft.AspNetCore.Blazor.Test.Routing
Assert.Equal("an/awesome", routeTable.Routes[0].Template.TemplateText);
}
[Fact]
public void PrefersMoreConstraintsOverFewer()
{
// Arrange
var routeTable = new TestRouteTableBuilder()
.AddRoute("/products/{id}")
.AddRoute("/products/{id:int}").Build();
var context = new RouteContext("/products/456");
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
Assert.Equal(context.Parameters, new Dictionary<string, object>
{
{ "id", 456 }
});
}
[Fact]
public void ProducesAStableOrderForNonAmbiguousRoutes()
{

View File

@ -104,7 +104,6 @@ namespace Microsoft.AspNetCore.Blazor.Routing
[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.")]
[InlineData("{:}", "Invalid template '{:}'. The character ':' in parameter segment '{:}' is not allowed.")]
public void ParseRouteParameter_ThrowsIf_ParameterContainsSpecialCharacters(string template, string expectedMessage)
{
// Act & Assert
@ -139,13 +138,13 @@ namespace Microsoft.AspNetCore.Blazor.Routing
public ExpectedTemplateBuilder Literal(string value)
{
Segments.Add(new TemplateSegment(value, isParameter: false));
Segments.Add(new TemplateSegment("testtemplate", value, isParameter: false));
return this;
}
public ExpectedTemplateBuilder Parameter(string value)
{
Segments.Add(new TemplateSegment(value, isParameter: true));
Segments.Add(new TemplateSegment("testtemplate", value, isParameter: true));
return this;
}