[Fixes #220] Support `@page` with custom route template on components

* Updates the router component to scan for components within assemblies.
* Parses the templates on `[Route]` in component instances and builds a
  route table that maps paths to components.
* Uses the route table to map paths to components.
This commit is contained in:
Javier Calvarro Nelson 2018-03-14 16:00:58 -07:00
parent 2150820efe
commit 70c4501497
22 changed files with 951 additions and 65 deletions

View File

@ -2,6 +2,4 @@
Configuring this stuff here is temporary. Later we'll move the app config Configuring this stuff here is temporary. Later we'll move the app config
into Program.cs, and it won't be necessary to specify AppAssembly. into Program.cs, and it won't be necessary to specify AppAssembly.
--> -->
<Router <Router AppAssembly=typeof(StandaloneApp.Program).Assembly />
AppAssembly=typeof(StandaloneApp.Program).Assembly
PagesNamespace="StandaloneApp.Pages" />

View File

@ -1,4 +1,5 @@
<h1>Counter</h1> @page "/counter"
<h1>Counter</h1>
<p>Current count: @currentCount</p> <p>Current count: @currentCount</p>

View File

@ -1,4 +1,5 @@
@inject HttpClient Http @page "/fetchdata"
@inject HttpClient Http
<h1>Weather forecast</h1> <h1>Weather forecast</h1>

View File

@ -1,3 +1,4 @@
<h1>Hello, world!</h1> @page "/"
<h1>Hello, world!</h1>
Welcome to your new app. Welcome to your new app.

View File

@ -0,0 +1,73 @@
// 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.Linq;
using System.Reflection;
namespace Microsoft.AspNetCore.Blazor.Components
{
/// <summary>
/// Resolves components for an application.
/// </summary>
internal class ComponentResolver
{
/// <summary>
/// Lists all the types
/// </summary>
/// <param name="appAssembly"></param>
/// <returns></returns>
public static IEnumerable<Type> ResolveComponents(Assembly appAssembly)
{
var blazorAssembly = typeof(IComponent).Assembly;
return EnumerateAssemblies(appAssembly.GetName(), blazorAssembly, new HashSet<Assembly>(new AssemblyComparer()))
.SelectMany(a => a.ExportedTypes)
.Where(t => typeof(IComponent).IsAssignableFrom(t));
}
private static IEnumerable<Assembly> EnumerateAssemblies(
AssemblyName assemblyName,
Assembly blazorAssembly,
HashSet<Assembly> visited)
{
var assembly = Assembly.Load(assemblyName);
if (visited.Contains(assembly))
{
// Avoid traversing visited assemblies.
yield break;
}
visited.Add(assembly);
var references = assembly.GetReferencedAssemblies();
if (!references.Any(r => string.Equals(r.FullName, blazorAssembly.FullName, StringComparison.Ordinal)))
{
// Avoid traversing references that don't point to blazor (like netstandard2.0)
yield break;
}
else
{
yield return assembly;
// Look at the list of transitive dependencies for more components.
foreach (var reference in references.SelectMany(r => EnumerateAssemblies(r, blazorAssembly, visited)))
{
yield return reference;
}
}
}
private class AssemblyComparer : IEqualityComparer<Assembly>
{
public bool Equals(Assembly x, Assembly y)
{
return string.Equals(x?.FullName, y?.FullName, StringComparison.Ordinal);
}
public int GetHashCode(Assembly obj)
{
return obj.FullName.GetHashCode();
}
}
}
}

View File

@ -57,7 +57,8 @@ namespace Microsoft.AspNetCore.Blazor.Components
private static PropertyInfo GetPropertyInfo(Type targetType, string propertyName) private static PropertyInfo GetPropertyInfo(Type targetType, string propertyName)
{ {
var property = targetType.GetProperty(propertyName); var flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
var property = targetType.GetProperty(propertyName, flags);
if (property == null) if (property == null)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using Microsoft.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.Components;
using Microsoft.AspNetCore.Blazor.RenderTree; using Microsoft.AspNetCore.Blazor.RenderTree;
@ -22,6 +23,11 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
/// </summary> /// </summary>
public Type Page { get; set; } public Type Page { get; set; }
/// <summary>
/// Gets or sets the parameters to pass to the page.
/// </summary>
public IDictionary<string, string> PageParameters { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public void Init(RenderHandle renderHandle) public void Init(RenderHandle renderHandle)
{ {
@ -58,6 +64,16 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
{ {
builder.AddAttribute(1, nameof(ILayoutComponent.Body), bodyParam); builder.AddAttribute(1, nameof(ILayoutComponent.Body), bodyParam);
} }
else
{
if (PageParameters != null)
{
foreach (var kvp in PageParameters)
{
builder.AddAttribute(1, kvp.Key, kvp.Value);
}
}
}
builder.CloseComponent(); builder.CloseComponent();
}; };

View File

@ -0,0 +1,26 @@
// 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;
namespace Microsoft.AspNetCore.Blazor.Routing
{
internal class RouteContext
{
private static char[] Separator = new[] { '/' };
public RouteContext(string path)
{
// This is a simplification. We are assuming there are no paths like /a//b/. A proper routing
// implementation would be more sophisticated.
Segments = path.Trim('/').Split(Separator, StringSplitOptions.RemoveEmptyEntries);
}
public string[] Segments { get; }
public Type Handler { get; set; }
public IDictionary<string, string> Parameters { get; set; }
}
}

View File

@ -0,0 +1,61 @@
// 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;
namespace Microsoft.AspNetCore.Blazor.Routing
{
internal class RouteEntry
{
public RouteEntry(RouteTemplate template, Type handler)
{
Template = template;
Handler = handler;
}
public RouteTemplate Template { get; }
public Type Handler { get; }
internal void Match(RouteContext context)
{
if (Template.Segments.Length != context.Segments.Length)
{
return;
}
// Parameters will be lazily initialized.
IDictionary<string, string> 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))
{
return;
}
else
{
if (segment.IsParameter)
{
GetParameters()[segment.Value] = pathSegment;
}
}
}
context.Parameters = parameters;
context.Handler = Handler;
IDictionary<string, string> GetParameters()
{
if (parameters == null)
{
parameters = new Dictionary<string, string>();
}
return parameters;
}
}
}
}

View File

@ -0,0 +1,130 @@
// 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.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Blazor.Components;
namespace Microsoft.AspNetCore.Blazor.Routing
{
internal class RouteTable
{
public RouteTable(RouteEntry[] routes)
{
Routes = routes;
}
public RouteEntry[] Routes { get; set; }
public static RouteTable Create(IEnumerable<Type> types)
{
var routes = new List<RouteEntry>();
foreach (var type in types)
{
var routeAttributes = type.GetCustomAttributes<RouteAttribute>(); // Inherit: true?
foreach (var routeAttribute in routeAttributes)
{
var template = TemplateParser.ParseTemplate(routeAttribute.Template);
var entry = new RouteEntry(template, type);
routes.Add(entry);
}
}
return new RouteTable(routes.OrderBy(id => id, RoutePrecedence).ToArray());
}
public static IComparer<RouteEntry> RoutePrecedence { get; } = Comparer<RouteEntry>.Create(RouteComparison);
/// <summary>
/// Route precedence algorithm.
/// We collect all the routes and sort them from most specific to
/// 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.
/// * 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}
///
/// Routes can be ambigous if:
/// They are composed of literals and those literals have the same values (case insensitive)
/// They are composed of a mix of literals and parameters, in the same relative order and the
/// literals have the same values.
/// For example:
/// * /literal and /Literal
/// /{parameter}/literal and /{something}/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
/// 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)
{
var xTemplate = x.Template;
var yTemplate = y.Template;
if (xTemplate.Segments.Length != y.Template.Segments.Length)
{
return xTemplate.Segments.Length < y.Template.Segments.Length ? -1 : 1;
}
else
{
for (int i = 0; i < xTemplate.Segments.Length; i++)
{
var xSegment = xTemplate.Segments[i];
var ySegment = yTemplate.Segments[i];
if (!xSegment.IsParameter && ySegment.IsParameter)
{
return -1;
}
if (xSegment.IsParameter && !ySegment.IsParameter)
{
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)
{
return -1;
}
if (xSegment.IsParameter && !ySegment.IsParameter)
{
return 1;
}
if (!xSegment.IsParameter)
{
var comparison = string.Compare(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase);
if (comparison != 0)
{
return comparison;
}
}
}
throw new InvalidOperationException($@"The following routes are ambiguous:
'{x.Template.TemplateText}' in '{x.Handler.FullName}'
'{y.Template.TemplateText}' in '{y.Handler.FullName}'
");
}
}
internal void Route(RouteContext routeContext)
{
foreach (var route in Routes)
{
route.Match(routeContext);
if (routeContext.Handler != null)
{
return;
}
}
}
}
}

View File

@ -0,0 +1,21 @@
// 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
{
internal class RouteTemplate
{
public static readonly char[] Separators = new[] { '/' };
public RouteTemplate(string TemplateText, TemplateSegment[] segments)
{
this.TemplateText = TemplateText;
Segments = segments;
}
public string TemplateText { get; }
public TemplateSegment[] Segments { get; }
}
}

View File

@ -2,7 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Linq; using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using Microsoft.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.Components;
using Microsoft.AspNetCore.Blazor.Layouts; using Microsoft.AspNetCore.Blazor.Layouts;
@ -31,17 +31,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
/// </summary> /// </summary>
public Assembly AppAssembly { get; set; } public Assembly AppAssembly { get; set; }
/// <summary> private RouteTable Routes { get; set; }
/// Gets or sets the namespace prefix that should be prepended when searching
/// for matching components.
/// </summary>
public string PagesNamespace { get; set; }
/// <summary>
/// Gets or sets the component name that will be used if the URI ends with
/// a slash.
/// </summary>
public string DefaultComponentName { get; set; } = "Index";
/// <inheritdoc /> /// <inheritdoc />
public void Init(RenderHandle renderHandle) public void Init(RenderHandle renderHandle)
@ -56,6 +46,8 @@ namespace Microsoft.AspNetCore.Blazor.Routing
public void SetParameters(ParameterCollection parameters) public void SetParameters(ParameterCollection parameters)
{ {
parameters.AssignToProperties(this); parameters.AssignToProperties(this);
var types = ComponentResolver.ResolveComponents(AppAssembly);
Routes = RouteTable.Create(types);
Refresh(); Refresh();
} }
@ -65,29 +57,6 @@ namespace Microsoft.AspNetCore.Blazor.Routing
UriHelper.OnLocationChanged -= OnLocationChanged; UriHelper.OnLocationChanged -= OnLocationChanged;
} }
protected virtual Type GetComponentTypeForPath(string locationPath)
{
if (AppAssembly == null)
{
throw new InvalidOperationException($"No value was specified for {nameof(AppAssembly)}.");
}
if (string.IsNullOrEmpty(PagesNamespace))
{
throw new InvalidOperationException($"No value was specified for {nameof(PagesNamespace)}.");
}
locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
var componentTypeName = $"{PagesNamespace}{locationPath.Replace('/', '.')}";
if (componentTypeName[componentTypeName.Length - 1] == '.')
{
componentTypeName += DefaultComponentName;
}
return FindComponentTypeInAssemblyOrReferences(AppAssembly, componentTypeName)
?? throw new InvalidOperationException($"{nameof(Router)} cannot find any component type with name {componentTypeName}.");
}
private string StringUntilAny(string str, char[] chars) private string StringUntilAny(string str, char[] chars)
{ {
var firstIndex = str.IndexOfAny(chars); var firstIndex = str.IndexOfAny(chars);
@ -96,31 +65,32 @@ namespace Microsoft.AspNetCore.Blazor.Routing
: str.Substring(0, firstIndex); : str.Substring(0, firstIndex);
} }
private Type FindComponentTypeInAssemblyOrReferences(Assembly assembly, string typeName) protected virtual void Render(RenderTreeBuilder builder, Type handler, IDictionary<string, string> parameters)
=> assembly.GetType(typeName, throwOnError: false, ignoreCase: true)
?? assembly.GetReferencedAssemblies()
.Select(Assembly.Load)
.Select(referencedAssembly => FindComponentTypeInAssemblyOrReferences(referencedAssembly, typeName))
.FirstOrDefault();
protected virtual void Render(RenderTreeBuilder builder, Type matchedComponentType)
{ {
builder.OpenComponent(0, typeof(LayoutDisplay)); builder.OpenComponent(0, typeof(LayoutDisplay));
builder.AddAttribute(1, nameof(LayoutDisplay.Page), matchedComponentType); builder.AddAttribute(1, nameof(LayoutDisplay.Page), handler);
builder.AddAttribute(2, nameof(LayoutDisplay.PageParameters), parameters);
builder.CloseComponent(); builder.CloseComponent();
} }
private void Refresh() private void Refresh()
{ {
var locationPath = UriHelper.ToBaseRelativePath(_baseUriPrefix, _locationAbsolute); var locationPath = UriHelper.ToBaseRelativePath(_baseUriPrefix, _locationAbsolute);
var matchedComponentType = GetComponentTypeForPath(locationPath); locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
if (!typeof(IComponent).IsAssignableFrom(matchedComponentType)) var context = new RouteContext(locationPath);
Routes.Route(context);
if (context.Handler == null)
{ {
throw new InvalidOperationException($"The type {matchedComponentType.FullName} " + throw new InvalidOperationException($"'{nameof(Router)}' cannot find any component with a route for '{locationPath}'.");
}
if (!typeof(IComponent).IsAssignableFrom(context.Handler))
{
throw new InvalidOperationException($"The type {context.Handler.FullName} " +
$"does not implement {typeof(IComponent).FullName}."); $"does not implement {typeof(IComponent).FullName}.");
} }
_renderHandle.Render(builder => Render(builder, matchedComponentType)); _renderHandle.Render(builder => Render(builder, context.Handler, context.Parameters));
} }
private void OnLocationChanged(object sender, string newAbsoluteUri) private void OnLocationChanged(object sender, string newAbsoluteUri)

