diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/RouteTokenTransformerConvention.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/RouteTokenTransformerConvention.cs index c83ba6cc47..004e4a22c3 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/RouteTokenTransformerConvention.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/RouteTokenTransformerConvention.cs @@ -8,7 +8,8 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels { /// /// An that sets attribute routing token replacement - /// to use the specified on selectors. + /// to use the specified on . + /// This convention does not effect Razor page routes. /// public class RouteTokenTransformerConvention : IActionModelConvention { diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteTransformerConvention.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteTransformerConvention.cs new file mode 100644 index 0000000000..df47d3b7c2 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteTransformerConvention.cs @@ -0,0 +1,42 @@ +// 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.ApplicationModels +{ + /// + /// An that sets page route resolution + /// to use the specified on . + /// This convention does not effect controller action routes. + /// + public class PageRouteTransformerConvention : IPageRouteModelConvention + { + private IOutboundParameterTransformer _parameterTransformer; + + /// + /// Creates a new instance of with the specified . + /// + /// The to use resolve page routes. + public PageRouteTransformerConvention(IOutboundParameterTransformer parameterTransformer) + { + if (parameterTransformer == null) + { + throw new ArgumentNullException(nameof(parameterTransformer)); + } + + _parameterTransformer = parameterTransformer; + } + + public void Apply(PageRouteModel model) + { + if (ShouldApply(model)) + { + model.Properties[typeof(IOutboundParameterTransformer)] = _parameterTransformer; + } + } + + protected virtual bool ShouldApply(PageRouteModel action) => true; + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs index 06bc6f52eb..71a826c857 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs @@ -4,10 +4,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.RazorPages.Internal; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure @@ -81,7 +86,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { Name = selector.AttributeRouteModel.Name, Order = selector.AttributeRouteModel.Order ?? 0, - Template = selector.AttributeRouteModel.Template, + Template = TransformPageRoute(model, selector), SuppressLinkGeneration = selector.AttributeRouteModel.SuppressLinkGeneration, SuppressPathMatching = selector.AttributeRouteModel.SuppressPathMatching, }, @@ -109,5 +114,35 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure actions.Add(descriptor); } } + + private static string TransformPageRoute(PageRouteModel model, SelectorModel selectorModel) + { + model.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out var transformer); + var pageRouteTransformer = transformer as IOutboundParameterTransformer; + + // Transformer not set on page route + if (pageRouteTransformer == null) + { + return selectorModel.AttributeRouteModel.Template; + } + + var pageRouteMetadata = selectorModel.EndpointMetadata.OfType().SingleOrDefault(); + if (pageRouteMetadata == null) + { + // Selector does not have expected metadata. Should never reach here + throw new InvalidOperationException("Page selector did not have page route metadata."); + } + + var segments = pageRouteMetadata.PageRoute.Split('/'); + for (var i = 0; i < segments.Length; i++) + { + segments[i] = pageRouteTransformer.TransformOutbound(segments[i]); + } + + var transformedPageRoute = string.Join("/", segments); + + // Combine transformed page route with template + return AttributeRouteModel.CombineTemplates(transformedPageRoute, pageRouteMetadata.RouteTemplate); + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteMetadata.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteMetadata.cs new file mode 100644 index 0000000000..ec5390ea98 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteMetadata.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal +{ + // This is used to store the uncombined parts of the final page route + internal class PageRouteMetadata + { + public PageRouteMetadata(string pageRoute, string routeTemplate) + { + PageRoute = pageRoute; + RouteTemplate = routeTemplate; + } + + public string PageRoute { get; } + public string RouteTemplate { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs index e3d2d6289a..63f06ded68 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs @@ -159,6 +159,10 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal AttributeRouteModel = new AttributeRouteModel { Template = AttributeRouteModel.CombineTemplates(prefix, routeTemplate), + }, + EndpointMetadata = + { + new PageRouteMetadata(prefix, routeTemplate) } }; } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/RouteTokenTransformerConventionTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/RouteTokenTransformerConventionTest.cs index 807bb4d41a..52214026ef 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/RouteTokenTransformerConventionTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/RouteTokenTransformerConventionTest.cs @@ -11,25 +11,6 @@ namespace Microsoft.AspNetCore.Mvc.Test.ApplicationModels { public class RouteTokenTransformerConventionTest { - [Fact] - public void Apply_NullAttributeRouteModel_NoOp() - { - // Arrange - var convention = new RouteTokenTransformerConvention(new TestParameterTransformer()); - - var model = new ActionModel(GetMethodInfo(), Array.Empty()); - model.Selectors.Add(new SelectorModel() - { - AttributeRouteModel = null - }); - - // Act - convention.Apply(model); - - // Assert - Assert.Null(model.Selectors[0].AttributeRouteModel); - } - [Fact] public void Apply_HasAttributeRouteModel_SetRouteTokenTransformer() { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/LinkGeneratorTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/LinkGeneratorTest.cs index 755a844425..1597aa98a0 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/LinkGeneratorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/LinkGeneratorTest.cs @@ -158,6 +158,18 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("/LGPage?another-value=4", responseContent); } + [Fact] + public async Task GetPathByPage_CanGeneratePathToPage_PathTransformed() + { + // Act + var response = await Client.GetAsync("LG1/LinkToPageWithTransformedPath?id=HelloWorld"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/page-route-transformer/test-page/ExtraPath/HelloWorld", responseContent); + } + [Fact] public async Task GetPathByPage_CanGeneratePathToPageInArea() { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs index b5b407d4e2..f842256023 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs @@ -115,6 +115,38 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Empty(result); } + [Fact] + public async Task Page_PageRouteTransformer() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/page-route-transformer/index"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Page_PageRouteTransformer_WithoutIndex() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/page-route-transformer"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Page_PageRouteTransformer_RouteParameter() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/page-route-transformer/test-page/ExtraPath/World"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello from World", body); + } + [Fact] public virtual async Task ConventionalRoutedController_ActionIsReachable() { diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/PageRouteTransformerConventionTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/PageRouteTransformerConventionTest.cs new file mode 100644 index 0000000000..1fd04584b7 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/PageRouteTransformerConventionTest.cs @@ -0,0 +1,67 @@ +// 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.Reflection; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Routing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Test.ApplicationModels +{ + public class PageRouteTransformerConventionTest + { + [Fact] + public void Apply_SetTransformer() + { + // Arrange + var transformer = new TestParameterTransformer(); + var convention = new PageRouteTransformerConvention(transformer); + + var model = new PageRouteModel(string.Empty, string.Empty); + + // Act + convention.Apply(model); + + // Assert + Assert.True(model.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out var routeTokenTransformer)); + Assert.Equal(transformer, routeTokenTransformer); + } + + [Fact] + public void Apply_ShouldApplyFalse_NoOp() + { + // Arrange + var transformer = new TestParameterTransformer(); + var convention = new CustomPageRouteTransformerConvention(transformer); + + var model = new PageRouteModel(string.Empty, string.Empty); + + // Act + convention.Apply(model); + + // Assert + Assert.False(model.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out _)); + } + + private class TestParameterTransformer : IOutboundParameterTransformer + { + public string TransformOutbound(object value) + { + return value?.ToString(); + } + } + + private class CustomPageRouteTransformerConvention : PageRouteTransformerConvention + { + public CustomPageRouteTransformerConvention(IOutboundParameterTransformer parameterTransformer) : base(parameterTransformer) + { + } + + protected override bool ShouldApply(PageRouteModel action) + { + return false; + } + } + } +} diff --git a/test/WebSites/RoutingWebSite/Controllers/LG1Controller.cs b/test/WebSites/RoutingWebSite/Controllers/LG1Controller.cs index f8fb863add..3cef33fa63 100644 --- a/test/WebSites/RoutingWebSite/Controllers/LG1Controller.cs +++ b/test/WebSites/RoutingWebSite/Controllers/LG1Controller.cs @@ -59,6 +59,14 @@ namespace RoutingWebSite values: QueryToRouteValues(HttpContext.Request.Query)); } + public string LinkToPageWithTransformedPath() + { + return _linkGenerator.GetPathByPage( + HttpContext, + page: "/PageRouteTransformer/TestPage", + values: QueryToRouteValues(HttpContext.Request.Query)); + } + public string LinkToPageInArea() { var values = QueryToRouteValues(HttpContext.Request.Query); diff --git a/test/WebSites/RoutingWebSite/Pages/PageRouteTransformer/Index.cshtml b/test/WebSites/RoutingWebSite/Pages/PageRouteTransformer/Index.cshtml new file mode 100644 index 0000000000..dd7e49caa8 --- /dev/null +++ b/test/WebSites/RoutingWebSite/Pages/PageRouteTransformer/Index.cshtml @@ -0,0 +1,3 @@ +@page +@{ +} diff --git a/test/WebSites/RoutingWebSite/Pages/PageRouteTransformer/TestPage.cshtml b/test/WebSites/RoutingWebSite/Pages/PageRouteTransformer/TestPage.cshtml new file mode 100644 index 0000000000..5e812c4640 --- /dev/null +++ b/test/WebSites/RoutingWebSite/Pages/PageRouteTransformer/TestPage.cshtml @@ -0,0 +1,4 @@ +@page "ExtraPath/{id?}" +@{ +} +Hello from @ViewContext.RouteData.Values["id"] \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/Startup.cs b/test/WebSites/RoutingWebSite/Startup.cs index 1bf0041a0d..2b3a78b8e6 100644 --- a/test/WebSites/RoutingWebSite/Startup.cs +++ b/test/WebSites/RoutingWebSite/Startup.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +16,8 @@ namespace RoutingWebSite // Set up application services public void ConfigureServices(IServiceCollection services) { + var pageRouteTransformerConvention = new PageRouteTransformerConvention(new SlugifyParameterTransformer()); + services .AddMvc(options => { @@ -23,6 +26,13 @@ namespace RoutingWebSite typeof(ParameterTransformerController), new SlugifyParameterTransformer())); }) + .AddRazorPagesOptions(options => + { + options.Conventions.AddFolderRouteModelConvention("/PageRouteTransformer", model => + { + pageRouteTransformerConvention.Apply(model); + }); + }) .SetCompatibilityVersion(CompatibilityVersion.Latest); services .AddRouting(options => diff --git a/test/WebSites/RoutingWebSite/StartupForLinkGenerator.cs b/test/WebSites/RoutingWebSite/StartupForLinkGenerator.cs index 3a85de8fda..1d8ff727e7 100644 --- a/test/WebSites/RoutingWebSite/StartupForLinkGenerator.cs +++ b/test/WebSites/RoutingWebSite/StartupForLinkGenerator.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -13,8 +14,17 @@ namespace RoutingWebSite { public void ConfigureServices(IServiceCollection services) { + var pageRouteTransformerConvention = new PageRouteTransformerConvention(new SlugifyParameterTransformer()); + services .AddMvc() + .AddRazorPagesOptions(options => + { + options.Conventions.AddFolderRouteModelConvention("/PageRouteTransformer", model => + { + pageRouteTransformerConvention.Apply(model); + }); + }) .SetCompatibilityVersion(CompatibilityVersion.Latest); services .AddRouting(options => diff --git a/test/WebSites/RoutingWebSite/StartupWith21Compat.cs b/test/WebSites/RoutingWebSite/StartupWith21Compat.cs index e35a9b681a..20800ec3ea 100644 --- a/test/WebSites/RoutingWebSite/StartupWith21Compat.cs +++ b/test/WebSites/RoutingWebSite/StartupWith21Compat.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -19,8 +20,17 @@ namespace RoutingWebSite // Set up application services public void ConfigureServices(IServiceCollection services) { + var pageRouteTransformerConvention = new PageRouteTransformerConvention(new SlugifyParameterTransformer()); + services .AddMvc() + .AddRazorPagesOptions(options => + { + options.Conventions.AddFolderRouteModelConvention("/PageRouteTransformer", model => + { + pageRouteTransformerConvention.Apply(model); + }); + }) .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); services.AddScoped();