diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Routing/ControllerLinkGeneratorExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/Routing/ControllerLinkGeneratorExtensions.cs new file mode 100644 index 0000000000..01481bf488 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Routing/ControllerLinkGeneratorExtensions.cs @@ -0,0 +1,281 @@ +// 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.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Routing; +using System; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Extension methods for using to generate links to MVC controllers. + /// + public static class ControllerLinkGeneratorExtensions + { + private static readonly LinkGenerationTemplateOptions _templateOptions = new LinkGenerationTemplateOptions() + { + UseAmbientValues = true, + }; + + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The associated with the current request. + /// + /// The action name. Used to resolve endpoints. Optional. If null is provided, the current action route value + /// will be used. + /// + /// + /// The controller name. Used to resolve endpoints. Optional. If null is provided, the current controller route value + /// will be used. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// An optional URI path base. Prepended to the path in the resulting URI. If not provided, the value of will be used. + /// + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null if a URI cannot be created. + public static string GetPathByAction( + this LinkGenerator generator, + HttpContext httpContext, + string action = default, + string controller = default, + object values = default, + PathString? pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var address = CreateAddress(httpContext, action, controller, values); + return generator.GetPathByAddress( + httpContext, + address, + address.ExplicitValues, + address.AmbientValues, + pathBase, + fragment, + options); + } + + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The action name. Used to resolve endpoints. + /// The controller name. Used to resolve endpoints. + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null if a URI cannot be created. + public static string GetPathByAction( + this LinkGenerator generator, + string action, + string controller, + object values = default, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (controller == null) + { + throw new ArgumentNullException(nameof(controller)); + } + + var address = CreateAddress(httpContext: null, action, controller, values); + return generator.GetPathByAddress(address, address.ExplicitValues, pathBase, fragment, options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The associated with the current request. + /// + /// The action name. Used to resolve endpoints. Optional. If null is provided, the current action route value + /// will be used. + /// + /// + /// The controller name. Used to resolve endpoints. Optional. If null is provided, the current controller route value + /// will be used. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// The URI scheme, applied to the resulting URI. Optional. If not provided, the value of will be used. + /// + /// + /// The URI host/authority, applied to the resulting URI. Optional. If not provided, the value will be used. + /// + /// + /// An optional URI path base. Prepended to the path in the resulting URI. If not provided, the value of will be used. + /// + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A absolute URI, or null if a URI cannot be created. + public static string GetUriByAction( + this LinkGenerator generator, + HttpContext httpContext, + string action = default, + string controller = default, + object values = default, + string scheme = default, + HostString? host = default, + PathString? pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var address = CreateAddress(httpContext, action, controller, values); + return generator.GetUriByAddress( + httpContext, + address, + address.ExplicitValues, + address.AmbientValues, + scheme, + host, + pathBase, + fragment, + options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The action name. Used to resolve endpoints. + /// The controller name. Used to resolve endpoints. + /// The route values. May be null. Used to resolve endpoints and expand parameters in the route template. + /// The URI scheme, applied to the resulting URI. + /// The URI host/authority, applied to the resulting URI. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A absolute URI, or null if a URI cannot be created. + public static string GetUriByAction( + this LinkGenerator generator, + string action, + string controller, + object values, + string scheme, + HostString host, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (controller == null) + { + throw new ArgumentNullException(nameof(controller)); + } + + var address = CreateAddress(httpContext: null, action, controller, values); + return generator.GetUriByAddress(address, address.ExplicitValues, scheme, host, pathBase, fragment, options); + } + + /// + /// Gets a based on the provided , , and . + /// + /// The . + /// The action name. Used to resolve endpoints. + /// The controller name. Used to resolve endpoints. + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// A if one or more endpoints matching the address can be found, otherwise null. + /// + public static LinkGenerationTemplate GetTemplateByAction( + this LinkGenerator generator, + string action, + string controller, + object values = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (controller == null) + { + throw new ArgumentNullException(nameof(controller)); + } + + var address = CreateAddress(httpContext: null, action, controller, values); + return generator.GetTemplateByAddress(address, _templateOptions); + } + + private static RouteValuesAddress CreateAddress(HttpContext httpContext, string action, string controller, object values) + { + var explicitValues = new RouteValueDictionary(values); + var ambientValues = GetAmbientValues(httpContext); + + UrlHelperBase.NormalizeRouteValuesForAction(action, controller, explicitValues, ambientValues); + + return new RouteValuesAddress() + { + AmbientValues = ambientValues, + ExplicitValues = explicitValues + }; + } + + private static RouteValueDictionary GetAmbientValues(HttpContext httpContext) + { + return httpContext?.Features.Get()?.RouteValues; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Routing/PageLinkGeneratorExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/Routing/PageLinkGeneratorExtensions.cs new file mode 100644 index 0000000000..ec508a9645 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Routing/PageLinkGeneratorExtensions.cs @@ -0,0 +1,268 @@ +// 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.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Routing; +using System; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Extension methods for using to generate links to Razor Pages. + /// + public static class PageLinkGeneratorExtensions + { + private static readonly LinkGenerationTemplateOptions _templateOptions = new LinkGenerationTemplateOptions() + { + UseAmbientValues = true, + }; + + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The associated with the current request. + /// + /// The page name. Used to resolve endpoints. Optional. If null is provided, the current page route value + /// will be used. + /// + /// + /// The page handler name. Used to resolve endpoints. Optional. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// An optional URI path base. Prepended to the path in the resulting URI. If not provided, the value of will be used. + /// + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null if a URI cannot be created. + public static string GetPathByPage( + this LinkGenerator generator, + HttpContext httpContext, + string page = default, + string handler = default, + object values = default, + PathString? pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var address = CreateAddress(httpContext, page, handler, values); + return generator.GetPathByAddress( + httpContext, + address, + address.ExplicitValues, + address.AmbientValues, + pathBase, + fragment, + options); + } + + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// + /// The page name. Used to resolve endpoints. + /// + /// + /// The page handler name. Used to resolve endpoints. Optional. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null if a URI cannot be created. + public static string GetPathByPage( + this LinkGenerator generator, + string page, + string handler = default, + object values = default, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (page == null) + { + throw new ArgumentNullException(nameof(page)); + } + + var address = CreateAddress(httpContext: null, page, handler, values); + return generator.GetPathByAddress(address, address.ExplicitValues, pathBase, fragment, options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The associated with the current request. + /// + /// The page name. Used to resolve endpoints. Optional. If null is provided, the current page route value + /// will be used. + /// + /// + /// The page handler name. Used to resolve endpoints. Optional. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// The URI scheme, applied to the resulting URI. Optional. If not provided, the value of will be used. + /// + /// + /// The URI host/authority, applied to the resulting URI. Optional. If not provided, the value will be used. + /// + /// + /// An optional URI path base. Prepended to the path in the resulting URI. If not provided, the value of will be used. + /// + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A absolute URI, or null if a URI cannot be created. + public static string GetUriByPage( + this LinkGenerator generator, + HttpContext httpContext, + string page = default, + string handler = default, + object values = default, + string scheme = default, + HostString? host = default, + PathString? pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var address = CreateAddress(httpContext, page, handler, values); + return generator.GetUriByAddress( + httpContext, + address, + address.ExplicitValues, + address.AmbientValues, + scheme, + host, + pathBase, + fragment, + options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The page name. Used to resolve endpoints. + /// The page handler name. May be null. + /// The route values. May be null. Used to resolve endpoints and expand parameters in the route template. + /// The URI scheme, applied to the resulting URI. + /// The URI host/authority, applied to the resulting URI. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A absolute URI, or null if a URI cannot be created. + public static string GetUriByPage( + this LinkGenerator generator, + string page, + string handler, + object values, + string scheme, + HostString host, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (page == null) + { + throw new ArgumentNullException(nameof(page)); + } + + var address = CreateAddress(httpContext: null, page, handler, values); + return generator.GetUriByAddress(address, address.ExplicitValues, scheme, host, pathBase, fragment, options); + } + + /// + /// Gets a based on the provided , , and . + /// + /// The . + /// The page name. Used to resolve endpoints. + /// The page handler name. Optional. + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// A if one or more endpoints matching the address can be found, otherwise null. + /// + public static LinkGenerationTemplate GetTemplateByPage( + this LinkGenerator generator, + string page, + string handler = default, + object values = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (page == null) + { + throw new ArgumentNullException(nameof(page)); + } + + var address = CreateAddress(httpContext: null, page, handler, values); + return generator.GetTemplateByAddress(address, _templateOptions); + } + + private static RouteValuesAddress CreateAddress(HttpContext httpContext, string page, string handler, object values) + { + var explicitValues = new RouteValueDictionary(values); + var ambientValues = GetAmbientValues(httpContext); + + UrlHelperBase.NormalizeRouteValuesForPage(context: null, page, handler, explicitValues, ambientValues); + + return new RouteValuesAddress() + { + AmbientValues = ambientValues, + ExplicitValues = explicitValues + }; + } + + private static RouteValueDictionary GetAmbientValues(HttpContext httpContext) + { + return httpContext?.Features.Get()?.RouteValues; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelper.cs b/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelper.cs index 8bab2581d4..33f7d36f3e 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelper.cs @@ -44,31 +44,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing var valuesDictionary = GetValuesDictionary(actionContext.Values); - if (actionContext.Action == null) - { - if (!valuesDictionary.ContainsKey("action") && - AmbientValues.TryGetValue("action", out var action)) - { - valuesDictionary["action"] = action; - } - } - else - { - valuesDictionary["action"] = actionContext.Action; - } - - if (actionContext.Controller == null) - { - if (!valuesDictionary.ContainsKey("controller") && - AmbientValues.TryGetValue("controller", out var controller)) - { - valuesDictionary["controller"] = controller; - } - } - else - { - valuesDictionary["controller"] = actionContext.Controller; - } + NormalizeRouteValuesForAction(actionContext.Action, actionContext.Controller, valuesDictionary, AmbientValues); var virtualPathData = GetVirtualPathData(routeName: null, values: valuesDictionary); return GenerateUrl(actionContext.Protocol, actionContext.Host, virtualPathData, actionContext.Fragment); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperBase.cs b/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperBase.cs index a68c9c988b..5c48596532 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperBase.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperBase.cs @@ -3,8 +3,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Text; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Mvc.Routing @@ -263,6 +267,109 @@ namespace Microsoft.AspNetCore.Mvc.Routing } } + internal static void NormalizeRouteValuesForAction( + string action, + string controller, + RouteValueDictionary values, + RouteValueDictionary ambientValues) + { + object obj = null; + if (action == null) + { + if (!values.ContainsKey("action") && + (ambientValues?.TryGetValue("action", out obj) ?? false)) + { + values["action"] = obj; + } + } + else + { + values["action"] = action; + } + + if (controller == null) + { + if (!values.ContainsKey("controller") && + (ambientValues?.TryGetValue("controller", out obj) ?? false)) + { + values["controller"] = obj; + } + } + else + { + values["controller"] = controller; + } + } + + internal static void NormalizeRouteValuesForPage( + ActionContext context, + string page, + string handler, + RouteValueDictionary values, + RouteValueDictionary ambientValues) + { + object value = null; + if (string.IsNullOrEmpty(page)) + { + if (!values.ContainsKey("page") && + (ambientValues?.TryGetValue("page", out value) ?? false)) + { + values["page"] = value; + } + } + else + { + values["page"] = CalculatePageName(context, ambientValues, page); + } + + if (string.IsNullOrEmpty(handler)) + { + if (!values.ContainsKey("handler") && + (ambientValues?.ContainsKey("handler") ?? false)) + { + // Clear out form action unless it's explicitly specified in the routeValues. + values["handler"] = null; + } + } + else + { + values["handler"] = handler; + } + } + + private static object CalculatePageName(ActionContext context, RouteValueDictionary ambientValues, string pageName) + { + Debug.Assert(pageName.Length > 0); + // Paths not qualified with a leading slash are treated as relative to the current page. + if (pageName[0] != '/') + { + // OK now we should get the best 'normalized' version of the page route value that we can. + string currentPagePath; + if (context != null) + { + currentPagePath = NormalizedRouteValue.GetNormalizedRouteValue(context, "page"); + } + else if (ambientValues != null) + { + currentPagePath = ambientValues["page"]?.ToString(); + } + else + { + currentPagePath = null; + } + + if (string.IsNullOrEmpty(currentPagePath)) + { + // Disallow the use sibling page routing, a Razor page specific feature, from a non-page action. + throw new InvalidOperationException(Resources.FormatUrlHelper_RelativePagePathIsNotSupported(pageName)); + } + + return ViewEnginePath.CombinePath(currentPagePath, pageName); + } + + return pageName; + } + // for unit testing internal static void AppendPathAndFragment(StringBuilder builder, PathString pathBase, string virtualPath, string fragment) { diff --git a/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs index 42da1bc1ac..e790b879ef 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs @@ -444,32 +444,8 @@ namespace Microsoft.AspNetCore.Mvc var routeValues = new RouteValueDictionary(values); var ambientValues = urlHelper.ActionContext.RouteData.Values; - if (string.IsNullOrEmpty(pageName)) - { - if (!routeValues.ContainsKey("page") && - ambientValues.TryGetValue("page", out var value)) - { - routeValues["page"] = value; - } - } - else - { - routeValues["page"] = CalculatePageName(urlHelper.ActionContext, pageName); - } - if (string.IsNullOrEmpty(pageHandler)) - { - if (!routeValues.ContainsKey("handler") && - ambientValues.TryGetValue("handler", out var handler)) - { - // Clear out form action unless it's explicitly specified in the routeValues. - routeValues["handler"] = null; - } - } - else - { - routeValues["handler"] = pageHandler; - } + UrlHelperBase.NormalizeRouteValuesForPage(urlHelper.ActionContext, pageName, pageHandler, routeValues, ambientValues); return urlHelper.RouteUrl( routeName: null, @@ -478,24 +454,5 @@ namespace Microsoft.AspNetCore.Mvc host: host, fragment: fragment); } - - private static object CalculatePageName(ActionContext actionContext, string pageName) - { - Debug.Assert(pageName.Length > 0); - // Paths not qualified with a leading slash are treated as relative to the current page. - if (pageName[0] != '/') - { - var currentPagePath = NormalizedRouteValue.GetNormalizedRouteValue(actionContext, "page"); - if (string.IsNullOrEmpty(currentPagePath)) - { - // Disallow the use sibling page routing, a Razor page specific feature, from a non-page action. - throw new InvalidOperationException(Resources.FormatUrlHelper_RelativePagePathIsNotSupported(pageName)); - } - - return ViewEnginePath.CombinePath(currentPagePath, pageName); - } - - return pageName; - } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ControllerLinkGeneratorExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ControllerLinkGeneratorExtensionsTest.cs new file mode 100644 index 0000000000..5d0f01fafb --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ControllerLinkGeneratorExtensionsTest.cs @@ -0,0 +1,245 @@ +// 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.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.ObjectPool; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class ControllerLinkGeneratorExtensionsTest + { + [Fact] + public void GetPathByAction_WithHttpContext_PromotesAmbientValues() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { controller = "Home", }); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var path = linkGenerator.GetPathByAction( + httpContext, + action: "Index", + values: new RouteValueDictionary(new { query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByAction_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var path = linkGenerator.GetPathByAction( + action: "Index", + controller: "Home", + values: new RouteValueDictionary(new { query = "some?query" }), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByAction_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var path = linkGenerator.GetPathByAction( + httpContext, + action: "Index", + controller: "Home", + values: new RouteValueDictionary(new { query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByAction_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var path = linkGenerator.GetUriByAction( + action: "Index", + controller: "Home", + values: new RouteValueDictionary(new { query = "some?query" }), + "http", + new HostString("example.com"), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByAction_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { controller = "Home", action = "Index", }); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new HostString("example.com"); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var uri = linkGenerator.GetUriByAction( + httpContext, + values: new RouteValueDictionary(new { query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", uri); + } + + [Fact] + public void GetTemplateByAction_CreatesTemplate() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var template = linkGenerator.GetTemplateByAction(action: "Index", controller: "Home"); + + // Assert + Assert.NotNull(template); + Assert.Equal("/Home/Index/17", template.GetPath(new { id = 17 })); + } + + private RouteEndpoint CreateEndpoint( + string template, + object defaults = null, + object requiredValues = null, + int order = 0, + object[] metadata = null) + { + return new RouteEndpoint( + (httpContext) => Task.CompletedTask, + RoutePatternFactory.Parse(template, defaults, parameterPolicies: null), + order, + new EndpointMetadataCollection(metadata ?? Array.Empty()), + null); + } + + private IServiceProvider CreateServices(IEnumerable endpoints) + { + if (endpoints == null) + { + endpoints = Enumerable.Empty(); + } + + var services = new ServiceCollection(); + services.AddOptions(); + services.AddLogging(); + services.AddRouting(); + services + .AddSingleton() + .AddSingleton(UrlEncoder.Default); + services.TryAddEnumerable(ServiceDescriptor.Singleton(new DefaultEndpointDataSource(endpoints))); + return services.BuildServiceProvider(); + } + + private LinkGenerator CreateLinkGenerator(params Endpoint[] endpoints) + { + var services = CreateServices(endpoints); + return services.GetRequiredService(); + } + + private HttpContext CreateHttpContext(object ambientValues = null) + { + var httpContext = new DefaultHttpContext(); + + var feature = new EndpointFeature + { + RouteValues = new RouteValueDictionary(ambientValues) + }; + + httpContext.Features.Set(feature); + httpContext.Features.Set(feature); + return httpContext; + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/PageLinkGeneratorExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/PageLinkGeneratorExtensionsTest.cs new file mode 100644 index 0000000000..f07f150472 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/PageLinkGeneratorExtensionsTest.cs @@ -0,0 +1,245 @@ +// 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.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.ObjectPool; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class PageLinkGeneratorExtensionsTest + { + [Fact] + public void GetPathByPage_WithHttpContext_PromotesAmbientValues() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers/{handler?}", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { page = "/About", id = 17, }); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var path = linkGenerator.GetPathByPage( + httpContext, + values: new RouteValueDictionary(new { id = 18, query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/About/18/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByPage_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers/{handler?}", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var path = linkGenerator.GetPathByPage( + page: "/Admin/ManageUsers", + handler: "Delete", + values: new RouteValueDictionary(new { user = "jamesnk", query = "some?query" }), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/Admin/ManageUsers/Delete/?user=jamesnk&query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByPage_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { page = "/Admin/ManageUsers", handler = "DeleteUser", }); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var path = linkGenerator.GetPathByPage( + httpContext, + page: "/About", + values: new RouteValueDictionary(new { id = 19, query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/About/19/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByPage_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var path = linkGenerator.GetUriByPage( + page: "/About", + handler: null, + values: new RouteValueDictionary(new { id = 19, query = "some?query" }), + "http", + new HostString("example.com"), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/About/19/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByPage_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { page = "/Admin/ManageUsers", }); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new HostString("example.com"); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var uri = linkGenerator.GetUriByPage( + httpContext, + values: new RouteValueDictionary(new { query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/Admin/ManageUsers/?query=some%3Fquery#Fragment?", uri); + } + + [Fact] + public void GetTemplateByAction_CreatesTemplate() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var template = linkGenerator.GetTemplateByPage(page: "/About"); + + // Assert + Assert.NotNull(template); + Assert.Equal("/About/17", template.GetPath(new { id = 17 })); + } + + private RouteEndpoint CreateEndpoint( + string template, + object defaults = null, + object requiredValues = null, + int order = 0, + object[] metadata = null) + { + return new RouteEndpoint( + (httpContext) => Task.CompletedTask, + RoutePatternFactory.Parse(template, defaults, parameterPolicies: null), + order, + new EndpointMetadataCollection(metadata ?? Array.Empty()), + null); + } + + private IServiceProvider CreateServices(IEnumerable endpoints) + { + if (endpoints == null) + { + endpoints = Enumerable.Empty(); + } + + var services = new ServiceCollection(); + services.AddOptions(); + services.AddLogging(); + services.AddRouting(); + services + .AddSingleton() + .AddSingleton(UrlEncoder.Default); + services.TryAddEnumerable(ServiceDescriptor.Singleton(new DefaultEndpointDataSource(endpoints))); + return services.BuildServiceProvider(); + } + + private LinkGenerator CreateLinkGenerator(params Endpoint[] endpoints) + { + var services = CreateServices(endpoints); + return services.GetRequiredService(); + } + + private HttpContext CreateHttpContext(object ambientValues = null) + { + var httpContext = new DefaultHttpContext(); + + var feature = new EndpointFeature + { + RouteValues = new RouteValueDictionary(ambientValues) + }; + + httpContext.Features.Set(feature); + httpContext.Features.Set(feature); + return httpContext; + } + } +} \ No newline at end of file