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 CanMatchOptionalParameterWithConstraintCases() => new object[][] +{ + new object[] { "/optional/{value:bool?}", "/optional/", null }, + new object[] { "/optional/{value:datetime?}", "/optional/", null }, + new object[] { "/optional/{value:decimal?}", "/optional/", null }, +}; + + [Theory] + [MemberData(nameof(CanMatchOptionalParameterWithConstraintCases))] + public void CanMatchOptionalParameterWithConstraint(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(new Dictionary + { + { "value", convertedValue } + }, context.Parameters); + } + + [Fact] + public void CanMatchMultipleOptionalParameterWithConstraint() + { + // Arrange + var template = "/optional/{value:datetime?}/{value2:datetime?}"; + var contextUrl = "/optional//"; + object convertedValue = null; + + 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(new Dictionary + { + { "value", convertedValue }, + { "value2", convertedValue } + }, context.Parameters); + } + + public static IEnumerable CanMatchSegmentWithMultipleConstraintsCases() => new object[][] +{ + new object[] { "/{value:double:int}/", "/15", 15 }, + new object[] { "/{value:double?:int?}/", "/", null }, +}; + + [Theory] + [MemberData(nameof(CanMatchSegmentWithMultipleConstraintsCases))] + public void CanMatchSegmentWithMultipleConstraints(string template, string contextUrl, object convertedValue) + { + // Arrange + var routeTable = new TestRouteTableBuilder().AddRoute(template).Build(); + var context = new RouteContext(contextUrl); + + // Act + routeTable.Route(context); + + // Assert + Assert.Equal(new Dictionary + { + { "value", convertedValue } + }, context.Parameters); } [Fact] @@ -320,6 +410,91 @@ public void PrefersLiteralTemplateOverTemplateWithParameters() Assert.Null(context.Parameters); } + [Fact] + public void PrefersLiteralTemplateOverTemplateWithOptionalParameters() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/users/1", typeof(TestHandler1)) + .AddRoute("/users/{id?}", typeof(TestHandler2)) + .Build(); + var context = new RouteContext("/users/1"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Null(context.Parameters); + } + + [Fact] + public void PrefersOptionalParamsOverNonOptionalParams() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/users/{id}", typeof(TestHandler1)) + .AddRoute("/users/{id?}", typeof(TestHandler2)) + .Build(); + var contextWithParam = new RouteContext("/users/1"); + var contextWithoutParam = new RouteContext("/users/"); + + // Act + routeTable.Route(contextWithParam); + routeTable.Route(contextWithoutParam); + + // Assert + Assert.NotNull(contextWithParam.Handler); + Assert.Equal(typeof(TestHandler1), contextWithParam.Handler); + + Assert.NotNull(contextWithoutParam.Handler); + Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler); + } + + [Fact] + public void PrefersOptionalParamsOverNonOptionalParamsReverseOrder() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/users/{id}", typeof(TestHandler1)) + .AddRoute("/users/{id?}", typeof(TestHandler2)) + .Build(); + var contextWithParam = new RouteContext("/users/1"); + var contextWithoutParam = new RouteContext("/users/"); + + // Act + routeTable.Route(contextWithParam); + routeTable.Route(contextWithoutParam); + + // Assert + Assert.NotNull(contextWithParam.Handler); + Assert.Equal(typeof(TestHandler1), contextWithParam.Handler); + + Assert.NotNull(contextWithoutParam.Handler); + Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler); + } + + + [Fact] + public void PrefersLiteralTemplateOverParmeterizedTemplates() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/users/1/friends", typeof(TestHandler1)) + .AddRoute("/users/{id}/{location}", typeof(TestHandler2)) + .AddRoute("/users/1/{location}", typeof(TestHandler2)) + .Build(); + var context = new RouteContext("/users/1/friends"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Equal(typeof(TestHandler1), context.Handler); + Assert.Null(context.Parameters); + } + [Fact] public void PrefersShorterRoutesOverLongerRoutes() { @@ -353,6 +528,25 @@ public void PrefersMoreConstraintsOverFewer() }); } + [Fact] + public void PrefersRoutesThatMatchMoreSegments() + { + // Arrange + var routeTable = new TestRouteTableBuilder() + .AddRoute("/{anythingGoes}", typeof(TestHandler1)) + .AddRoute("/users/{id?}", typeof(TestHandler2)) + .Build(); + var context = new RouteContext("/users/1"); + + // Act + routeTable.Route(context); + + // Assert + Assert.NotNull(context.Handler); + Assert.Equal(typeof(TestHandler2), context.Handler); + Assert.NotNull(context.Parameters); + } + [Fact] public void ProducesAStableOrderForNonAmbiguousRoutes() { diff --git a/src/Components/Components/test/Routing/TemplateParserTests.cs b/src/Components/Components/test/Routing/TemplateParserTests.cs index 752963f52da9..aba3f0496a24 100644 --- a/src/Components/Components/test/Routing/TemplateParserTests.cs +++ b/src/Components/Components/test/Routing/TemplateParserTests.cs @@ -68,6 +68,21 @@ public void Parse_MultipleParameters() Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance); } + [Fact] + public void Parse_MultipleOptionalParameters() + { + // Arrange + var template = "{p1?}/{p2?}/{p3?}"; + + var expected = new ExpectedTemplateBuilder().Parameter("p1?").Parameter("p2?").Parameter("p3?"); + + // Act + var actual = TemplateParser.ParseTemplate(template); + + // Assert + Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance); + } + [Fact] public void InvalidTemplate_WithRepeatedParameter() { @@ -99,7 +114,6 @@ public void InvalidTemplate_WithMismatchedBraces(string template, string expecte [Theory] [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.")] [InlineData("{=}", "Invalid template '{=}'. The character '=' in parameter segment '{=}' is not allowed.")] @@ -132,6 +146,36 @@ public void InvalidTemplate_ConsecutiveSeparatorsSlashSlashThrows() Assert.Equal(expectedMessage, ex.Message); } + [Fact] + public void InvalidTemplate_LiteralAfterOptionalParam() + { + var ex = Assert.Throws(() => TemplateParser.ParseTemplate("/test/{a?}/test")); + + var expectedMessage = "Invalid template 'test/{a?}/test'. Non-optional parameters or literal routes cannot appear after optional parameters."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_NonOptionalParamAfterOptionalParam() + { + var ex = Assert.Throws(() => TemplateParser.ParseTemplate("/test/{a?}/{b}")); + + var expectedMessage = "Invalid template 'test/{a?}/{b}'. Non-optional parameters or literal routes cannot appear after optional parameters."; + + Assert.Equal(expectedMessage, ex.Message); + } + + [Fact] + public void InvalidTemplate_BadOptionalCharacterPosition() + { + var ex = Assert.Throws(() => TemplateParser.ParseTemplate("/test/{a?bc}/{b}")); + + var expectedMessage = "Malformed parameter 'a?bc' in route '/test/{a?bc}/{b}'. '?' character can only appear at the end of parameter name."; + + Assert.Equal(expectedMessage, ex.Message); + } + private class ExpectedTemplateBuilder { public IList Segments { get; set; } = new List(); @@ -172,6 +216,10 @@ public bool Equals(RouteTemplate x, RouteTemplate y) { return false; } + if (xSegment.IsOptional != ySegment.IsOptional) + { + return false; + } if (!string.Equals(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase)) { return false; diff --git a/src/Components/Server/test/Circuits/RevalidatingServerAuthenticationStateProvider.cs b/src/Components/Server/test/Circuits/RevalidatingServerAuthenticationStateProvider.cs index ef4eae3630b6..324821cf433a 100644 --- a/src/Components/Server/test/Circuits/RevalidatingServerAuthenticationStateProvider.cs +++ b/src/Components/Server/test/Circuits/RevalidatingServerAuthenticationStateProvider.cs @@ -185,8 +185,8 @@ public async Task SuppliesCancellationTokenThatSignalsWhenRevalidationLoopIsBein } [Fact] - [QuarantinedTest] - public async Task IfValidateAuthenticationStateAsyncReturnsUnrelatedCanceledTask_TreatAsFailure() + [QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/19940")] + public async Task IfValidateAuthenticationStateAsyncReturnsUnrelatedCancelledTask_TreatAsFailure() { // Arrange var validationTcs = new TaskCompletionSource(); diff --git a/src/Components/test/E2ETest/Tests/RoutingTest.cs b/src/Components/test/E2ETest/Tests/RoutingTest.cs index 166692a865e2..8d9c4b45ea7e 100644 --- a/src/Components/test/E2ETest/Tests/RoutingTest.cs +++ b/src/Components/test/E2ETest/Tests/RoutingTest.cs @@ -83,6 +83,30 @@ public void CanArriveAtPageWithNumberParameters() Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text); } + [Fact] + public void CanArriveAtPageWithOptionalParametersProvided() + { + var testAge = 101; + + SetUrlViaPushState($"/WithOptionalParameters/{testAge}"); + + var app = Browser.MountTestComponent(); + var expected = $"Your age is {testAge}."; + + Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text); + } + + [Fact] + public void CanArriveAtPageWithOptionalParametersNotProvided() + { + SetUrlViaPushState($"/WithOptionalParameters"); + + var app = Browser.MountTestComponent(); + var expected = $"Your age is ."; + + Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text); + } + [Fact] public void CanArriveAtNonDefaultPage() { diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/WithOptionalParameter.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/WithOptionalParameter.razor new file mode 100644 index 000000000000..1e8070f61f74 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/WithOptionalParameter.razor @@ -0,0 +1,7 @@ +@page "/WithOptionalParameters/{age:int?}" +
Your age is @Age.
+ +@code +{ + [Parameter] public int? Age { get; set; } +}