diff --git a/samples/StandaloneApp/App.cshtml b/samples/StandaloneApp/App.cshtml
index d04c20226c..4b8a0ffa42 100644
--- a/samples/StandaloneApp/App.cshtml
+++ b/samples/StandaloneApp/App.cshtml
@@ -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.
-->
-
+
diff --git a/samples/StandaloneApp/Pages/Counter.cshtml b/samples/StandaloneApp/Pages/Counter.cshtml
index 88367f46fa..3677204440 100644
--- a/samples/StandaloneApp/Pages/Counter.cshtml
+++ b/samples/StandaloneApp/Pages/Counter.cshtml
@@ -1,4 +1,5 @@
-
Counter
+@page "/counter"
+Counter
Current count: @currentCount
diff --git a/samples/StandaloneApp/Pages/FetchData.cshtml b/samples/StandaloneApp/Pages/FetchData.cshtml
index ef6efd2958..37484dbed4 100644
--- a/samples/StandaloneApp/Pages/FetchData.cshtml
+++ b/samples/StandaloneApp/Pages/FetchData.cshtml
@@ -1,4 +1,5 @@
-@inject HttpClient Http
+@page "/fetchdata"
+@inject HttpClient Http
Weather forecast
diff --git a/samples/StandaloneApp/Pages/Index.cshtml b/samples/StandaloneApp/Pages/Index.cshtml
index f75a462181..86eb281395 100644
--- a/samples/StandaloneApp/Pages/Index.cshtml
+++ b/samples/StandaloneApp/Pages/Index.cshtml
@@ -1,3 +1,4 @@
-Hello, world!
+@page "/"
+Hello, world!
Welcome to your new app.
diff --git a/src/Microsoft.AspNetCore.Blazor/Components/ComponentResolver.cs b/src/Microsoft.AspNetCore.Blazor/Components/ComponentResolver.cs
new file mode 100644
index 0000000000..41efd48bbf
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Components/ComponentResolver.cs
@@ -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
+{
+ ///
+ /// Resolves components for an application.
+ ///
+ internal class ComponentResolver
+ {
+ ///
+ /// Lists all the types
+ ///
+ ///
+ ///
+ public static IEnumerable ResolveComponents(Assembly appAssembly)
+ {
+ var blazorAssembly = typeof(IComponent).Assembly;
+
+ return EnumerateAssemblies(appAssembly.GetName(), blazorAssembly, new HashSet(new AssemblyComparer()))
+ .SelectMany(a => a.ExportedTypes)
+ .Where(t => typeof(IComponent).IsAssignableFrom(t));
+ }
+
+ private static IEnumerable EnumerateAssemblies(
+ AssemblyName assemblyName,
+ Assembly blazorAssembly,
+ HashSet 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
+ {
+ 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();
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs b/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs
index 4fb9d69a2a..1f39d7df85 100644
--- a/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs
+++ b/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs
@@ -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(
diff --git a/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs b/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs
index 80a30c5576..cb95a0aa7e 100644
--- a/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs
+++ b/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs
@@ -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;
-
+
///
/// Gets or sets the type of the page component to display.
/// The type must implement .
///
public Type Page { get; set; }
+ ///
+ /// Gets or sets the parameters to pass to the page.
+ ///
+ public IDictionary PageParameters { get; set; }
+
///
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();
};
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs
new file mode 100644
index 0000000000..cf7fcfa4e0
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs
@@ -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 Parameters { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs
new file mode 100644
index 0000000000..3fc73ab475
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs
@@ -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 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 GetParameters()
+ {
+ if (parameters == null)
+ {
+ parameters = new Dictionary();
+ }
+
+ return parameters;
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs
new file mode 100644
index 0000000000..efaeff92db
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs
@@ -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 types)
+ {
+ var routes = new List();
+ foreach (var type in types)
+ {
+ var routeAttributes = type.GetCustomAttributes(); // 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 RoutePrecedence { get; } = Comparer.Create(RouteComparison);
+
+ ///
+ /// 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;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs
new file mode 100644
index 0000000000..d673ecb303
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs
@@ -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; }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs b/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs
index 4691689ed5..1a9ffaa02b 100644
--- a/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs
@@ -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
///
public Assembly AppAssembly { get; set; }
- ///
- /// Gets or sets the namespace prefix that should be prepended when searching
- /// for matching components.
- ///
- public string PagesNamespace { get; set; }
-
- ///
- /// Gets or sets the component name that will be used if the URI ends with
- /// a slash.
- ///
- public string DefaultComponentName { get; set; } = "Index";
+ private RouteTable Routes { get; set; }
///
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 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)
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs
new file mode 100644
index 0000000000..d999c0f32e
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs
@@ -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());
+ }
+
+ 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);
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs
new file mode 100644
index 0000000000..b7e46086c6
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs
@@ -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);
+ }
+ }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs
index 272cd9e632..e6f04cde60 100644
--- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs
+++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs
@@ -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();
+ 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();
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();
+ 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()
{
diff --git a/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs b/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs
new file mode 100644
index 0000000000..3a0087beaa
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs
@@ -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
+ {
+ ["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(() => 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());
+ }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs b/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs
new file mode 100644
index 0000000000..d9fb20a54a
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs
@@ -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(
+ () => 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(
+ () => 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(() => TemplateParser.ParseTemplate(template));
+
+ Assert.Equal(expectedMessage, ex.Message);
+ }
+
+ [Fact]
+ public void InvalidTemplate_InvalidParameterNameWithEmptyNameThrows()
+ {
+ var ex = Assert.Throws(() => 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(() => 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 Segments { get; set; } = new List();
+
+ 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
+ {
+ 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;
+ }
+ }
+}
diff --git a/test/testapps/BasicTestApp/RouterTest/Default.cshtml b/test/testapps/BasicTestApp/RouterTest/Default.cshtml
index fb8b327e4d..3118c9d386 100644
--- a/test/testapps/BasicTestApp/RouterTest/Default.cshtml
+++ b/test/testapps/BasicTestApp/RouterTest/Default.cshtml
@@ -1,3 +1,4 @@
-@using BasicTestApp.RouterTest
+@page "/RouterTest"
+@using BasicTestApp.RouterTest
This is the default page.
diff --git a/test/testapps/BasicTestApp/RouterTest/Links.cshtml b/test/testapps/BasicTestApp/RouterTest/Links.cshtml
index 9490da1725..36ca9857b8 100644
--- a/test/testapps/BasicTestApp/RouterTest/Links.cshtml
+++ b/test/testapps/BasicTestApp/RouterTest/Links.cshtml
@@ -1,5 +1,5 @@
-@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
-
+@page "/Links"
+@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
diff --git a/test/testapps/BasicTestApp/RouterTest/Other.cshtml b/test/testapps/BasicTestApp/RouterTest/Other.cshtml
index 72a79a1cd0..633800b0bd 100644
--- a/test/testapps/BasicTestApp/RouterTest/Other.cshtml
+++ b/test/testapps/BasicTestApp/RouterTest/Other.cshtml
@@ -1,3 +1,4 @@
-@using BasicTestApp.RouterTest
+@page "/RouterTest/Other"
+@using BasicTestApp.RouterTest
This is another page.
diff --git a/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml b/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml
index d3c34ffcd4..b9c48dff7e 100644
--- a/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml
+++ b/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml
@@ -1,4 +1,2 @@
@using Microsoft.AspNetCore.Blazor.Routing
-
+
diff --git a/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml b/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml
new file mode 100644
index 0000000000..b31a871071
--- /dev/null
+++ b/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml
@@ -0,0 +1,10 @@
+@page "/RouterTest/WithParameters/Name/{firstName}/LastName/{lastName}"
+@using BasicTestApp.RouterTest
+Your full name is @FirstName @LastName.
+
+@functions
+{
+ public string FirstName { get; set; }
+
+ public string LastName { get ; set; }
+}