Add fallback routing for controllers and pages

This commit is contained in:
Ryan Nowak 2019-02-28 15:57:51 -08:00
parent bb28db6fb2
commit a1ec03e1e6
23 changed files with 1130 additions and 13 deletions

View File

@ -4,7 +4,6 @@
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
namespace Microsoft.AspNetCore.Builder
{

View File

@ -9,6 +9,10 @@ namespace Microsoft.AspNetCore.Builder
public static void MapControllerRoute(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string name, string template, object defaults = null, object constraints = null, object dataTokens = null) { }
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapControllers(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes) { throw null; }
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapDefaultControllerRoute(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes) { throw null; }
public static void MapFallbackToAreaController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string action, string controller, string area) { }
public static void MapFallbackToAreaController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string pattern, string action, string controller, string area) { }
public static void MapFallbackToController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string action, string controller) { }
public static void MapFallbackToController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string pattern, string action, string controller) { }
}
public static partial class MvcApplicationBuilderExtensions
{

View File

@ -3,6 +3,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Core;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
@ -152,6 +153,296 @@ namespace Microsoft.AspNetCore.Builder
routes.MapControllerRoute(name, template, defaultsDictionary, constraintsDictionary, dataTokens);
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-file-names with the lowest possible priority. The request will be routed to a controller endpoint that
/// matches <paramref name="action"/>, and <paramref name="controller"/>.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="action">The action name.</param>
/// <param name="controller">The controller name.</param>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToController(IEndpointRouteBuilder, string, string)"/> is intended to handle cases where URL path of
/// the request does not contain a file name, and no other endpoint has matched. This is convenient for routing
/// requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to
/// result in an HTTP 404.
/// </para>
/// <para>
/// <see cref="MapFallbackToController(IEndpointRouteBuilder, string, string)"/> registers an endpoint using the pattern
/// <c>{*path:nonfile}</c>. The order of the registered endpoint will be <c>int.MaxValue</c>.
/// </para>
/// <para>
/// <see cref="MapFallbackToController(IEndpointRouteBuilder, string, string)"/> does not re-execute routing, and will
/// not generate route values based on routes defined elsewhere. When using this overload, the <c>path</c> route value
/// will be available.
/// </para>
/// <para>
/// <see cref="MapFallbackToController(IEndpointRouteBuilder, string, string)"/> does not attempt to disambiguate between
/// multiple actions that match the provided <paramref name="action"/> and <paramref name="controller"/>. If multiple
/// actions match these values, the result is implementation defined.
/// </para>
/// </remarks>
public static void MapFallbackToController(
this IEndpointRouteBuilder routes,
string action,
string controller)
{
if (routes == null)
{
throw new ArgumentNullException(nameof(routes));
}
if (action == null)
{
throw new ArgumentNullException(nameof(action));
}
if (controller == null)
{
throw new ArgumentNullException(nameof(controller));
}
EnsureControllerServices(routes);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(routes);
// Maps a fallback endpoint with an empty delegate. This is OK because
// we don't expect the delegate to run.
routes.MapFallback(context => Task.CompletedTask).Add(b =>
{
// MVC registers a policy that looks for this metadata.
b.Metadata.Add(CreateDynamicControllerMetadata(action, controller, area: null));
});
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-file-names with the lowest possible priority. The request will be routed to a controller endpoint that
/// matches <paramref name="action"/>, and <paramref name="controller"/>.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="pattern">The route pattern.</param>
/// <param name="action">The action name.</param>
/// <param name="controller">The controller name.</param>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToController(IEndpointRouteBuilder, string, string, string)"/> is intended to handle cases
/// where URL path of the request does not contain a file name, and no other endpoint has matched. This is convenient
/// for routing requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to
/// result in an HTTP 404.
/// </para>
/// <para>
/// The order of the registered endpoint will be <c>int.MaxValue</c>.
/// </para>
/// <para>
/// This overload will use the provided <paramref name="pattern"/> verbatim. Use the <c>:nonfile</c> route contraint
/// to exclude requests for static files.
/// </para>
/// <para>
/// <see cref="MapFallbackToController(IEndpointRouteBuilder, string, string, string)"/> does not re-execute routing, and will
/// not generate route values based on routes defined elsewhere. When using this overload, the route values provided by matching
/// <paramref name="pattern"/> will be available.
/// </para>
/// <para>
/// <see cref="MapFallbackToController(IEndpointRouteBuilder, string, string, string)"/> does not attempt to disambiguate between
/// multiple actions that match the provided <paramref name="action"/> and <paramref name="controller"/>. If multiple
/// actions match these values, the result is implementation defined.
/// </para>
/// </remarks>
public static void MapFallbackToController(
this IEndpointRouteBuilder routes,
string pattern,
string action,
string controller)
{
if (routes == null)
{
throw new ArgumentNullException(nameof(routes));
}
if (pattern == null)
{
throw new ArgumentNullException(nameof(pattern));
}
if (action == null)
{
throw new ArgumentNullException(nameof(action));
}
if (controller == null)
{
throw new ArgumentNullException(nameof(controller));
}
EnsureControllerServices(routes);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(routes);
// Maps a fallback endpoint with an empty delegate. This is OK because
// we don't expect the delegate to run.
routes.MapFallback(pattern, context => Task.CompletedTask).Add(b =>
{
// MVC registers a policy that looks for this metadata.
b.Metadata.Add(CreateDynamicControllerMetadata(action, controller, area: null));
});
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-file-names with the lowest possible priority. The request will be routed to a controller endpoint that
/// matches <paramref name="action"/>, <paramref name="controller"/>, and <paramref name="area"/>.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="action">The action name.</param>
/// <param name="controller">The controller name.</param>
/// <param name="area">The area name.</param>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToAreaController(IEndpointRouteBuilder, string, string, string)"/> is intended to handle cases where URL path of
/// the request does not contain a file name, and no other endpoint has matched. This is convenient for routing
/// requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to
/// result in an HTTP 404.
/// </para>
/// <para>
/// <see cref="MapFallbackToAreaController(IEndpointRouteBuilder, string, string, string)"/> registers an endpoint using the pattern
/// <c>{*path:nonfile}</c>. The order of the registered endpoint will be <c>int.MaxValue</c>.
/// </para>
/// <para>
/// <see cref="MapFallbackToAreaController(IEndpointRouteBuilder, string, string, string)"/> does not re-execute routing, and will
/// not generate route values based on routes defined elsewhere. When using this overload, the <c>path</c> route value
/// will be available.
/// </para>
/// <para>
/// <see cref="MapFallbackToAreaController(IEndpointRouteBuilder, string, string, string)"/> does not attempt to disambiguate between
/// multiple actions that match the provided <paramref name="action"/>, <paramref name="controller"/>, and <paramref name="area"/>. If multiple
/// actions match these values, the result is implementation defined.
/// </para>
/// </remarks>
public static void MapFallbackToAreaController(
this IEndpointRouteBuilder routes,
string action,
string controller,
string area)
{
if (routes == null)
{
throw new ArgumentNullException(nameof(routes));
}
if (action == null)
{
throw new ArgumentNullException(nameof(action));
}
if (controller == null)
{
throw new ArgumentNullException(nameof(controller));
}
EnsureControllerServices(routes);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(routes);
// Maps a fallback endpoint with an empty delegate. This is OK because
// we don't expect the delegate to run.
routes.MapFallback(context => Task.CompletedTask).Add(b =>
{
// MVC registers a policy that looks for this metadata.
b.Metadata.Add(CreateDynamicControllerMetadata(action, controller, area));
});
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-file-names with the lowest possible priority. The request will be routed to a controller endpoint that
/// matches <paramref name="action"/>, <paramref name="controller"/>, and <paramref name="area"/>.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="pattern">The route pattern.</param>
/// <param name="action">The action name.</param>
/// <param name="controller">The controller name.</param>
/// <param name="area">The area name.</param>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToAreaController(IEndpointRouteBuilder, string, string, string, string)"/> is intended to handle
/// cases where URL path of the request does not contain a file name, and no other endpoint has matched. This is
/// convenient for routing requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to
/// result in an HTTP 404.
/// </para>
/// <para>
/// The order of the registered endpoint will be <c>int.MaxValue</c>.
/// </para>
/// <para>
/// This overload will use the provided <paramref name="pattern"/> verbatim. Use the <c>:nonfile</c> route contraint
/// to exclude requests for static files.
/// </para>
/// <para>
/// <see cref="MapFallbackToAreaController(IEndpointRouteBuilder, string, string, string, string)"/> does not re-execute routing, and will
/// not generate route values based on routes defined elsewhere. When using this overload, the route values provided by matching
/// <paramref name="pattern"/> will be available.
/// </para>
/// <para>
/// <see cref="MapFallbackToAreaController(IEndpointRouteBuilder, string, string, string, string)"/> does not attempt to disambiguate between
/// multiple actions that match the provided <paramref name="action"/>, <paramref name="controller"/>, and <paramref name="area"/>. If multiple
/// actions match these values, the result is implementation defined.
/// </para>
/// </remarks>
public static void MapFallbackToAreaController(
this IEndpointRouteBuilder routes,
string pattern,
string action,
string controller,
string area)
{
if (routes == null)
{
throw new ArgumentNullException(nameof(routes));
}
if (pattern == null)
{
throw new ArgumentNullException(nameof(pattern));
}
if (action == null)
{
throw new ArgumentNullException(nameof(action));
}
if (controller == null)
{
throw new ArgumentNullException(nameof(controller));
}
EnsureControllerServices(routes);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(routes);
// Maps a fallback endpoint with an empty delegate. This is OK because
// we don't expect the delegate to run.
routes.MapFallback(pattern, context => Task.CompletedTask).Add(b =>
{
// MVC registers a policy that looks for this metadata.
b.Metadata.Add(CreateDynamicControllerMetadata(action, controller, area));
});
}
private static DynamicControllerMetadata CreateDynamicControllerMetadata(string action, string controller, string area)
{
return new DynamicControllerMetadata(new RouteValueDictionary()
{
{ "action", action },
{ "controller", controller },
{ "area", area }
});
}
private static void EnsureControllerServices(IEndpointRouteBuilder routes)
{
var marker = routes.ServiceProvider.GetService<MvcMarkerService>();

View File

@ -272,6 +272,8 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddSingleton<ActionEndpointDataSource>();
services.TryAddSingleton<ControllerActionEndpointDataSource>();
services.TryAddSingleton<ActionEndpointFactory>();
services.TryAddSingleton<DynamicControllerEndpointSelector>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, DynamicControllerEndpointMatcherPolicy>());
//
// Middleware pipeline filter related

View File

@ -84,7 +84,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
// Only include RouteEndpoints and only those that aren't suppressed.
items: endpoints.OfType<RouteEndpoint>().Where(e =>
{
return e.Metadata.GetMetadata<ISuppressMatchingMetadata>().SuppressMatching != true;
return e.Metadata.GetMetadata<ISuppressMatchingMetadata>()?.SuppressMatching != true;
}),
getRouteKeys: e => e.RoutePattern.RequiredValues.Keys,

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Internal;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
@ -60,20 +59,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
return 0;
}
var hash = new HashCodeCombiner();
var hash = new HashCode();
for (var i = 0; i < obj.Length; i++)
{
var o = obj[i];
// Route values define null and "" to be equivalent.
if (string.IsNullOrEmpty(o))
{
o = null;
}
hash.Add(o, _valueComparer);
hash.Add(obj[i] ?? string.Empty, _valueComparer);
}
return hash.CombinedHash;
return hash.ToHashCode();
}
}
}

