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" });
});
}
}