Add support for optional parameters in Blazor routes (#19733)

This commit is contained in:
Safia Abdalla 2020-03-30 12:30:28 -07:00 committed by GitHub
parent 619e2025f1
commit a57943a443
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 480 additions and 24 deletions

View File

@ -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;
}
}
}
}

View File

@ -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;
}

View File

@ -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;
}
}
}
}

View File

@ -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;

View File

@ -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; }
}
}

View File

@ -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))

View File

@ -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)

View File

@ -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);
}
}
}

View File

@ -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()
{

View File

@ -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;

View File

@ -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>();

View File

@ -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()
{

View File

@ -0,0 +1,7 @@
@page "/WithOptionalParameters/{age:int?}"
<div id="test-info">Your age is @Age.</div>
@code
{
[Parameter] public int? Age { get; set; }
}