[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:
parent
2150820efe
commit
70c4501497
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<h1>Counter</h1>
|
||||
@page "/counter"
|
||||
<h1>Counter</h1>
|
||||
|
||||
<p>Current count: @currentCount</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@inject HttpClient Http
|
||||
@page "/fetchdata"
|
||||
@inject HttpClient Http
|
||||
|
||||
<h1>Weather forecast</h1>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<h1>Hello, world!</h1>
|
||||
@page "/"
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
@using BasicTestApp.RouterTest
|
||||
@page "/RouterTest"
|
||||
@using BasicTestApp.RouterTest
|
||||
<div id="test-info">This is the default page.</div>
|
||||
<Links />
|
||||
|
|
|
|||
|
|
@ -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"); }>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
@using BasicTestApp.RouterTest
|
||||
@page "/RouterTest/Other"
|
||||
@using BasicTestApp.RouterTest
|
||||
<div id="test-info">This is another page.</div>
|
||||
<Links />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
Loading…
Reference in New Issue