View File

@ -0,0 +1,107 @@
// 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;
namespace Microsoft.AspNetCore.Blazor.Routing
{
// This implementation is temporary, in the future we'll want to have
// 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.
// 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:
// * 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)
// * Parameter path segments (Like /Customer/{Id}/Orders/{OrderId})
internal class TemplateParser
{
public static readonly char[] InvalidParameterNameCharacters =
new char[] { '*', '?', '{', '}', '=', '.', ':' };
internal static RouteTemplate ParseTemplate(string template)
{
template = template.Trim('/');
if (template == "")
{
// Special case "/";
return new RouteTemplate("/", Array.Empty<TemplateSegment>());
}
var segments = template.Split('/');
var templateSegments = new TemplateSegment[segments.Length];
for (int i = 0; i < segments.Length; i++)
{
var segment = segments[i];
if (string.IsNullOrEmpty(segment))
{
throw new InvalidOperationException(
$"Invalid template '{template}'. Empty segments are not allowed.");
}
if (segment[0] != '{')
{
if (segment[segment.Length - 1] == '}')
{
throw new InvalidOperationException(
$"Invalid template '{template}'. Missing '{{' in parameter segment '{segment}'.");
}
templateSegments[i] = new TemplateSegment(segment, isParameter: false);
}
else
{
if (segment[segment.Length - 1] != '}')
{
throw new InvalidOperationException(
$"Invalid template '{template}'. Missing '}}' in parameter segment '{segment}'.");
}
if (segment.Length < 3)
{
throw new InvalidOperationException(
$"Invalid template '{template}'. Empty parameter name in segment '{segment}' is not allowed.");
}
var invalidCharacter = segment.IndexOfAny(InvalidParameterNameCharacters, 1, segment.Length - 2);
if (invalidCharacter != -1)
{
throw new InvalidOperationException(
$"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);
}
}
for (int i = 0; i < templateSegments.Length; i++)
{
var currentSegment = templateSegments[i];
if (!currentSegment.IsParameter)
{
continue;
}
for (int j = i + 1; j < templateSegments.Length; j++)
{
var nextSegment = templateSegments[j];
if (!nextSegment.IsParameter)
{
continue;
}
if (string.Equals(currentSegment.Value, nextSegment.Value, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Invalid template '{template}'. The parameter '{currentSegment}' appears multiple times.");
}
}
}
return new RouteTemplate(template, templateSegments);
}
}
}

View File

@ -0,0 +1,34 @@
// 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;
namespace Microsoft.AspNetCore.Blazor.Routing
{
internal class TemplateSegment
{
public TemplateSegment(string segment, bool isParameter)
{
Value = segment;
IsParameter = isParameter;
}
// The value of the segment. The exact text to match when is a literal.
// The parameter name when its a segment
public string Value { get; }
public bool IsParameter { get; }
public bool Match(string pathSegment)
{
if (IsParameter)
{
return true;
}
else
{
return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase);
}
}
}
}

View File

@ -32,6 +32,15 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text); Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
} }
[Fact]
public void CanArriveAtPageWithParameters()
{
SetUrlViaPushState($"{ServerPathBase}/RouterTest/WithParameters/Name/Dan/LastName/Roth");
var app = MountTestComponent<TestRouter>();
Assert.Equal("Your full name is Dan Roth.", app.FindElement(By.Id("test-info")).Text);
}
[Fact] [Fact]
public void CanArriveAtNonDefaultPage() public void CanArriveAtNonDefaultPage()
{ {
@ -61,6 +70,16 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text); Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
} }
[Fact]
public void CanFollowLinkToPageWithParameters()
{
SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other");
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("With parameters")).Click();
Assert.Equal("Your full name is Steve Sanderson.", app.FindElement(By.Id("test-info")).Text);
}
[Fact] [Fact]
public void CanFollowLinkToDefaultPage() public void CanFollowLinkToDefaultPage()
{ {

View File

@ -0,0 +1,218 @@
// 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.Linq;
using Microsoft.AspNetCore.Blazor.Routing;
using Xunit;
namespace Microsoft.AspNetCore.Blazor.Test.Routing
{
public class RouteTableTests
{
[Fact]
public void CanMatchRootTemplate()
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/").Build();
var context = new RouteContext("/");
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
}
[Fact]
public void CanMatchLiteralTemplate()
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/literal").Build();
var context = new RouteContext("/literal/");
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
}
[Fact]
public void CanMatchTemplateWithMultipleLiterals()
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/some/awesome/route/").Build();
var context = new RouteContext("/some/awesome/route");
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
}
[Fact]
public void RouteMatchingIsCaseInsensitive()
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/some/AWESOME/route/").Build();
var context = new RouteContext("/Some/awesome/RouTe");
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
}
[Fact]
public void DoesNotMatchIfSegmentsDontMatch()
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/some/AWESOME/route/").Build();
var context = new RouteContext("/some/brilliant/route");
// Act
routeTable.Route(context);
// Assert
Assert.Null(context.Handler);
}
[Theory]
[InlineData("/some")]
[InlineData("/some/awesome/route/with/extra/segments")]
public void DoesNotMatchIfDifferentNumberOfSegments(string path)
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/some/awesome/route/").Build();
var context = new RouteContext(path);
// Act
routeTable.Route(context);
// Assert
Assert.Null(context.Handler);
}
[Theory]
[InlineData("/value1", "value1")]
[InlineData("/value2/", "value2")]
public void CanMatchParameterTemplate(string path, string expectedValue)
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/{parameter}").Build();
var context = new RouteContext(path);
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
Assert.Single(context.Parameters, p => p.Key == "parameter" && p.Value == expectedValue);
}
[Fact]
public void CanMatchTemplateWithMultipleParameters()
{
// Arrange
var routeTable = new TestRouteTableBuilder().AddRoute("/{some}/awesome/{route}/").Build();
var context = new RouteContext("/an/awesome/path");
var expectedParameters = new Dictionary<string, string>
{
["some"] = "an",
["route"] = "path"
};
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
Assert.Equal(expectedParameters, context.Parameters);
}
[Fact]
public void PrefersLiteralTemplateOverTemplateWithParameters()
{
// Arrange
var routeTable = new TestRouteTableBuilder()
.AddRoute("/an/awesome/path")
.AddRoute("/{some}/awesome/{route}/").Build();
var context = new RouteContext("/an/awesome/path");
// Act
routeTable.Route(context);
// Assert
Assert.NotNull(context.Handler);
Assert.Null(context.Parameters);
}
[Fact]
public void PrefersShorterRoutesOverLongerRoutes()
{
// Arrange & Act
var handler = typeof(int);
var routeTable = new TestRouteTableBuilder()
.AddRoute("/an/awesome/path")
.AddRoute("/an/awesome/", handler).Build();
// Act
Assert.Equal("an/awesome", routeTable.Routes[0].Template.TemplateText);
}
[Fact]
public void ProducesAStableOrderForNonAmbiguousRoutes()
{
// Arrange & Act
var handler = typeof(int);
var routeTable = new TestRouteTableBuilder()
.AddRoute("/an/awesome/", handler)
.AddRoute("/a/brilliant/").Build();
// Act
Assert.Equal("a/brilliant", routeTable.Routes[0].Template.TemplateText);
}
[Theory]
[InlineData("/literal", "/Literal/")]
[InlineData("/{parameter}", "/{parameter}/")]
[InlineData("/literal/{parameter}", "/Literal/{something}")]
[InlineData("/{parameter}/literal/{something}", "{param}/Literal/{else}")]
public void DetectsAmbigousRoutes(string left, string right)
{
// Arrange
var expectedMessage = $@"The following routes are ambiguous:
'{left.Trim('/')}' in '{typeof(object).FullName}'
'{right.Trim('/')}' in '{typeof(object).FullName}'
";
// Act
var exception = Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
.AddRoute(left)
.AddRoute(right).Build());
Assert.Equal(expectedMessage, exception.Message);
}
private class TestRouteTableBuilder
{
IList<(string, Type)> _routeTemplates = new List<(string, Type)>();
Type _handler = typeof(object);
public TestRouteTableBuilder AddRoute(string template, Type handler = null)
{
_routeTemplates.Add((template, handler ?? _handler));
return this;
}
public RouteTable Build() => new RouteTable(_routeTemplates
.Select(rt => new RouteEntry(TemplateParser.ParseTemplate(rt.Item1), rt.Item2))
.OrderBy(id => id, RouteTable.RoutePrecedence)
.ToArray());
}
}
}