View File

@ -0,0 +1,120 @@
// 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.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
namespace Microsoft.AspNetCore.Mvc.Routing
{
internal class DynamicControllerEndpointMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
private readonly DynamicControllerEndpointSelector _selector;
public DynamicControllerEndpointMatcherPolicy(DynamicControllerEndpointSelector selector)
{
if (selector == null)
{
throw new ArgumentNullException(nameof(selector));
}
_selector = selector;
}
public override int Order => int.MinValue + 100;
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
{
if (endpoints == null)
{
throw new ArgumentNullException(nameof(endpoints));
}
if (!ContainsDynamicEndpoints(endpoints))
{
// Dynamic controller endpoints are always dynamic endpoints.
return false;
}
for (var i = 0; i < endpoints.Count; i++)
{
var metadata = endpoints[i].Metadata.GetMetadata<DynamicControllerMetadata>();
if (metadata != null)
{
// Found a dynamic controller endpoint
return true;
}
}
return false;
}
public Task ApplyAsync(HttpContext httpContext, EndpointSelectorContext context, CandidateSet candidates)
{
if (httpContext == null)
{
throw new ArgumentNullException(nameof(httpContext));
}
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (candidates == null)
{
throw new ArgumentNullException(nameof(candidates));
}
// There's no real benefit here from trying to avoid the async state machine.
// We only execute on nodes that contain a dynamic policy, and thus always have
// to await something.
for (var i = 0; i < candidates.Count; i++)
{
if (!candidates.IsValidCandidate(i))
{
continue;
}
var endpoint = candidates[i].Endpoint;
var metadata = endpoint.Metadata.GetMetadata<DynamicControllerMetadata>();
if (metadata == null)
{
continue;
}
var matchedValues = candidates[i].Values;
var endpoints = _selector.SelectEndpoints(metadata.Values);
if (endpoints.Count == 0)
{
// If there's no match this is a configuration error. We can't really check
// during startup that the action you configured exists.
throw new InvalidOperationException(
"Cannot find the fallback endpoint specified by route values: " +
"{ " + string.Join(", ", metadata.Values.Select(kvp => $"{kvp.Key}: {kvp.Value}")) + " }.");
}
var replacement = endpoints[0];
// We need to provide the route values associated with this endpoint, so that features
// like URL generation work.
var values = new RouteValueDictionary(metadata.Values);
// Include values that were matched by the fallback route.
foreach (var kvp in matchedValues)
{
values.TryAdd(kvp.Key, kvp.Value);
}
candidates.ReplaceEndpoint(i, replacement, values);
}
return Task.CompletedTask;
}
}
}

