From db7555b0bacc023a5a9b55978af464c933e61814 Mon Sep 17 00:00:00 2001 From: "ASP.NET CI" Date: Sun, 23 Sep 2018 19:25:04 +0000 Subject: [PATCH 1/3] Update dependencies.props [auto-updated: dependencies] --- build/dependencies.props | 146 +++++++++++++++++++-------------------- korebuild-lock.txt | 4 +- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/build/dependencies.props b/build/dependencies.props index 97da37f7e6..7d118388d6 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -16,87 +16,87 @@ 0.43.0 2.1.1.1 2.1.1 - 2.2.0-preview3-35252 - 2.2.0-preview1-20180911.1 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-a-preview3-ambientvalues-17006 - 2.2.0-a-preview3-ambientvalues-17006 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 + 2.2.0-preview3-35301 + 2.2.0-preview1-20180918.1 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 5.2.6 2.8.0 2.8.0 - 2.2.0-preview3-35252 + 2.2.0-preview3-35301 1.7.0 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 2.1.0 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 2.0.9 2.1.3 2.2.0-preview2-26905-02 - 2.2.0-preview3-35252 - 2.2.0-preview3-35252 + 2.2.0-preview3-35301 + 2.2.0-preview3-35301 15.6.1 4.7.49 2.0.3 diff --git a/korebuild-lock.txt b/korebuild-lock.txt index 7124f37441..649bf2ba0b 100644 --- a/korebuild-lock.txt +++ b/korebuild-lock.txt @@ -1,2 +1,2 @@ -version:2.2.0-preview1-20180911.1 -commithash:ddfecdfc6e8e4859db5a0daea578070b862aac65 +version:2.2.0-preview1-20180918.1 +commithash:ad5e3fc53442741a0dd49bce437d2ac72f4b5800 From 50cef4822a5fda5490896818377a15611246f703 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Mon, 24 Sep 2018 10:46:54 -0700 Subject: [PATCH 2/3] Invoke FlushAsync before disposing the HttpResponseWriter in JsonResultExecutor Fixes #8486 --- .../Internal/JsonResultExecutor.cs | 8 ++- .../Internal/JsonResultExecutorTest.cs | 62 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/JsonResultExecutor.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/JsonResultExecutor.cs index f7e2fc09f2..332d1bdc59 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/JsonResultExecutor.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/JsonResultExecutor.cs @@ -86,7 +86,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal /// The . /// The . /// A which will complete when writing has completed. - public virtual Task ExecuteAsync(ActionContext context, JsonResult result) + public virtual async Task ExecuteAsync(ActionContext context, JsonResult result) { if (context == null) { @@ -128,9 +128,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal var jsonSerializer = JsonSerializer.Create(serializerSettings); jsonSerializer.Serialize(jsonWriter, result.Value); } - } - return Task.CompletedTask; + // Perf: call FlushAsync to call WriteAsync on the stream with any content left in the TextWriter's + // buffers. This is better than just letting dispose handle it (which would result in a synchronous write). + await writer.FlushAsync(); + } } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Internal/JsonResultExecutorTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Internal/JsonResultExecutorTest.cs index 2dbbeb1dad..8803474ccd 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Internal/JsonResultExecutorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Internal/JsonResultExecutorTest.cs @@ -5,6 +5,7 @@ using System; using System.Buffers; using System.IO; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -14,6 +15,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; +using Moq; using Newtonsoft.Json; using Xunit; @@ -217,6 +219,66 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal Assert.Equal(expected, logger.MostRecentMessage); } + [Fact] + public async Task ExecuteAsync_WritesToTheResponseStream_WhenContentIsLargerThanBuffer() + { + // Arrange + var writeLength = 2 * TestHttpResponseStreamWriterFactory.DefaultBufferSize + 4; + var text = new string('a', writeLength); + var expectedWriteCallCount = Math.Ceiling((double)writeLength / TestHttpResponseStreamWriterFactory.DefaultBufferSize); + + var stream = new Mock(); + stream.SetupGet(s => s.CanWrite).Returns(true); + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = stream.Object; + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + var result = new JsonResult(text); + var executor = CreateExecutor(); + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + // HttpResponseStreamWriter buffers content up to the buffer size (16k). When writes exceed the buffer size, it'll perform a synchronous + // write to the response stream. + stream.Verify(s => s.Write(It.IsAny(), It.IsAny(), TestHttpResponseStreamWriterFactory.DefaultBufferSize), Times.Exactly(2)); + + // Remainder buffered content is written asynchronously as part of the FlushAsync. + stream.Verify(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + + // Dispose does not call Flush + stream.Verify(s => s.Flush(), Times.Never()); + } + + [Theory] + [InlineData(5)] + [InlineData(TestHttpResponseStreamWriterFactory.DefaultBufferSize - 30)] + public async Task ExecuteAsync_DoesNotWriteSynchronouslyToTheResponseBody_WhenContentIsSmallerThanBufferSize(int writeLength) + { + // Arrange + var text = new string('a', writeLength); + + var stream = new Mock(); + stream.SetupGet(s => s.CanWrite).Returns(true); + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = stream.Object; + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + var result = new JsonResult(text); + var executor = CreateExecutor(); + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + // HttpResponseStreamWriter buffers content up to the buffer size (16k) and will asynchronously write content to the response as part + // of the FlushAsync call if the content written to it is smaller than the buffer size. + // This test verifies that no synchronous writes are performed in this scenario. + stream.Verify(s => s.Flush(), Times.Never()); + stream.Verify(s => s.Write(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + } + private static JsonResultExecutor CreateExecutor(ILogger logger = null) { return new JsonResultExecutor( From 831937c86c897da146edfee8ba7f7790aa5cb991 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 24 Sep 2018 21:08:28 -0700 Subject: [PATCH 3/3] Add LinkGenerator extensions for MVC --- .../ControllerLinkGeneratorExtensions.cs | 281 ++++++++++++++++++ .../Routing/PageLinkGeneratorExtensions.cs | 268 +++++++++++++++++ .../Routing/UrlHelper.cs | 26 +- .../Routing/UrlHelperBase.cs | 107 +++++++ .../UrlHelperExtensions.cs | 45 +-- .../ControllerLinkGeneratorExtensionsTest.cs | 245 +++++++++++++++ .../PageLinkGeneratorExtensionsTest.cs | 245 +++++++++++++++ 7 files changed, 1148 insertions(+), 69 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/Routing/ControllerLinkGeneratorExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/Routing/PageLinkGeneratorExtensions.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ControllerLinkGeneratorExtensionsTest.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/PageLinkGeneratorExtensionsTest.cs 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