From b72b44c20c145d07475e06c8ea86e25db2d0a657 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 14 Jul 2014 15:41:56 -0700 Subject: [PATCH] Implement RouteKeyHandling.CatchAll --- .../Properties/Resources.Designer.cs | 16 +++ .../ReflectedActionDescriptorProvider.cs | 23 +++- src/Microsoft.AspNet.Mvc.Core/Resources.resx | 3 + .../RouteConstraintAttribute.cs | 64 +++++++++++ .../DefaultActionSelectorTests.cs | 87 +++++++++++++++ .../Microsoft.AspNet.Mvc.Core.Test.kproj | 2 +- .../RoutingTests.cs | 104 ++++++++++++++++++ .../Products/ProductsController.cs | 23 ++++ .../Products/US/ProductsController.cs | 23 ++++ .../RoutingWebSite/CountryNeutralAttribute.cs | 15 +++ .../CountrySpecificAttribute.cs | 15 +++ .../RoutingWebSite/RoutingWebSite.kproj | 4 + test/WebSites/RoutingWebSite/Startup.cs | 12 +- 13 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 test/WebSites/RoutingWebSite/Controllers/Products/ProductsController.cs create mode 100644 test/WebSites/RoutingWebSite/Controllers/Products/US/ProductsController.cs create mode 100644 test/WebSites/RoutingWebSite/CountryNeutralAttribute.cs create mode 100644 test/WebSites/RoutingWebSite/CountrySpecificAttribute.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs index f6acaed810..1efeb0c0f4 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs @@ -1226,6 +1226,22 @@ namespace Microsoft.AspNet.Mvc.Core return GetString("AttributeRoute_TokenReplacement_UnescapedBraceInToken"); } + /// + /// The value must be either '{0}' or '{1}'. + /// + internal static string RouteConstraintAttribute_InvalidKeyHandlingValue + { + get { return GetString("RouteConstraintAttribute_InvalidKeyHandlingValue"); } + } + + /// + /// The value must be either '{0}' or '{1}'. + /// + internal static string FormatRouteConstraintAttribute_InvalidKeyHandlingValue(object p0, object p1) + { + return string.Format(CultureInfo.CurrentCulture, GetString("RouteConstraintAttribute_InvalidKeyHandlingValue"), p0, p1); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs index e7d3e3cd62..8d263f89ee 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs @@ -215,9 +215,18 @@ namespace Microsoft.AspNet.Mvc // Skip duplicates if (!HasConstraint(actionDescriptor.RouteConstraints, constraintAttribute.RouteKey)) { - actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( - constraintAttribute.RouteKey, - constraintAttribute.RouteValue)); + if (constraintAttribute.RouteValue == null) + { + actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( + constraintAttribute.RouteKey, + constraintAttribute.RouteKeyHandling)); + } + else + { + actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( + constraintAttribute.RouteKey, + constraintAttribute.RouteValue)); + } } } @@ -229,7 +238,13 @@ namespace Microsoft.AspNet.Mvc // want to provide these values as ambient values. foreach (var constraint in actionDescriptor.RouteConstraints) { - actionDescriptor.RouteValueDefaults.Add(constraint.RouteKey, constraint.RouteValue); + // We don't need to do anything with attribute routing for 'catch all' behavior. Order + // and predecedence of attribute routes allow this kind of behavior. + if (constraint.KeyHandling == RouteKeyHandling.RequireKey || + constraint.KeyHandling == RouteKeyHandling.DenyKey) + { + actionDescriptor.RouteValueDefaults.Add(constraint.RouteKey, constraint.RouteValue); + } } // Replaces tokens like [controller]/[action] in the route template with the actual values diff --git a/src/Microsoft.AspNet.Mvc.Core/Resources.resx b/src/Microsoft.AspNet.Mvc.Core/Resources.resx index 0746343d75..ef973ac845 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Core/Resources.resx @@ -348,4 +348,7 @@ An unescaped '[' token is not allowed inside of a replacement token. Use '[[' to escape. + + The value must be either '{0}' or '{1}'. + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/RouteConstraintAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/RouteConstraintAttribute.cs index eda9928ade..f5be7295ba 100644 --- a/src/Microsoft.AspNet.Mvc.Core/RouteConstraintAttribute.cs +++ b/src/Microsoft.AspNet.Mvc.Core/RouteConstraintAttribute.cs @@ -2,12 +2,59 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNet.Mvc.Core; namespace Microsoft.AspNet.Mvc { + /// + /// An attribute which specifies a required route value for an action or controller. + /// + /// When placed on an action, the route data of a request must match the expectations of the route + /// constraint in order for the action to be selected. See for + /// the expectations that must be satisfied by the route data. + /// + /// When placed on a controller, unless overridden by the action, the constraint applies to all + /// actions defined by the controller. + /// + /// + /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] public abstract class RouteConstraintAttribute : Attribute { + /// + /// Creates a new . + /// + /// The route value key. + /// + /// The value. Must be + /// or . + /// + protected RouteConstraintAttribute( + [NotNull] string routeKey, + RouteKeyHandling keyHandling) + { + RouteKey = routeKey; + RouteKeyHandling = keyHandling; + + if (keyHandling != RouteKeyHandling.CatchAll && + keyHandling != RouteKeyHandling.DenyKey) + { + var message = Resources.FormatRouteConstraintAttribute_InvalidKeyHandlingValue( + Enum.GetName(typeof(RouteKeyHandling), RouteKeyHandling.CatchAll), + Enum.GetName(typeof(RouteKeyHandling), RouteKeyHandling.DenyKey)); + throw new ArgumentException(message, "keyHandling"); + } + } + + /// + /// Creates a new with + /// set to . + /// + /// The route value key. + /// The expected route value. + /// + /// Set to true to negate this constraint on all actions that do not define a behavior for this route key. + /// protected RouteConstraintAttribute( [NotNull]string routeKey, [NotNull]string routeValue, @@ -18,8 +65,25 @@ namespace Microsoft.AspNet.Mvc BlockNonAttributedActions = blockNonAttributedActions; } + /// + /// The route value key. + /// public string RouteKey { get; private set; } + + /// + /// The . + /// + public RouteKeyHandling RouteKeyHandling { get; private set; } + + /// + /// The expected route value. Will be null unless is + /// set to . + /// public string RouteValue { get; private set; } + + /// + /// Set to true to negate this constraint on all actions that do not define a behavior for this route key. + /// public bool BlockNonAttributedActions { get; private set; } } } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTests.cs index 9f72ec2bdd..d0fef35e57 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTests.cs @@ -176,6 +176,92 @@ namespace Microsoft.AspNet.Mvc Assert.Same(action, actionWithConstraints); } + [Fact] + public async Task SelectAsync_WithCatchAll_PrefersNonCatchAll() + { + // Arrange + var actions = new ActionDescriptor[] + { + CreateAction(area: null, controller: "Store", action: "Buy"), + CreateAction(area: null, controller: "Store", action: "Buy"), + CreateAction(area: null, controller: "Store", action: "Buy"), + }; + + actions[0].RouteConstraints.Add(new RouteDataActionConstraint("country", "CA")); + actions[1].RouteConstraints.Add(new RouteDataActionConstraint("country", "US")); + actions[2].RouteConstraints.Add(new RouteDataActionConstraint("country", RouteKeyHandling.CatchAll)); + + var selector = CreateSelector(actions); + var context = CreateRouteContext("GET"); + + context.RouteData.Values.Add("controller", "Store"); + context.RouteData.Values.Add("action", "Buy"); + context.RouteData.Values.Add("country", "CA"); + + // Act + var action = await selector.SelectAsync(context); + + // Assert + Assert.Same(action, actions[0]); + } + + [Fact] + public async Task SelectAsync_WithCatchAll_CatchAllIsOnlyMatch() + { + // Arrange + var actions = new ActionDescriptor[] + { + CreateAction(area: null, controller: "Store", action: "Buy"), + CreateAction(area: null, controller: "Store", action: "Buy"), + CreateAction(area: null, controller: "Store", action: "Buy"), + }; + + actions[0].RouteConstraints.Add(new RouteDataActionConstraint("country", "CA")); + actions[1].RouteConstraints.Add(new RouteDataActionConstraint("country", "US")); + actions[2].RouteConstraints.Add(new RouteDataActionConstraint("country", RouteKeyHandling.CatchAll)); + + var selector = CreateSelector(actions); + var context = CreateRouteContext("GET"); + + context.RouteData.Values.Add("controller", "Store"); + context.RouteData.Values.Add("action", "Buy"); + context.RouteData.Values.Add("country", "DE"); + + // Act + var action = await selector.SelectAsync(context); + + // Assert + Assert.Same(action, actions[2]); + } + + [Fact] + public async Task SelectAsync_WithCatchAll_NoMatch() + { + // Arrange + var actions = new ActionDescriptor[] + { + CreateAction(area: null, controller: "Store", action: "Buy"), + CreateAction(area: null, controller: "Store", action: "Buy"), + CreateAction(area: null, controller: "Store", action: "Buy"), + }; + + actions[0].RouteConstraints.Add(new RouteDataActionConstraint("country", "CA")); + actions[1].RouteConstraints.Add(new RouteDataActionConstraint("country", "US")); + actions[2].RouteConstraints.Add(new RouteDataActionConstraint("country", RouteKeyHandling.CatchAll)); + + var selector = CreateSelector(actions); + var context = CreateRouteContext("GET"); + + context.RouteData.Values.Add("controller", "Store"); + context.RouteData.Values.Add("action", "Buy"); + + // Act + var action = await selector.SelectAsync(context); + + // Assert + Assert.Null(action); + } + private static ActionDescriptor[] GetActions() { return new ActionDescriptor[] @@ -268,6 +354,7 @@ namespace Microsoft.AspNet.Mvc { Name = string.Format("Area: {0}, Controller: {1}, Action: {2}", area, controller, action), RouteConstraints = new List(), + Parameters = new List(), }; actionDescriptor.RouteConstraints.Add( diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj index 7f051786f8..f9e444cdaa 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj @@ -120,4 +120,4 @@ - + \ 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 b4b4c44661..df02fc80e7 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs @@ -817,6 +817,110 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal("/Admin/Users/All", result.Link); } + [Fact] + public async Task ControllerWithCatchAll_CanReachSpecificCountry() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/api/Products/US/GetProducts"); + + // Assert + Assert.Equal(200, response.StatusCode); + + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Products/US/GetProducts", result.ExpectedUrls); + Assert.Equal("Products", result.Controller); + Assert.Equal("GetProducts", result.Action); + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "country", "US" }, + { "action", "GetProducts" }, + { "controller", "Products" }, + }, + result.RouteValues); + } + + // The 'default' route doesn't provide a value for {country} + [Fact] + public async Task ControllerWithCatchAll_CannotReachWithoutCountry() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/Products/GetProducts"); + + // Assert + Assert.Equal(404, response.StatusCode); + } + + [Fact] + public async Task ControllerWithCatchAll_GenerateLinkForSpecificCountry() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = + LinkFrom("http://localhost/") + .To(new { action = "GetProducts", controller = "Products", country = "US" }); + var response = await client.GetAsync(url); + + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("/api/Products/US/GetProducts", result.Link); + } + + [Fact] + public async Task ControllerWithCatchAll_GenerateLinkForFallback() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = + LinkFrom("http://localhost/") + .To(new { action = "GetProducts", controller = "Products", country = "CA" }); + var response = await client.GetAsync(url); + + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("/api/Products/CA/GetProducts", result.Link); + } + + [Fact] + public async Task ControllerWithCatchAll_GenerateLink_FailsWithoutCountry() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = + LinkFrom("http://localhost/") + .To(new { action = "GetProducts", controller = "Products", country = (string)null }); + var response = await client.GetAsync(url); + + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Null(result.Link); + } + private static LinkBuilder LinkFrom(string url) { return new LinkBuilder(url); diff --git a/test/WebSites/RoutingWebSite/Controllers/Products/ProductsController.cs b/test/WebSites/RoutingWebSite/Controllers/Products/ProductsController.cs new file mode 100644 index 0000000000..f8c67feb3c --- /dev/null +++ b/test/WebSites/RoutingWebSite/Controllers/Products/ProductsController.cs @@ -0,0 +1,23 @@ +// 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 Microsoft.AspNet.Mvc; + +namespace RoutingWebSite.Products +{ + [CountryNeutral] + public class ProductsController : Controller + { + private readonly TestResponseGenerator _generator; + + public ProductsController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult GetProducts() + { + return _generator.Generate("/api/Products/CA/GetProducts"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/Controllers/Products/US/ProductsController.cs b/test/WebSites/RoutingWebSite/Controllers/Products/US/ProductsController.cs new file mode 100644 index 0000000000..3acad912c3 --- /dev/null +++ b/test/WebSites/RoutingWebSite/Controllers/Products/US/ProductsController.cs @@ -0,0 +1,23 @@ +// 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 Microsoft.AspNet.Mvc; + +namespace RoutingWebSite.Products.US +{ + [CountrySpecific("US")] + public class ProductsController : Controller + { + private readonly TestResponseGenerator _generator; + + public ProductsController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult GetProducts() + { + return _generator.Generate("/api/Products/US/GetProducts"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/CountryNeutralAttribute.cs b/test/WebSites/RoutingWebSite/CountryNeutralAttribute.cs new file mode 100644 index 0000000000..0c3274a3b4 --- /dev/null +++ b/test/WebSites/RoutingWebSite/CountryNeutralAttribute.cs @@ -0,0 +1,15 @@ +// 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 Microsoft.AspNet.Mvc; + +namespace RoutingWebSite +{ + public class CountryNeutralAttribute : RouteConstraintAttribute + { + public CountryNeutralAttribute() + : base("country", RouteKeyHandling.CatchAll) + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/CountrySpecificAttribute.cs b/test/WebSites/RoutingWebSite/CountrySpecificAttribute.cs new file mode 100644 index 0000000000..44febbbb6f --- /dev/null +++ b/test/WebSites/RoutingWebSite/CountrySpecificAttribute.cs @@ -0,0 +1,15 @@ +// 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 Microsoft.AspNet.Mvc; + +namespace RoutingWebSite +{ + public class CountrySpecificAttribute : RouteConstraintAttribute + { + public CountrySpecificAttribute(string countryCode) + : base("country", countryCode, blockNonAttributedActions: false) + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/RoutingWebSite.kproj b/test/WebSites/RoutingWebSite/RoutingWebSite.kproj index a7ccec35b7..d527b3506d 100644 --- a/test/WebSites/RoutingWebSite/RoutingWebSite.kproj +++ b/test/WebSites/RoutingWebSite/RoutingWebSite.kproj @@ -35,9 +35,13 @@ + + + + diff --git a/test/WebSites/RoutingWebSite/Startup.cs b/test/WebSites/RoutingWebSite/Startup.cs index eb11249f9c..65aee1009a 100644 --- a/test/WebSites/RoutingWebSite/Startup.cs +++ b/test/WebSites/RoutingWebSite/Startup.cs @@ -1,7 +1,8 @@ -using Microsoft.AspNet.Builder; -using Microsoft.AspNet.Mvc; +// 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 Microsoft.AspNet.Builder; using Microsoft.AspNet.Routing; -using Microsoft.Framework.ConfigurationModel; using Microsoft.Framework.DependencyInjection; namespace RoutingWebSite @@ -27,6 +28,11 @@ namespace RoutingWebSite routes.MapRoute("ActionAsMethod", "{controller}/{action}", defaults: new { controller = "Home", action = "Index" }); + + routes.MapRoute( + "products", + "api/Products/{country}/{action}", + defaults: new { controller = "Products" }); }); } }