View File

@ -0,0 +1,52 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.Mvc.Routing
{
internal class DynamicControllerEndpointSelector : IDisposable
{
private readonly ControllerActionEndpointDataSource _dataSource;
private readonly DataSourceDependentCache<ActionSelectionTable<RouteEndpoint>> _cache;
public DynamicControllerEndpointSelector(ControllerActionEndpointDataSource dataSource)
{
if (dataSource == null)
{
throw new ArgumentNullException(nameof(dataSource));
}
_dataSource = dataSource;
_cache = new DataSourceDependentCache<ActionSelectionTable<RouteEndpoint>>(dataSource, Initialize);
}
private ActionSelectionTable<RouteEndpoint> Table => _cache.EnsureInitialized();
public IReadOnlyList<RouteEndpoint> SelectEndpoints(RouteValueDictionary values)
{
if (values == null)
{
throw new ArgumentNullException(nameof(values));
}
var table = Table;
var matches = table.Select(values);
return matches;
}
private static ActionSelectionTable<RouteEndpoint> Initialize(IReadOnlyList<Endpoint> endpoints)
{
return ActionSelectionTable<RouteEndpoint>.Create(endpoints);
}
public void Dispose()
{
_cache.Dispose();
}
}
}

View File

@ -0,0 +1,25 @@
// 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 Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.Mvc.Routing
{
internal class DynamicControllerMetadata : IDynamicEndpointMetadata
{
public DynamicControllerMetadata(RouteValueDictionary values)
{
if (values == null)
{
throw new ArgumentNullException(nameof(values));
}
Values = values;
}
public bool IsDynamic => true;
public RouteValueDictionary Values { get; }
}
}

