diff --git a/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs b/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs index cb95a0aa7e..dab3c7fba1 100644 --- a/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs +++ b/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs @@ -26,7 +26,7 @@ namespace Microsoft.AspNetCore.Blazor.Layouts /// /// Gets or sets the parameters to pass to the page. /// - public IDictionary PageParameters { get; set; } + public IDictionary PageParameters { get; set; } /// public void Init(RenderHandle renderHandle) diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteConstraint.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteConstraint.cs new file mode 100644 index 0000000000..ebce1daecc --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteConstraint.cs @@ -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 _cachedConstraints + = new Dictionary(); + + 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.TryParse); + case "datetime": + return new TypeRouteConstraint((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 "double": + return new TypeRouteConstraint((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 "guid": + return new TypeRouteConstraint(Guid.TryParse); + case "int": + return new TypeRouteConstraint((string str, out int result) + => int.TryParse(str, NumberStyles.None, CultureInfo.InvariantCulture, out result)); + case "long": + return new TypeRouteConstraint((string str, out long result) + => long.TryParse(str, NumberStyles.None, CultureInfo.InvariantCulture, out result)); + default: + return null; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs index cf7fcfa4e0..605e45581e 100644 --- a/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs +++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs @@ -21,6 +21,6 @@ namespace Microsoft.AspNetCore.Blazor.Routing public Type Handler { get; set; } - public IDictionary Parameters { get; set; } + public IDictionary Parameters { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs index 3fc73ab475..c84b189ba1 100644 --- a/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs +++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs @@ -26,12 +26,12 @@ namespace Microsoft.AspNetCore.Blazor.Routing } // Parameters will be lazily initialized. - IDictionary parameters = null; + IDictionary 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 GetParameters() + IDictionary GetParameters() { if (parameters == null) { - parameters = new Dictionary(); + parameters = new Dictionary(); } return parameters; diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs index efaeff92db..2d982b3889 100644 --- a/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs +++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs @@ -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) diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs b/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs index 1a9ffaa02b..cf7e7b344b 100644 --- a/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs +++ b/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs @@ -65,7 +65,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing : str.Substring(0, firstIndex); } - protected virtual void Render(RenderTreeBuilder builder, Type handler, IDictionary parameters) + protected virtual void Render(RenderTreeBuilder builder, Type handler, IDictionary parameters) { builder.OpenComponent(0, typeof(LayoutDisplay)); builder.AddAttribute(1, nameof(LayoutDisplay.Page), handler); diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs index d999c0f32e..d41500d00d 100644 --- a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs +++ b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs @@ -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); } } diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs index b7e46086c6..6e698662ad 100644 --- a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs +++ b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs @@ -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(); + } + 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); } } diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/TypeRouteConstraint.cs b/src/Microsoft.AspNetCore.Blazor/Routing/TypeRouteConstraint.cs new file mode 100644 index 0000000000..fcb4458083 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Routing/TypeRouteConstraint.cs @@ -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 +{ + /// + /// A route constraint that requires the value to be parseable as a specified type. + /// + /// The type to which the value must be parseable. + internal class TypeRouteConstraint : 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; + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs b/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs index 3a0087beaa..61790aa561 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs @@ -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 + var expectedParameters = new Dictionary { ["some"] = "an", ["route"] = "path" @@ -135,6 +157,60 @@ namespace Microsoft.AspNetCore.Blazor.Test.Routing Assert.Equal(expectedParameters, context.Parameters); } + public static IEnumerable 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 + { + { "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 + { + { "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 + { + { "id", 456 } + }); + } + [Fact] public void ProducesAStableOrderForNonAmbiguousRoutes() { diff --git a/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs b/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs index d9fb20a54a..f6c097c06f 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs @@ -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; }