From 70c45014978d664890a3d4c5bd50e67f99f16ca5 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson <jacalvar@microsoft.com> Date: Wed, 14 Mar 2018 16:00:58 -0700 Subject: [PATCH] [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. --- samples/StandaloneApp/App.cshtml | 4 +- samples/StandaloneApp/Pages/Counter.cshtml | 3 +- samples/StandaloneApp/Pages/FetchData.cshtml | 3 +- samples/StandaloneApp/Pages/Index.cshtml | 3 +- .../Components/ComponentResolver.cs | 73 ++++++ .../ParameterCollectionExtensions.cs | 3 +- .../Layouts/LayoutDisplay.cs | 20 +- .../Routing/RouteContext.cs | 26 +++ .../Routing/RouteEntry.cs | 61 +++++ .../Routing/RouteTable.cs | 130 +++++++++++ .../Routing/RouteTemplate.cs | 21 ++ .../Routing/Router.cs | 66 ++---- .../Routing/TemplateParser.cs | 107 +++++++++ .../Routing/TemplateSegment.cs | 34 +++ .../Tests/RoutingTest.cs | 21 +- .../Routing/RouteTableTests.cs | 218 ++++++++++++++++++ .../Routing/TemplateParserTests.cs | 198 ++++++++++++++++ .../BasicTestApp/RouterTest/Default.cshtml | 3 +- .../BasicTestApp/RouterTest/Links.cshtml | 5 +- .../BasicTestApp/RouterTest/Other.cshtml | 3 +- .../BasicTestApp/RouterTest/TestRouter.cshtml | 4 +- .../RouterTest/WithParameters.cshtml | 10 + 22 files changed, 951 insertions(+), 65 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Blazor/Components/ComponentResolver.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs create mode 100644 test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml diff --git a/samples/StandaloneApp/App.cshtml b/samples/StandaloneApp/App.cshtml index d04c20226c6..4b8a0ffa42d 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. --> -<Router - AppAssembly=typeof(StandaloneApp.Program).Assembly - PagesNamespace="StandaloneApp.Pages" /> +<Router AppAssembly=typeof(StandaloneApp.Program).Assembly /> diff --git a/samples/StandaloneApp/Pages/Counter.cshtml b/samples/StandaloneApp/Pages/Counter.cshtml index 88367f46fa0..36772044403 100644 --- a/samples/StandaloneApp/Pages/Counter.cshtml +++ b/samples/StandaloneApp/Pages/Counter.cshtml @@ -1,4 +1,5 @@ -<h1>Counter</h1> +@page "/counter" +<h1>Counter</h1> <p>Current count: @currentCount</p> diff --git a/samples/StandaloneApp/Pages/FetchData.cshtml b/samples/StandaloneApp/Pages/FetchData.cshtml index ef6efd29584..37484dbed48 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 <h1>Weather forecast</h1> diff --git a/samples/StandaloneApp/Pages/Index.cshtml b/samples/StandaloneApp/Pages/Index.cshtml index f75a462181b..86eb2813957 100644 --- a/samples/StandaloneApp/Pages/Index.cshtml +++ b/samples/StandaloneApp/Pages/Index.cshtml @@ -1,3 +1,4 @@ -<h1>Hello, world!</h1> +@page "/" +<h1>Hello, world!</h1> 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 00000000000..41efd48bbf1 --- /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 +{ + /// <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(); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs b/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs index 4fb9d69a2ac..1f39d7df852 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 80a30c55764..cb95a0aa7e3 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; - + /// <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(); }; diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs new file mode 100644 index 00000000000..cf7fcfa4e0f --- /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<string, string> 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 00000000000..3fc73ab4754 --- /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<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; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs new file mode 100644 index 00000000000..efaeff92db3 --- /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<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; + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs new file mode 100644 index 00000000000..d673ecb3037 --- /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 4691689ed52..1a9ffaa02b2 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 /// </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($"'{nameof(Router)}' cannot find any component with a route for '{locationPath}'."); + } + + if (!typeof(IComponent).IsAssignableFrom(context.Handler)) { - throw new InvalidOperationException($"The type {matchedComponentType.FullName} " + + 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 00000000000..d999c0f32ed --- /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<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); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs new file mode 100644 index 00000000000..b7e46086c6c --- /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 272cd9e632c..e6f04cde609 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<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() { 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 00000000000..3a0087beaac --- /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<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()); + } + } +} 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 00000000000..d9fb20a54a9 --- /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<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; + } + } +} diff --git a/test/testapps/BasicTestApp/RouterTest/Default.cshtml b/test/testapps/BasicTestApp/RouterTest/Default.cshtml index fb8b327e4d6..3118c9d3863 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 <div id="test-info">This is the default page.</div> <Links /> diff --git a/test/testapps/BasicTestApp/RouterTest/Links.cshtml b/test/testapps/BasicTestApp/RouterTest/Links.cshtml index 9490da17253..36ca9857b81 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 <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"); }> diff --git a/test/testapps/BasicTestApp/RouterTest/Other.cshtml b/test/testapps/BasicTestApp/RouterTest/Other.cshtml index 72a79a1cd03..633800b0bd8 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 <div id="test-info">This is another page.</div> <Links /> diff --git a/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml b/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml index d3c34ffcd46..b9c48dff7e2 100644 --- a/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml +++ b/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml @@ -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 /> diff --git a/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml b/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml new file mode 100644 index 00000000000..b31a8710716 --- /dev/null +++ b/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml @@ -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; } +} -- GitLab