View File

@ -327,6 +327,7 @@ namespace Microsoft.AspNetCore.Mvc
{
typeof(ConsumesMatcherPolicy),
typeof(ActionConstraintMatcherPolicy),
typeof(DynamicControllerEndpointMatcherPolicy),
}
},
};

View File

@ -5,6 +5,10 @@ namespace Microsoft.AspNetCore.Builder
{
public static partial class RazorPagesEndpointRouteBuilderExtensions
{
public static void MapFallbackToAreaPage(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string page, string area) { }
public static void MapFallbackToAreaPage(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string pattern, string page, string area) { }
public static void MapFallbackToPage(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string page) { }
public static void MapFallbackToPage(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes, string pattern, string page) { }
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapRazorPages(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes) { throw null; }
}
}

View File

@ -261,7 +261,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
}
// Internal for unit testing
internal static void EnsureValidPageName(string pageName)
internal static void EnsureValidPageName(string pageName, string argumentName = "pageName")
{
if (string.IsNullOrEmpty(pageName))
{

View File

@ -3,6 +3,8 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
@ -31,6 +33,253 @@ namespace Microsoft.AspNetCore.Builder
return GetOrCreateDataSource(routes);
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-file-names with the lowest possible priority. The request will be routed to a page endpoint that
/// matches <paramref name="page"/>.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="page">The page name.</param>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToPage(IEndpointRouteBuilder, string)"/> is intended to handle cases where URL path of
/// the request does not contain a file name, and no other endpoint has matched. This is convenient for routing
/// requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to
/// result in an HTTP 404.
/// </para>
/// <para>
/// <see cref="MapFallbackToPage(IEndpointRouteBuilder, string)"/> registers an endpoint using the pattern
/// <c>{*path:nonfile}</c>. The order of the registered endpoint will be <c>int.MaxValue</c>.
/// </para>
/// <para>
/// <see cref="MapFallbackToPage(IEndpointRouteBuilder, string)"/> does not re-execute routing, and will
/// not generate route values based on routes defined elsewhere. When using this overload, the <c>path</c> route value
/// will be available.
/// </para>
/// </remarks>
public static void MapFallbackToPage(this IEndpointRouteBuilder routes, string page)
{
if (routes == null)
{
throw new ArgumentNullException(nameof(routes));
}
if (page == null)
{
throw new ArgumentNullException(nameof(page));
}
PageConventionCollection.EnsureValidPageName(page, nameof(page));
EnsureRazorPagesServices(routes);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(routes);
// Maps a fallback endpoint with an empty delegate. This is OK because
// we don't expect the delegate to run.
routes.MapFallback(context => Task.CompletedTask).Add(b =>
{
// MVC registers a policy that looks for this metadata.
b.Metadata.Add(CreateDynamicPageMetadata(page, area: null));
});
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-file-names with the lowest possible priority. The request will be routed to a page endpoint that
/// matches <paramref name="page"/>.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="pattern">The route pattern.</param>
/// <param name="page">The action name.</param>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToPage(IEndpointRouteBuilder, string, string)"/> is intended to handle cases where URL path of
/// the request does not contain a file name, and no other endpoint has matched. This is convenient for routing
/// requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to
/// result in an HTTP 404.
/// </para>
/// <para>
/// The order of the registered endpoint will be <c>int.MaxValue</c>.
/// </para>
/// <para>
/// This overload will use the provided <paramref name="pattern"/> verbatim. Use the <c>:nonfile</c> route contraint
/// to exclude requests for static files.
/// </para>
/// <para>
/// <see cref="MapFallbackToPage(IEndpointRouteBuilder, string, string)"/> does not re-execute routing, and will
/// not generate route values based on routes defined elsewhere. When using this overload, the route values provided by matching
/// <paramref name="pattern"/> will be available.
/// </para>
/// </remarks>
public static void MapFallbackToPage(
this IEndpointRouteBuilder routes,
string pattern,
string page)
{
if (routes == null)
{
throw new ArgumentNullException(nameof(routes));
}
if (pattern == null)
{
throw new ArgumentNullException(nameof(pattern));
}
if (page == null)
{
throw new ArgumentNullException(nameof(page));
}
PageConventionCollection.EnsureValidPageName(page, nameof(page));
EnsureRazorPagesServices(routes);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(routes);
// Maps a fallback endpoint with an empty delegate. This is OK because
// we don't expect the delegate to run.
routes.MapFallback(pattern, context => Task.CompletedTask).Add(b =>
{
// MVC registers a policy that looks for this metadata.
b.Metadata.Add(CreateDynamicPageMetadata(page, area: null));
});
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-file-names with the lowest possible priority. The request will be routed to a page endpoint that
/// matches <paramref name="page"/>, and <paramref name="area"/>.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="page">The action name.</param>
/// <param name="area">The area name.</param>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToAreaPage(IEndpointRouteBuilder, string, string)"/> is intended to handle cases where URL path of
/// the request does not contain a file name, and no other endpoint has matched. This is convenient for routing
/// requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to
/// result in an HTTP 404.
/// </para>
/// <para>
/// <see cref="MapFallbackToAreaPage(IEndpointRouteBuilder, string, string)"/> registers an endpoint using the pattern
/// <c>{*path:nonfile}</c>. The order of the registered endpoint will be <c>int.MaxValue</c>.
/// </para>
/// <para>
/// <see cref="MapFallbackToAreaPage(IEndpointRouteBuilder, string, string)"/> does not re-execute routing, and will
/// not generate route values based on routes defined elsewhere. When using this overload, the <c>path</c> route value
/// will be available.
/// </para>
/// </remarks>
public static void MapFallbackToAreaPage(
this IEndpointRouteBuilder routes,
string page,
string area)
{
if (routes == null)
{
throw new ArgumentNullException(nameof(routes));
}
if (page == null)
{
throw new ArgumentNullException(nameof(page));
}
PageConventionCollection.EnsureValidPageName(page, nameof(page));
EnsureRazorPagesServices(routes);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(routes);
// Maps a fallback endpoint with an empty delegate. This is OK because
// we don't expect the delegate to run.
routes.MapFallback(context => Task.CompletedTask).Add(b =>
{
// MVC registers a policy that looks for this metadata.
b.Metadata.Add(CreateDynamicPageMetadata(page, area));
});
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-file-names with the lowest possible priority. The request will be routed to a page endpoint that
/// matches <paramref name="page"/>, and <paramref name="area"/>.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="pattern">The route pattern.</param>
/// <param name="page">The action name.</param>
/// <param name="area">The area name.</param>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToAreaPage(IEndpointRouteBuilder, string, string, string)"/> is intended to handle cases where URL path of
/// the request does not contain a file name, and no other endpoint has matched. This is convenient for routing
/// requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to
/// result in an HTTP 404.
/// </para>
/// <para>
/// The order of the registered endpoint will be <c>int.MaxValue</c>.
/// </para>
/// <para>
/// This overload will use the provided <paramref name="pattern"/> verbatim. Use the <c>:nonfile</c> route contraint
/// to exclude requests for static files.
/// </para>
/// <para>
/// <see cref="MapFallbackToAreaPage(IEndpointRouteBuilder, string, string, string)"/> does not re-execute routing, and will
/// not generate route values based on routes defined elsewhere. When using this overload, the route values provided by matching
/// <paramref name="pattern"/> will be available.
/// </para>
/// </remarks>
public static void MapFallbackToAreaPage(
this IEndpointRouteBuilder routes,
string pattern,
string page,
string area)
{
if (routes == null)
{
throw new ArgumentNullException(nameof(routes));
}
if (pattern == null)
{
throw new ArgumentNullException(nameof(pattern));
}
if (page == null)
{
throw new ArgumentNullException(nameof(page));
}
PageConventionCollection.EnsureValidPageName(page, nameof(page));
EnsureRazorPagesServices(routes);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(routes);
// Maps a fallback endpoint with an empty delegate. This is OK because
// we don't expect the delegate to run.
routes.MapFallback(pattern, context => Task.CompletedTask).Add(b =>
{
// MVC registers a policy that looks for this metadata.
b.Metadata.Add(CreateDynamicPageMetadata(page, area));
});
}
private static DynamicPageMetadata CreateDynamicPageMetadata(string page, string area)
{
return new DynamicPageMetadata(new RouteValueDictionary()
{
{ "page", page },
{ "area", area }
});
}
private static void EnsureRazorPagesServices(IEndpointRouteBuilder routes)
{
var marker = routes.ServiceProvider.GetService<PageActionEndpointDataSource>();

View File

@ -85,6 +85,8 @@ namespace Microsoft.Extensions.DependencyInjection
// Routing
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, PageLoaderMatcherPolicy>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, DynamicPageEndpointMatcherPolicy>());
services.TryAddSingleton<DynamicPageEndpointSelector>();
// Action description and invocation
services.TryAddEnumerable(
@ -92,6 +94,8 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPageRouteModelProvider, CompiledPageRouteModelProvider>());
services.TryAddSingleton<PageActionEndpointDataSource>();
services.TryAddSingleton<DynamicPageEndpointSelector>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, DynamicPageEndpointMatcherPolicy>());
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPageApplicationModelProvider, DefaultPageApplicationModelProvider>());

View File

@ -0,0 +1,126 @@
// 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.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
{
internal class DynamicPageEndpointMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
private readonly DynamicPageEndpointSelector _selector;
private readonly PageLoader _loader;
public DynamicPageEndpointMatcherPolicy(DynamicPageEndpointSelector selector, PageLoader loader)
{
if (selector == null)
{
throw new ArgumentNullException(nameof(selector));
}
if (loader == null)
{
throw new ArgumentNullException(nameof(loader));
}
_selector = selector;
_loader = loader;
}
public override int Order => int.MinValue + 100;
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
{
if (endpoints == null)
{
throw new ArgumentNullException(nameof(endpoints));
}
if (!ContainsDynamicEndpoints(endpoints))
{
// Dynamic page endpoints are always dynamic endpoints.
return false;
}
for (var i = 0; i < endpoints.Count; i++)
{
var metadata = endpoints[i].Metadata.GetMetadata<DynamicPageMetadata>();
if (metadata != null)
{
// Found a dynamic page endpoint
return true;
}
}
return false;
}
public async Task ApplyAsync(HttpContext httpContext, EndpointSelectorContext context, CandidateSet candidates)
{
if (httpContext == null)
{
throw new ArgumentNullException(nameof(httpContext));
}
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (candidates == null)
{
throw new ArgumentNullException(nameof(candidates));
}
// There's no real benefit here from trying to avoid the async state machine.
// We only execute on nodes that contain a dynamic policy, and thus always have
// to await something.
for (var i = 0; i < candidates.Count; i++)
{
if (!candidates.IsValidCandidate(i))
{
continue;
}
var endpoint = candidates[i].Endpoint;
var metadata = endpoint.Metadata.GetMetadata<DynamicPageMetadata>();
if (metadata == null)
{
continue;
}
var matchedValues = candidates[i].Values;
var endpoints = _selector.SelectEndpoints(metadata.Values);
if (endpoints.Count == 0)
{
// If there's no match this is a configuration error. We can't really check
// during startup that the action you configured exists.
throw new InvalidOperationException(
"Cannot find the fallback endpoint specified by route values: " +
"{ " + string.Join(", ", metadata.Values.Select(kvp => $"{kvp.Key}: {kvp.Value}")) + " }.");
}
var compiled = await _loader.LoadAsync(endpoints[0].Metadata.GetMetadata<PageActionDescriptor>());
var replacement = compiled.Endpoint;
// We need to provide the route values associated with this endpoint, so that features
// like URL generation work.
var values = new RouteValueDictionary(metadata.Values);
// Include values that were matched by the fallback route.
foreach (var kvp in matchedValues)
{
values.TryAdd(kvp.Key, kvp.Value);
}
candidates.ReplaceEndpoint(i, replacement, values);
}
}
}
}

View File

@ -0,0 +1,52 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
{
internal class DynamicPageEndpointSelector : IDisposable
{
private readonly PageActionEndpointDataSource _dataSource;
private readonly DataSourceDependentCache<ActionSelectionTable<RouteEndpoint>> _cache;
public DynamicPageEndpointSelector(PageActionEndpointDataSource dataSource)
{
if (dataSource == null)
{
throw new ArgumentNullException(nameof(dataSource));
}
_dataSource = dataSource;
_cache = new DataSourceDependentCache<ActionSelectionTable<RouteEndpoint>>(dataSource, Initialize);
}
private ActionSelectionTable<RouteEndpoint> Table => _cache.EnsureInitialized();
public IReadOnlyList<RouteEndpoint> SelectEndpoints(RouteValueDictionary values)
{
if (values == null)
{
throw new ArgumentNullException(nameof(values));
}
var table = Table;
var matches = table.Select(values);
return matches;
}
private static ActionSelectionTable<RouteEndpoint> Initialize(IReadOnlyList<Endpoint> endpoints)
{
return ActionSelectionTable<RouteEndpoint>.Create(endpoints);
}
public void Dispose()
{
_cache.Dispose();
}
}
}

View File

@ -0,0 +1,25 @@
// 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 Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
{
internal class DynamicPageMetadata : IDynamicEndpointMetadata
{
public DynamicPageMetadata(RouteValueDictionary values)
{
if (values == null)
{
throw new ArgumentNullException(nameof(values));
}
Values = values;
}
public bool IsDynamic => true;
public RouteValueDictionary Values { get; }
}
}

View File

@ -68,6 +68,8 @@ namespace MvcSandbox
builder.MapControllers();
builder.MapRazorPages();
builder.MapFallbackToController("Index", "Home");
});
app.UseDeveloperExceptionPage();

