From a1ec03e1e65580b09a4dec5f2041a354bdca376d Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Thu, 28 Feb 2019 15:57:51 -0800 Subject: [PATCH] Add fallback routing for controllers and pages --- .../FallbackEndpointRouteBuilderExtensions.cs | 1 - ...osoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs | 4 + ...ontrollerEndpointRouteBuilderExtensions.cs | 291 ++++++++++++++++++ .../MvcCoreServiceCollectionExtensions.cs | 2 + .../Infrastructure/ActionSelectionTable.cs | 2 +- .../src/Infrastructure/StringArrayComparer.cs | 13 +- .../DynamicControllerEndpointMatcherPolicy.cs | 120 ++++++++ .../DynamicControllerEndpointSelector.cs | 52 ++++ .../src/Routing/DynamicControllerMetadata.cs | 25 ++ .../MvcCoreServiceCollectionExtensionsTest.cs | 1 + ...AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs | 4 + .../PageConventionCollection.cs | 2 +- ...azorPagesEndpointRouteBuilderExtensions.cs | 249 +++++++++++++++ .../MvcRazorPagesMvcCoreBuilderExtensions.cs | 4 + .../DynamicPageEndpointMatcherPolicy.cs | 126 ++++++++ .../DynamicPageEndpointSelector.cs | 52 ++++ .../src/Infrastructure/DynamicPageMetadata.cs | 25 ++ src/Mvc/samples/MvcSandbox/Startup.cs | 2 + .../RoutingFallbackTest.cs | 88 ++++++ .../Areas/Admin/FallbackController.cs | 16 + .../RoutingWebSite/Pages/FallbackPage.cshtml | 3 + .../Pages/FallbackPage.cshtml.cs | 16 + .../RoutingWebSite/StartupForFallback.cs | 45 +++ 23 files changed, 1130 insertions(+), 13 deletions(-) create mode 100644 src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs create mode 100644 src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs create mode 100644 src/Mvc/Mvc.Core/src/Routing/DynamicControllerMetadata.cs create mode 100644 src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs create mode 100644 src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs create mode 100644 src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageMetadata.cs create mode 100644 src/Mvc/test/Mvc.FunctionalTests/RoutingFallbackTest.cs create mode 100644 src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/FallbackController.cs create mode 100644 src/Mvc/test/WebSites/RoutingWebSite/Pages/FallbackPage.cshtml create mode 100644 src/Mvc/test/WebSites/RoutingWebSite/Pages/FallbackPage.cshtml.cs create mode 100644 src/Mvc/test/WebSites/RoutingWebSite/StartupForFallback.cs 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"); + })); + } + } +}