Add support for optional parameters in Blazor routes (#19733)
This commit is contained in:
parent
619e2025f1
commit
a57943a443
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,32 +47,64 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
|
||||
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);
|
||||
|
|
@ -62,8 +93,32 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,17 @@ namespace Microsoft.AspNetCore.Components
|
|||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Routing
|
||||
{
|
||||
|
|
@ -13,10 +14,13 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
{
|
||||
TemplateText = templateText;
|
||||
Segments = segments;
|
||||
OptionalSegmentsCount = segments.Count(template => template.IsOptional);
|
||||
}
|
||||
|
||||
public string TemplateText { get; }
|
||||
|
||||
public TemplateSegment[] Segments { get; }
|
||||
|
||||
public int OptionalSegmentsCount { get; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TemplateSegment>());
|
||||
|
|
@ -89,9 +88,10 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
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))
|
||||
|
|
|
|||
|
|
@ -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 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
{
|
||||
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
|
||||
|
|
@ -25,6 +45,10 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
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 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
|
||||
public bool IsParameter { get; }
|
||||
|
||||
public bool IsOptional { get; }
|
||||
|
||||
public RouteConstraint[] Constraints { get; }
|
||||
|
||||
public bool Match(string pathSegment, out object matchedParameterValue)
|
||||
|
|
|
|||
|
|
@ -32,5 +32,15 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
// Assert
|
||||
Assert.Same(original, another);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DoesNotThrowIfOptionalConstraint()
|
||||
{
|
||||
// Act
|
||||
var exceptions = Record.Exception(() => RouteConstraint.Parse("ignore", "ignore", "int?"));
|
||||
|
||||
// Assert
|
||||
Assert.Null(exceptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using System;
|
|||
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 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
// 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>
|
||||
Assert.Equal(new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
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<string, object>
|
||||
{
|
||||
{ "value", convertedValue }
|
||||
}, context.Parameters);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> 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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "value", convertedValue },
|
||||
{ "value2", convertedValue }
|
||||
}, context.Parameters);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> 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<string, object>
|
||||
{
|
||||
{ "value", convertedValue }
|
||||
}, context.Parameters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -320,6 +410,91 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
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 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
});
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -68,6 +68,21 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
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 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
|
||||
[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 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
Assert.Equal(expectedMessage, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidTemplate_LiteralAfterOptionalParam()
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ArgumentException>(() => 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<TemplateSegment> Segments { get; set; } = new List<TemplateSegment>();
|
||||
|
|
@ -172,6 +216,10 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
{
|
||||
return false;
|
||||
}
|
||||
if (xSegment.IsOptional != ySegment.IsOptional)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!string.Equals(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -185,8 +185,8 @@ namespace Microsoft.AspNetCore.Components
|
|||
}
|
||||
|
||||
[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<bool>();
|
||||
|
|
|
|||
|
|
@ -83,6 +83,30 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
|||
Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanArriveAtPageWithOptionalParametersProvided()
|
||||
{
|
||||
var testAge = 101;
|
||||
|
||||
SetUrlViaPushState($"/WithOptionalParameters/{testAge}");
|
||||
|
||||
var app = Browser.MountTestComponent<TestRouter>();
|
||||
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<TestRouter>();
|
||||
var expected = $"Your age is .";
|
||||
|
||||
Assert.Equal(expected, app.FindElement(By.Id("test-info")).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanArriveAtNonDefaultPage()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
@page "/WithOptionalParameters/{age:int?}"
|
||||
<div id="test-info">Your age is @Age.</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public int? Age { get; set; }
|
||||
}
|
||||
Loading…
Reference in New Issue