View File

@ -0,0 +1,88 @@
// 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.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public class RoutingFallbackTest : IClassFixture<MvcTestFixture<RoutingWebSite.StartupForFallback>>
{
public RoutingFallbackTest(MvcTestFixture<RoutingWebSite.StartupForFallback> fixture)
{
var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
Client = factory.CreateDefaultClient();
}
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => builder.UseStartup<RoutingWebSite.StartupForFallback>();
public HttpClient Client { get; }
[Fact]
public async Task Fallback_CanGet404ForMissingFile()
{
// Arrange
var url = "http://localhost/pranav.jpg";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await Client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Fallback_CanAccessKnownEndpoint()
{
// Arrange
var url = "http://localhost/Edit/17";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await Client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Hello from Edit page", content.Trim());
}
[Fact]
public async Task Fallback_CanFallbackToControllerInArea()
{
// Arrange
var url = "http://localhost/Admin/Foo";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await Client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Hello from fallback controller: /Admin/Fallback", content);
}
[Fact]
public async Task Fallback_CanFallbackToPage()
{
// Arrange
var url = "http://localhost/Foo";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await Client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Hello from fallback page: /FallbackPage", content);
}
}
}

View File

@ -0,0 +1,16 @@
// 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 Microsoft.AspNetCore.Mvc;
namespace RoutingWebSite.Areas.Admin
{
[Area("Admin")]
public class FallbackController : Controller
{
public ActionResult Index()
{
return Content("Hello from fallback controller: " + Url.Action());
}
}
}