View File

@ -0,0 +1,198 @@
// 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.Linq;
using Xunit;
namespace Microsoft.AspNetCore.Blazor.Routing
{
public class TemplateParserTests
{
[Fact]
public void Parse_SingleLiteral()
{
// Arrange
var expected = new ExpectedTemplateBuilder().Literal("awesome");
// Act
var actual = TemplateParser.ParseTemplate("awesome");
// Assert
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
}
[Fact]
public void Parse_SingleParameter()
{
// Arrange
var template = "{p}";
var expected = new ExpectedTemplateBuilder().Parameter("p");
// Act
var actual = TemplateParser.ParseTemplate(template);
// Assert
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
}
[Fact]
public void Parse_MultipleLiterals()
{
// Arrange
var template = "awesome/cool/super";
var expected = new ExpectedTemplateBuilder().Literal("awesome").Literal("cool").Literal("super");
// Act
var actual = TemplateParser.ParseTemplate(template);
// Assert
Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
}
[Fact]
public void Parse_MultipleParameters()
{
// 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()
{
var ex = Assert.Throws<InvalidOperationException>(
() => TemplateParser.ParseTemplate("{p1}/literal/{p1}"));
var expectedMessage = "Invalid template '{p1}/literal/{p1}'. The parameter 'Microsoft.AspNetCore.Blazor.Routing.TemplateSegment' appears multiple times.";
Assert.Equal(expectedMessage, ex.Message);
}
[Theory]
[InlineData("p}", "Invalid template 'p}'. Missing '{' in parameter segment 'p}'.")]
[InlineData("{p", "Invalid template '{p'. Missing '}' in parameter segment '{p'.")]
[InlineData("Literal/p}", "Invalid template 'Literal/p}'. Missing '{' in parameter segment 'p}'.")]
[InlineData("Literal/{p", "Invalid template 'Literal/{p'. Missing '}' in parameter segment '{p'.")]
[InlineData("p}/Literal", "Invalid template 'p}/Literal'. Missing '{' in parameter segment 'p}'.")]
[InlineData("{p/Literal", "Invalid template '{p/Literal'. Missing '}' in parameter segment '{p'.")]
[InlineData("Another/p}/Literal", "Invalid template 'Another/p}/Literal'. Missing '{' in parameter segment 'p}'.")]
[InlineData("Another/{p/Literal", "Invalid template 'Another/{p/Literal'. Missing '}' in parameter segment '{p'.")]
public void InvalidTemplate_WithMismatchedBraces(string template, string expectedMessage)
{
var ex = Assert.Throws<InvalidOperationException>(
() => TemplateParser.ParseTemplate(template));
Assert.Equal(expectedMessage, ex.Message);
}
[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.")]
[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
var ex = Assert.Throws<InvalidOperationException>(() => TemplateParser.ParseTemplate(template));
Assert.Equal(expectedMessage, ex.Message);
}
[Fact]
public void InvalidTemplate_InvalidParameterNameWithEmptyNameThrows()
{
var ex = Assert.Throws<InvalidOperationException>(() => TemplateParser.ParseTemplate("{a}/{}/{z}"));
var expectedMessage = "Invalid template '{a}/{}/{z}'. Empty parameter name in segment '{}' is not allowed.";
Assert.Equal(expectedMessage, ex.Message);
}
[Fact]
public void InvalidTemplate_ConsecutiveSeparatorsSlashSlashThrows()
{
var ex = Assert.Throws<InvalidOperationException>(() => TemplateParser.ParseTemplate("{a}//{z}"));
var expectedMessage = "Invalid template '{a}//{z}'. Empty segments are not allowed.";
Assert.Equal(expectedMessage, ex.Message);
}
private class ExpectedTemplateBuilder
{
public IList<TemplateSegment> Segments { get; set; } = new List<TemplateSegment>();
public ExpectedTemplateBuilder Literal(string value)
{
Segments.Add(new TemplateSegment(value, isParameter: false));
return this;
}
public ExpectedTemplateBuilder Parameter(string value)
{
Segments.Add(new TemplateSegment(value, isParameter: true));
return this;
}
public RouteTemplate Build() => new RouteTemplate(string.Join('/', Segments), Segments.ToArray());
public static implicit operator RouteTemplate(ExpectedTemplateBuilder builder) => builder.Build();
}
private class RouteTemplateTestComparer : IEqualityComparer<RouteTemplate>
{
public static RouteTemplateTestComparer Instance { get; } = new RouteTemplateTestComparer();
public bool Equals(RouteTemplate x, RouteTemplate y)
{
if (x == null && y == null)
{
return true;
}
if ((x == null) != (y == null))
{
return false;
}
if (x.Segments.Length != y.Segments.Length)
{
return false;
}
for (int i = 0; i < x.Segments.Length; i++)
{
var xSegment = x.Segments[i];
var ySegment = y.Segments[i];
if (xSegment.IsParameter != ySegment.IsParameter)
{
return false;
}
if (!string.Equals(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
public int GetHashCode(RouteTemplate obj) => 0;
}
}
}

View File

@ -1,3 +1,4 @@
@using BasicTestApp.RouterTest @page "/RouterTest"
@using BasicTestApp.RouterTest
<div id="test-info">This is the default page.</div> <div id="test-info">This is the default page.</div>
<Links /> <Links />

View File

@ -1,5 +1,5 @@
@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper @page "/Links"
@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
<ul> <ul>
<li><a href="/subdir/RouterTest/">Default</a></li> <li><a href="/subdir/RouterTest/">Default</a></li>
<li><a href="/subdir/RouterTest/?abc=123">Default with query</a></li> <li><a href="/subdir/RouterTest/?abc=123">Default with query</a></li>
@ -8,6 +8,7 @@
<li><a href="RouterTest/Other">Other with base-relative URL</a></li> <li><a href="RouterTest/Other">Other with base-relative URL</a></li>
<li><a href="/subdir/RouterTest/Other?abc=123">Other with query</a></li> <li><a href="/subdir/RouterTest/Other?abc=123">Other with query</a></li>
<li><a href="/subdir/RouterTest/Other#blah">Other with hash</a></li> <li><a href="/subdir/RouterTest/Other#blah">Other with hash</a></li>
<li><a href="/subdir/RouterTest/WithParameters/Name/Steve/LastName/Sanderson">With parameters</a></li>
</ul> </ul>
<button onclick=@{ uriHelper.NavigateTo("RouterTest/Other"); }> <button onclick=@{ uriHelper.NavigateTo("RouterTest/Other"); }>

View File

@ -1,3 +1,4 @@
@using BasicTestApp.RouterTest @page "/RouterTest/Other"
@using BasicTestApp.RouterTest
<div id="test-info">This is another page.</div> <div id="test-info">This is another page.</div>
<Links /> <Links />

View File

@ -1,4 +1,2 @@
@using Microsoft.AspNetCore.Blazor.Routing @using Microsoft.AspNetCore.Blazor.Routing
<Router AppAssembly=typeof(BasicTestApp.Program).Assembly <Router AppAssembly=typeof(BasicTestApp.Program).Assembly />
PagesNamespace=@nameof(BasicTestApp)
DefaultComponentName="Default" />

View File

@ -0,0 +1,10 @@
@page "/RouterTest/WithParameters/Name/{firstName}/LastName/{lastName}"
@using BasicTestApp.RouterTest
<div id="test-info">Your full name is @FirstName @LastName.</div>
@functions
{
public string FirstName { get; set; }
public string LastName { get ; set; }
}