[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
into Program.cs, and it won't be necessary to specify AppAssembly.
-->
<Router
AppAssembly=typeof(StandaloneApp.Program).Assembly
PagesNamespace="StandaloneApp.Pages" />
<Router AppAssembly=typeof(StandaloneApp.Program).Assembly />

View File

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

View File

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

View File

@ -1,3 +1,4 @@
<h1>Hello, world!</h1>
@page "/"
<h1>Hello, world!</h1>
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)
{
var property = targetType.GetProperty(propertyName);
var flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
var property = targetType.GetProperty(propertyName, flags);
if (property == null)
{
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.
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.AspNetCore.Blazor.Components;
using Microsoft.AspNetCore.Blazor.RenderTree;
@ -15,13 +16,18 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
public class LayoutDisplay : IComponent
{
private RenderHandle _renderHandle;
/// <summary>
/// Gets or sets the type of the page component to display.
/// The type must implement <see cref="IComponent"/>.
/// </summary>
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 />
public void Init(RenderHandle renderHandle)
{
@ -47,7 +53,7 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
{
fragment = RenderComponentWithBody(layoutType, fragment);
}
_renderHandle.Render(fragment);
}
@ -58,6 +64,16 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
{
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();
};

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.
using System;
using System.Linq;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.AspNetCore.Blazor.Components;
using Microsoft.AspNetCore.Blazor.Layouts;
@ -31,17 +31,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
/// </summary>
public Assembly AppAssembly { get; set; }
/// <summary>
/// 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";
private RouteTable Routes { get; set; }
/// <inheritdoc />
public void Init(RenderHandle renderHandle)
@ -56,6 +46,8 @@ namespace Microsoft.AspNetCore.Blazor.Routing
public void SetParameters(ParameterCollection parameters)
{
parameters.AssignToProperties(this);
var types = ComponentResolver.ResolveComponents(AppAssembly);
Routes = RouteTable.Create(types);
Refresh();
}
@ -65,29 +57,6 @@ namespace Microsoft.AspNetCore.Blazor.Routing
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)
{
var firstIndex = str.IndexOfAny(chars);
@ -96,31 +65,32 @@ namespace Microsoft.AspNetCore.Blazor.Routing
: str.Substring(0, firstIndex);
}
private Type FindComponentTypeInAssemblyOrReferences(Assembly assembly, string typeName)
=> 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)
protected virtual void Render(RenderTreeBuilder builder, Type handler, IDictionary<string, string> parameters)
{
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();
}
private void Refresh()
{
var locationPath = UriHelper.ToBaseRelativePath(_baseUriPrefix, _locationAbsolute);
var matchedComponentType = GetComponentTypeForPath(locationPath);
if (!typeof(IComponent).IsAssignableFrom(matchedComponentType))
locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
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}.");
}
_renderHandle.Render(builder => Render(builder, matchedComponentType));
_renderHandle.Render(builder => Render(builder, context.Handler, context.Parameters));
}
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);
}
[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]
public void CanArriveAtNonDefaultPage()
{
@ -54,13 +63,23 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
[Fact]
public void CanFollowLinkToOtherPageWithBaseRelativeUrl()
{
SetUrlViaPushState($"{ServerPathBase}/RouterTest/");
SetUrlViaPushState($"{ServerPathBase}/RouterTest/");
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other with base-relative URL")).Click();
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]
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>
<Links />

View File

@ -1,5 +1,5 @@
@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
@page "/Links"
@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
<ul>
<li><a href="/subdir/RouterTest/">Default</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="/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/WithParameters/Name/Steve/LastName/Sanderson">With parameters</a></li>
</ul>
<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>
<Links />

View File

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

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