View File

@ -0,0 +1,3 @@
@page
@model RoutingWebSite.Pages.FallbackPageModel
Hello from fallback page: @Url.Page("")

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace RoutingWebSite.Pages
{
public class FallbackPageModel : PageModel
{
public void OnGet()
{
}
}
}

View File

@ -0,0 +1,45 @@
// 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 Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace RoutingWebSite
{
// For by tests for fallback routing to pages/controllers
public class StartupForFallback
{
public void ConfigureServices(IServiceCollection services)
{
services
.AddMvc()
.AddNewtonsoftJson()
.SetCompatibilityVersion(CompatibilityVersion.Latest);
// Used by some controllers defined in this project.
services.Configure<RouteOptions>(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer));
}
public void Configure(IApplicationBuilder app)
{
app.UseRouting(routes =>
{
// Workaround for #8130
//
// You can't fallback to this unless it already has another route.
routes.MapAreaControllerRoute("admin", "Admin", "Admin/{controller=Home}/{action=Index}/{id?}");
routes.MapFallbackToAreaController("admin/{*path:nonfile}", "Index", "Fallback", "Admin");
routes.MapFallbackToPage("/FallbackPage");
});
app.Map("/afterrouting", b => b.Run(c =>
{
return c.Response.WriteAsync("Hello from middleware after routing");
}));
}
}
}