From 87c430ae1900478a5f8bfdd73551d41e8e6b82b3 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 7 Jul 2014 18:46:16 -0700 Subject: [PATCH] [#732] Attribute Routing: Implement `~/` for overriding a prefix This change allows a user to override a route prefix set using [Route("...")] on the controller by providing a route template on the action that starts with "~/" or "/". For example, [HttpGet("~/...")] or [HttpGet("/...")] If the user specifies a template in [Route] that starts with "~/" or "/", we will just strip the prefix from the template and use the remaining part of the template. The reason to do this is that there's a reasonable extensibility scenario where a user can implement a global prefix for routes as a convention (using IReflectedApplicationModelConvention), and use ~/ to escape that prefix (just like we support with action-level routes). --- .../Routing/AttributeRouteTemplate.cs | 70 ++++++++++++++++--- .../Routing/AttributeRouteTemplateTests.cs | 47 ++++++++++++- .../RoutingTests.cs | 43 +++++++++--- .../Controllers/EmployeeController.cs | 6 ++ 4 files changed, 145 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteTemplate.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteTemplate.cs index 599fd104a4..4c60deb075 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteTemplate.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteTemplate.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; + namespace Microsoft.AspNet.Mvc.Routing { /// @@ -15,6 +17,12 @@ namespace Microsoft.AspNet.Mvc.Routing /// The right template. /// A combined template. public static string Combine(string left, string right) + { + var result = CombineCore(left, right); + return CleanTemplate(result); + } + + private static string CombineCore(string left, string right) { if (left == null && right == null) { @@ -22,28 +30,68 @@ namespace Microsoft.AspNet.Mvc.Routing } else if (left == null) { - return right.Trim('/'); + return right; } else if (right == null) { - return left.Trim('/'); + return left; } - // Neither is null - var trimmedLeft = left.Trim('/'); - var trimmedRight = right.Trim('/'); - - if (trimmedLeft == string.Empty) + if (right.StartsWith("~/", StringComparison.OrdinalIgnoreCase) || + right.StartsWith("/", StringComparison.OrdinalIgnoreCase) || + left.Equals("~/", StringComparison.OrdinalIgnoreCase) || + left.Equals("/", StringComparison.OrdinalIgnoreCase)) { - return trimmedRight; + return right; } - else if (trimmedRight == string.Empty) + + if (left.EndsWith("/", StringComparison.OrdinalIgnoreCase)) { - return trimmedLeft; + return left + right; } // Both templates contain some text. - return trimmedLeft + '/' + trimmedRight; + return left + '/' + right; + } + + private static string CleanTemplate(string result) + { + if (result == null) + { + return null; + } + + // This is an invalid combined template, so we don't want to + // accidentally clean it and produce a valid template. For that + // reason we ignore the clean up process for it. + if (result.Equals("//", StringComparison.OrdinalIgnoreCase)) + { + return result; + } + + var startIndex = 0; + if (result.StartsWith("/", StringComparison.OrdinalIgnoreCase)) + { + startIndex = 1; + } + else if (result.StartsWith("~/", StringComparison.OrdinalIgnoreCase)) + { + startIndex = 2; + } + + // We are in the case where the string is "/" or "~/" + if (startIndex == result.Length) + { + return ""; + } + + var subStringLength = result.Length - startIndex; + if (result.EndsWith("/", StringComparison.OrdinalIgnoreCase)) + { + subStringLength--; + } + + return result.Substring(startIndex, subStringLength); } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTemplateTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTemplateTests.cs index 0b7dfc0661..f8d70aff18 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTemplateTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTemplateTests.cs @@ -18,6 +18,13 @@ namespace Microsoft.AspNet.Mvc.Routing [InlineData("", "/", "")] [InlineData("/", "/", "")] [InlineData("/", "/", "")] + [InlineData("~/", null, "")] + [InlineData("~/", "", "")] + [InlineData("~/", "/", "")] + [InlineData("~/", "~/", "")] + [InlineData(null, "~/", "")] + [InlineData("", "~/", "")] + [InlineData("/", "~/", "")] public void Combine_EmptyTemplates(string left, string right, string expected) { // Arrange & Act @@ -30,10 +37,19 @@ namespace Microsoft.AspNet.Mvc.Routing [Theory] [InlineData("home", null, "home")] [InlineData("home", "", "home")] - [InlineData("/home/", "/", "home")] + [InlineData("/home/", "/", "")] + [InlineData("/home/", "~/", "")] [InlineData(null, "GetEmployees", "GetEmployees")] [InlineData("/", "GetEmployees", "GetEmployees")] + [InlineData("~/", "Blog/Index/", "Blog/Index")] [InlineData("", "/GetEmployees/{id}/", "GetEmployees/{id}")] + [InlineData("~/home", null, "home")] + [InlineData("~/home", "", "home")] + [InlineData("~/home", "/", "")] + [InlineData(null, "~/home", "home")] + [InlineData("", "~/home", "home")] + [InlineData("", "~/home/", "home")] + [InlineData("/", "~/home", "home")] public void Combine_OneTemplateHasValue(string left, string right, string expected) { // Arrange & Act @@ -45,8 +61,11 @@ namespace Microsoft.AspNet.Mvc.Routing [Theory] [InlineData("home", "About", "home/About")] - [InlineData("home/", "/About", "home/About")] + [InlineData("home/", "/About", "About")] + [InlineData("home/", "/About/", "About")] [InlineData("/home/{action}", "{id}", "home/{action}/{id}")] + [InlineData("home", "~/index", "index")] + [InlineData("home", "~/index/", "index")] public void Combine_BothTemplatesHasValue(string left, string right, string expected) { // Arrange & Act @@ -55,5 +74,29 @@ namespace Microsoft.AspNet.Mvc.Routing // Assert Assert.Equal(expected, combined); } + + [Theory] + [InlineData("~~/", null, "~~")] + [InlineData("~~/", "", "~~")] + [InlineData("~~/", "//", "//")] + [InlineData("~~/", "~~/", "~~/~~")] + [InlineData("~~/", "home", "~~/home")] + [InlineData("~~/", "home/", "~~/home")] + [InlineData("//", null, "//")] + [InlineData("//", "", "//")] + [InlineData("//", "//", "//")] + [InlineData("//", "~~/", "/~~")] + [InlineData("//", "home", "/home")] + [InlineData("//", "home/", "/home")] + [InlineData("////", null, "//")] + [InlineData("~~//", null, "~~/")] + public void Combine_InvalidTemplates(string left, string right, string expected) + { + // Arrange & Act + var combined = AttributeRouteTemplate.Combine(left, right); + + // Assert + Assert.Equal(expected, combined); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs index bdc3de8d1d..1876cc64c0 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs @@ -143,12 +143,13 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests // Act var response = await client.GetAsync("http://localhost/Store/Shop/Products"); + + // Assert Assert.Equal(200, response.StatusCode); var body = await response.ReadBodyAsStringAsync(); var result = JsonConvert.DeserializeObject(body); - // Assert Assert.Contains("/Store/Shop/Products", result.ExpectedUrls); Assert.Equal("Store", result.Controller); Assert.Equal("ListProducts", result.Action); @@ -180,13 +181,13 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests // Act var response = await client.GetAsync("http://localhost/Home/About"); - Assert.Equal(200, response.StatusCode); // Assert + Assert.Equal(200, response.StatusCode); + var body = await response.ReadBodyAsStringAsync(); var result = JsonConvert.DeserializeObject(body); - // Assert Assert.Contains("/Home/About", result.ExpectedUrls); Assert.Equal("Store", result.Controller); Assert.Equal("About", result.Action); @@ -210,13 +211,13 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests // Act var response = await client.GetAsync("http://localhost/Blog/Edit/5"); - Assert.Equal(200, response.StatusCode); // Assert + Assert.Equal(200, response.StatusCode); + var body = await response.ReadBodyAsStringAsync(); var result = JsonConvert.DeserializeObject(body); - // Assert Assert.Contains("/Blog/Edit/5", result.ExpectedUrls); Assert.Equal("Blog", result.Controller); Assert.Equal("Edit", result.Action); @@ -245,9 +246,10 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests // Act var response = await client.GetAsync("http://localhost/api/Employee"); - Assert.Equal(200, response.StatusCode); // Assert + Assert.Equal(200, response.StatusCode); + var body = await response.ReadBodyAsStringAsync(); var result = JsonConvert.DeserializeObject(body); @@ -267,13 +269,13 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests // Act var response = await client.GetAsync("http://localhost/api/Employee/5/Boss"); - Assert.Equal(200, response.StatusCode); // Assert + Assert.Equal(200, response.StatusCode); + var body = await response.ReadBodyAsStringAsync(); var result = JsonConvert.DeserializeObject(body); - // Assert Assert.Contains("/api/Employee/5/Boss", result.ExpectedUrls); Assert.Equal("Employee", result.Controller); Assert.Equal("GetBoss", result.Action); @@ -283,6 +285,31 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests result.RouteValues); } + [Fact] + public async Task AttributeRoutedAction_ActionLevelRouteWithTildeSlash_OverridesControllerLevelRoute() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/Manager/5"); + + // Assert + Assert.Equal(200, response.StatusCode); + + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Manager/5", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("GetManager", result.Action); + + Assert.Contains( + new KeyValuePair("id", "5"), + result.RouteValues); + } + // See TestResponseGenerator in RoutingWebSite for the code that generates this data. private class RoutingResult { diff --git a/test/WebSites/RoutingWebSite/Controllers/EmployeeController.cs b/test/WebSites/RoutingWebSite/Controllers/EmployeeController.cs index dc21fa9df9..2c3a1ffdba 100644 --- a/test/WebSites/RoutingWebSite/Controllers/EmployeeController.cs +++ b/test/WebSites/RoutingWebSite/Controllers/EmployeeController.cs @@ -31,5 +31,11 @@ namespace RoutingWebSite { return _generator.Generate("/api/Employee/" + id + "/Boss"); } + + [HttpGet("~/Manager/{id}")] + public IActionResult GetManager(int id) + { + return _generator.Generate("/Manager/" + id); + } } } \ No newline at end of file