Support route constraints of the form ":type" (e.g, ":int", ":guid", etc.)
This commit is contained in:
parent
c58df0b739
commit
a88ab0db49
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue