diff --git a/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs
index 43aa9cd66d..1d7190f1da 100644
--- a/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs
+++ b/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs
@@ -4,7 +4,6 @@
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
-using Microsoft.AspNetCore.Routing.Patterns;
namespace Microsoft.AspNetCore.Builder
{
diff --git a/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs b/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs
index 1a142d6aec..c8dbe9009a 100644
--- a/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs
+++ b/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs
@@ -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
{
diff --git a/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs b/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs
index 6d96bde95e..74b94902bb 100644
--- a/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs
+++ b/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs
@@ -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);
}
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-file-names with the lowest possible priority. The request will be routed to a controller endpoint that
+ /// matches , and .
+ ///
+ /// The to add the route to.
+ /// The action name.
+ /// The controller name.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// registers an endpoint using the pattern
+ /// {*path:nonfile}. The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ /// does not re-execute routing, and will
+ /// not generate route values based on routes defined elsewhere. When using this overload, the path route value
+ /// will be available.
+ ///
+ ///
+ /// does not attempt to disambiguate between
+ /// multiple actions that match the provided and . If multiple
+ /// actions match these values, the result is implementation defined.
+ ///
+ ///
+ 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));
+ });
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-file-names with the lowest possible priority. The request will be routed to a controller endpoint that
+ /// matches , and .
+ ///
+ /// The to add the route to.
+ /// The route pattern.
+ /// The action name.
+ /// The controller name.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ /// This overload will use the provided verbatim. Use the :nonfile route contraint
+ /// to exclude requests for static files.
+ ///
+ ///
+ /// 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
+ /// will be available.
+ ///
+ ///
+ /// does not attempt to disambiguate between
+ /// multiple actions that match the provided and . If multiple
+ /// actions match these values, the result is implementation defined.
+ ///
+ ///
+ 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));
+ });
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-file-names with the lowest possible priority. The request will be routed to a controller endpoint that
+ /// matches , , and .
+ ///
+ /// The to add the route to.
+ /// The action name.
+ /// The controller name.
+ /// The area name.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// registers an endpoint using the pattern
+ /// {*path:nonfile}. The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ /// does not re-execute routing, and will
+ /// not generate route values based on routes defined elsewhere. When using this overload, the path route value
+ /// will be available.
+ ///
+ ///
+ /// does not attempt to disambiguate between
+ /// multiple actions that match the provided , , and . If multiple
+ /// actions match these values, the result is implementation defined.
+ ///
+ ///
+ 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));
+ });
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-file-names with the lowest possible priority. The request will be routed to a controller endpoint that
+ /// matches , , and .
+ ///
+ /// The to add the route to.
+ /// The route pattern.
+ /// The action name.
+ /// The controller name.
+ /// The area name.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ /// This overload will use the provided verbatim. Use the :nonfile route contraint
+ /// to exclude requests for static files.
+ ///
+ ///
+ /// 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
+ /// will be available.
+ ///
+ ///
+ /// does not attempt to disambiguate between
+ /// multiple actions that match the provided , , and . If multiple
+ /// actions match these values, the result is implementation defined.
+ ///
+ ///
+ 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();
diff --git a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs
index 1a309d3e8f..2ab9cb64f0 100644
--- a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs
+++ b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs
@@ -272,6 +272,8 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
//
// Middleware pipeline filter related
diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs
index 7bee7d79a5..2abc8e6e71 100644
--- a/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs
+++ b/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs
@@ -84,7 +84,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
// Only include RouteEndpoints and only those that aren't suppressed.
items: endpoints.OfType().Where(e =>
{
- return e.Metadata.GetMetadata().SuppressMatching != true;
+ return e.Metadata.GetMetadata()?.SuppressMatching != true;
}),
getRouteKeys: e => e.RoutePattern.RequiredValues.Keys,
diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/StringArrayComparer.cs b/src/Mvc/Mvc.Core/src/Infrastructure/StringArrayComparer.cs
index a0454f71ce..df980aa6fb 100644
--- a/src/Mvc/Mvc.Core/src/Infrastructure/StringArrayComparer.cs
+++ b/src/Mvc/Mvc.Core/src/Infrastructure/StringArrayComparer.cs
@@ -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();
}
}
}
diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs
new file mode 100644
index 0000000000..798a13f109
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs
@@ -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 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();
+ 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();
+ 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;
+ }
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs
new file mode 100644
index 0000000000..cbf2f6ac21
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs
@@ -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> _cache;
+
+ public DynamicControllerEndpointSelector(ControllerActionEndpointDataSource dataSource)
+ {
+ if (dataSource == null)
+ {
+ throw new ArgumentNullException(nameof(dataSource));
+ }
+
+ _dataSource = dataSource;
+ _cache = new DataSourceDependentCache>(dataSource, Initialize);
+ }
+
+ private ActionSelectionTable Table => _cache.EnsureInitialized();
+
+ public IReadOnlyList SelectEndpoints(RouteValueDictionary values)
+ {
+ if (values == null)
+ {
+ throw new ArgumentNullException(nameof(values));
+ }
+
+ var table = Table;
+ var matches = table.Select(values);
+ return matches;
+ }
+
+ private static ActionSelectionTable Initialize(IReadOnlyList endpoints)
+ {
+ return ActionSelectionTable.Create(endpoints);
+ }
+
+ public void Dispose()
+ {
+ _cache.Dispose();
+ }
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerMetadata.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerMetadata.cs
new file mode 100644
index 0000000000..ee59b35f55
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerMetadata.cs
@@ -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; }
+ }
+}
diff --git a/src/Mvc/Mvc.Core/test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs b/src/Mvc/Mvc.Core/test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs
index 9f434c1f14..1c9117ef6e 100644
--- a/src/Mvc/Mvc.Core/test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs
+++ b/src/Mvc/Mvc.Core/test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs
@@ -327,6 +327,7 @@ namespace Microsoft.AspNetCore.Mvc
{
typeof(ConsumesMatcherPolicy),
typeof(ActionConstraintMatcherPolicy),
+ typeof(DynamicControllerEndpointMatcherPolicy),
}
},
};
diff --git a/src/Mvc/Mvc.RazorPages/ref/Microsoft.AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs b/src/Mvc/Mvc.RazorPages/ref/Microsoft.AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs
index c0f65820f9..d6f71c30c6 100644
--- a/src/Mvc/Mvc.RazorPages/ref/Microsoft.AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs
+++ b/src/Mvc/Mvc.RazorPages/ref/Microsoft.AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs
@@ -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; }
}
}
diff --git a/src/Mvc/Mvc.RazorPages/src/ApplicationModels/PageConventionCollection.cs b/src/Mvc/Mvc.RazorPages/src/ApplicationModels/PageConventionCollection.cs
index ba38593f10..0a73a0da39 100644
--- a/src/Mvc/Mvc.RazorPages/src/ApplicationModels/PageConventionCollection.cs
+++ b/src/Mvc/Mvc.RazorPages/src/ApplicationModels/PageConventionCollection.cs
@@ -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))
{
diff --git a/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs b/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs
index e078577a24..310c22f9ad 100644
--- a/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs
+++ b/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs
@@ -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);
}
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-file-names with the lowest possible priority. The request will be routed to a page endpoint that
+ /// matches .
+ ///
+ /// The to add the route to.
+ /// The page name.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// registers an endpoint using the pattern
+ /// {*path:nonfile}. The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ /// does not re-execute routing, and will
+ /// not generate route values based on routes defined elsewhere. When using this overload, the path route value
+ /// will be available.
+ ///
+ ///
+ 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));
+ });
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-file-names with the lowest possible priority. The request will be routed to a page endpoint that
+ /// matches .
+ ///
+ /// The to add the route to.
+ /// The route pattern.
+ /// The action name.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ /// This overload will use the provided verbatim. Use the :nonfile route contraint
+ /// to exclude requests for static files.
+ ///
+ ///
+ /// 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
+ /// will be available.
+ ///
+ ///
+ 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));
+ });
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-file-names with the lowest possible priority. The request will be routed to a page endpoint that
+ /// matches , and .
+ ///
+ /// The to add the route to.
+ /// The action name.
+ /// The area name.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// registers an endpoint using the pattern
+ /// {*path:nonfile}. The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ /// does not re-execute routing, and will
+ /// not generate route values based on routes defined elsewhere. When using this overload, the path route value
+ /// will be available.
+ ///
+ ///
+ 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));
+ });
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-file-names with the lowest possible priority. The request will be routed to a page endpoint that
+ /// matches , and .
+ ///
+ /// The to add the route to.
+ /// The route pattern.
+ /// The action name.
+ /// The area name.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ /// This overload will use the provided verbatim. Use the :nonfile route contraint
+ /// to exclude requests for static files.
+ ///
+ ///
+ /// 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
+ /// will be available.
+ ///
+ ///
+ 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();
diff --git a/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs b/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs
index d5c92d94a1..bc62660748 100644
--- a/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs
+++ b/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs
@@ -85,6 +85,8 @@ namespace Microsoft.Extensions.DependencyInjection
// Routing
services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.TryAddSingleton();
// Action description and invocation
services.TryAddEnumerable(
@@ -92,6 +94,8 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddEnumerable(
ServiceDescriptor.Singleton());
services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
services.TryAddEnumerable(
ServiceDescriptor.Singleton());
diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs
new file mode 100644
index 0000000000..cf41aac9fd
--- /dev/null
+++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs
@@ -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 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();
+ 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();
+ 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());
+ 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);
+ }
+ }
+ }
+}
diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs
new file mode 100644
index 0000000000..42f87b0210
--- /dev/null
+++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs
@@ -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> _cache;
+
+ public DynamicPageEndpointSelector(PageActionEndpointDataSource dataSource)
+ {
+ if (dataSource == null)
+ {
+ throw new ArgumentNullException(nameof(dataSource));
+ }
+
+ _dataSource = dataSource;
+ _cache = new DataSourceDependentCache>(dataSource, Initialize);
+ }
+
+ private ActionSelectionTable Table => _cache.EnsureInitialized();
+
+ public IReadOnlyList SelectEndpoints(RouteValueDictionary values)
+ {
+ if (values == null)
+ {
+ throw new ArgumentNullException(nameof(values));
+ }
+
+ var table = Table;
+ var matches = table.Select(values);
+ return matches;
+ }
+
+ private static ActionSelectionTable Initialize(IReadOnlyList endpoints)
+ {
+ return ActionSelectionTable.Create(endpoints);
+ }
+
+ public void Dispose()
+ {
+ _cache.Dispose();
+ }
+ }
+}
diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageMetadata.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageMetadata.cs
new file mode 100644
index 0000000000..a96a6d7278
--- /dev/null
+++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageMetadata.cs
@@ -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; }
+ }
+}
diff --git a/src/Mvc/samples/MvcSandbox/Startup.cs b/src/Mvc/samples/MvcSandbox/Startup.cs
index bb6257bf65..14c2c66686 100644
--- a/src/Mvc/samples/MvcSandbox/Startup.cs
+++ b/src/Mvc/samples/MvcSandbox/Startup.cs
@@ -68,6 +68,8 @@ namespace MvcSandbox
builder.MapControllers();
builder.MapRazorPages();
+
+ builder.MapFallbackToController("Index", "Home");
});
app.UseDeveloperExceptionPage();
diff --git a/src/Mvc/test/Mvc.FunctionalTests/RoutingFallbackTest.cs b/src/Mvc/test/Mvc.FunctionalTests/RoutingFallbackTest.cs
new file mode 100644
index 0000000000..ed23c1fe02
--- /dev/null
+++ b/src/Mvc/test/Mvc.FunctionalTests/RoutingFallbackTest.cs
@@ -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>
+ {
+ public RoutingFallbackTest(MvcTestFixture fixture)
+ {
+ var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
+ Client = factory.CreateDefaultClient();
+ }
+
+ private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => builder.UseStartup();
+
+ 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);
+ }
+ }
+}
diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/FallbackController.cs b/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/FallbackController.cs
new file mode 100644
index 0000000000..a15564d2b7
--- /dev/null
+++ b/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/FallbackController.cs
@@ -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());
+ }
+ }
+}
diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Pages/FallbackPage.cshtml b/src/Mvc/test/WebSites/RoutingWebSite/Pages/FallbackPage.cshtml
new file mode 100644
index 0000000000..49b541f01c
--- /dev/null
+++ b/src/Mvc/test/WebSites/RoutingWebSite/Pages/FallbackPage.cshtml
@@ -0,0 +1,3 @@
+@page
+@model RoutingWebSite.Pages.FallbackPageModel
+Hello from fallback page: @Url.Page("")
\ No newline at end of file
diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Pages/FallbackPage.cshtml.cs b/src/Mvc/test/WebSites/RoutingWebSite/Pages/FallbackPage.cshtml.cs
new file mode 100644
index 0000000000..a8df7eac3d
--- /dev/null
+++ b/src/Mvc/test/WebSites/RoutingWebSite/Pages/FallbackPage.cshtml.cs
@@ -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()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Mvc/test/WebSites/RoutingWebSite/StartupForFallback.cs b/src/Mvc/test/WebSites/RoutingWebSite/StartupForFallback.cs
new file mode 100644
index 0000000000..3bb86e4c89
--- /dev/null
+++ b/src/Mvc/test/WebSites/RoutingWebSite/StartupForFallback.cs
@@ -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(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");
+ }));
+ }
+ }
+}