Fix for #1194 - Error using [HttpPost] and [Route] together

This change enables some compatibility scenarios with MVC 5 by expanding
the set of legal ways to configure attribute routing. Most promiently, the
following example is now legal:

[HttpPost]
[Route("Products")]
public void MyAction() { }

This will define a single action that accepts POST on route "Products".

See the comments in #1194 for a more detailed description of what changed
with more examples.
This commit is contained in:
Ryan Nowak 2014-11-14 10:55:03 -08:00
parent e21f157095
commit ed8ba5ae9c
8 changed files with 391 additions and 243 deletions

View File

@ -34,24 +34,28 @@ namespace Microsoft.AspNet.Mvc.ApplicationModels
// The set of route attributes are split into those that 'define' a route versus those that are
// 'silent'.
//
// We need to define from action for each attribute that 'defines' a route, and a single action
// We need to define an action for each attribute that 'defines' a route, and a single action
// for all of the ones that don't (if any exist).
//
// If the attribute that 'defines' a route is NOT an IActionHttpMethodProvider, then we'll include with
// it, any IActionHttpMethodProvider that are 'silent' IRouteTemplateProviders. In this case the 'extra'
// action for silent route providers isn't needed.
//
// Ex:
// [HttpGet]
// [AcceptVerbs("POST", "PUT")]
// [Route("Api/Things")]
// [HttpPost("Api/Things")]
// public void DoThing()
//
// This will generate 2 actions:
// 1. [Route("Api/Things")]
// 1. [HttpPost("Api/Things")]
// 2. [HttpGet], [AcceptVerbs("POST", "PUT")]
//
// Note that having a route attribute that doesn't define a route template _might_ be an error. We
// don't have enough context to really know at this point so we just pass it on.
var splitAttributes = new List<object>();
var hasSilentRouteAttribute = false;
var routeProviders = new List<object>();
var createActionForSilentRouteProviders = false;
foreach (var attribute in attributes)
{
var routeTemplateProvider = attribute as IRouteTemplateProvider;
@ -59,35 +63,70 @@ namespace Microsoft.AspNet.Mvc.ApplicationModels
{
if (IsSilentRouteAttribute(routeTemplateProvider))
{
hasSilentRouteAttribute = true;
createActionForSilentRouteProviders = true;
}
else
{
splitAttributes.Add(attribute);
routeProviders.Add(attribute);
}
}
}
foreach (var routeProvider in routeProviders)
{
// If we see an attribute like
// [Route(...)]
//
// Then we want to group any attributes like [HttpGet] with it.
//
// Basically...
//
// [HttpGet]
// [HttpPost("Products")]
// public void Foo() { }
//
// Is two actions. And...
//
// [HttpGet]
// [Route("Products")]
// public void Foo() { }
//
// Is one action.
if (!(routeProvider is IActionHttpMethodProvider))
{
createActionForSilentRouteProviders = false;
}
}
var actionModels = new List<ActionModel>();
if (splitAttributes.Count == 0 && !hasSilentRouteAttribute)
if (routeProviders.Count == 0 && !createActionForSilentRouteProviders)
{
actionModels.Add(CreateActionModel(methodInfo, attributes));
}
else
{
foreach (var splitAttribute in splitAttributes)
// Each of these routeProviders are the ones that actually have routing information on them
// something like [HttpGet] won't show up here, but [HttpGet("Products")] will.
foreach (var routeProvider in routeProviders)
{
var filteredAttributes = new List<object>();
foreach (var attribute in attributes)
{
if (attribute == splitAttribute)
if (attribute == routeProvider)
{
filteredAttributes.Add(attribute);
}
else if (attribute is IRouteTemplateProvider)
else if (routeProviders.Contains(attribute))
{
// Exclude other route template providers
}
else if (
routeProvider is IActionHttpMethodProvider &&
attribute is IActionHttpMethodProvider)
{
// Exclude other http method providers if this route is an
// http method provider.
}
else
{
filteredAttributes.Add(attribute);
@ -97,12 +136,12 @@ namespace Microsoft.AspNet.Mvc.ApplicationModels
actionModels.Add(CreateActionModel(methodInfo, filteredAttributes));
}
if (hasSilentRouteAttribute)
if (createActionForSilentRouteProviders)
{
var filteredAttributes = new List<object>();
foreach (var attribute in attributes)
{
if (!splitAttributes.Contains(attribute))
if (!routeProviders.Contains(attribute))
{
filteredAttributes.Add(attribute);
}
@ -250,8 +289,13 @@ namespace Microsoft.AspNet.Mvc.ApplicationModels
.SelectMany(a => a.HttpMethods)
.Distinct());
var routeTemplateProvider = attributes.OfType<IRouteTemplateProvider>().FirstOrDefault();
if (routeTemplateProvider != null && !IsSilentRouteAttribute(routeTemplateProvider))
var routeTemplateProvider =
attributes
.OfType<IRouteTemplateProvider>()
.Where(a => !IsSilentRouteAttribute(a))
.SingleOrDefault();
if (routeTemplateProvider != null)
{
actionModel.AttributeRouteModel = new AttributeRouteModel(routeTemplateProvider);
}

View File

@ -592,13 +592,9 @@ namespace Microsoft.AspNet.Mvc
ControllerActionDescriptor actionDescriptor,
IDictionary<MethodInfo, string> routingConfigurationErrors)
{
string combinedErrorMessage = null;
var hasAttributeRoutedActions = false;
var hasConventionallyRoutedActions = false;
var invalidHttpMethodActions = new Dictionary<ActionModel, IEnumerable<string>>();
var actionsForMethod = methodMap[actionDescriptor.MethodInfo];
foreach (var reflectedAction in actionsForMethod)
{
@ -613,132 +609,24 @@ namespace Microsoft.AspNet.Mvc
hasConventionallyRoutedActions = true;
}
}
// Keep a list of actions with possible invalid IHttpActionMethodProvider attributes
// to generate an error in case the method generates attribute routed actions.
ValidateActionHttpMethodProviders(reflectedAction.Key, invalidHttpMethodActions);
}
// Validate that no method result in attribute and non attribute actions at the same time.
// By design, mixing attribute and conventionally actions in the same method is not allowed.
// This is for example the case when someone uses[HttpGet("Products")] and[HttpPost]
// on the same method.
//
// This for example:
//
// [HttpGet]
// [HttpPost("Foo")]
// public void Foo() { }
if (hasAttributeRoutedActions && hasConventionallyRoutedActions)
{
combinedErrorMessage = CreateMixedRoutedActionDescriptorsErrorMessage(
var message = CreateMixedRoutedActionDescriptorsErrorMessage(
actionDescriptor,
actionsForMethod);
routingConfigurationErrors.Add(actionDescriptor.MethodInfo, message);
}
// Validate that no method that creates attribute routed actions and
// also uses attributes that only constrain the set of HTTP methods. For example,
// if an attribute that implements IActionHttpMethodProvider but does not implement
// IRouteTemplateProvider is used with an attribute that implements IRouteTemplateProvider on
// the same action, the HTTP methods provided by the attribute that only implements
// IActionHttpMethodProvider would be silently ignored, so we choose to throw to
// inform the user of the invalid configuration.
if (hasAttributeRoutedActions && invalidHttpMethodActions.Any())
{
var errorMessage = CreateInvalidActionHttpMethodProviderErrorMessage(
actionDescriptor,
invalidHttpMethodActions,
actionsForMethod);
combinedErrorMessage = CombineErrorMessage(combinedErrorMessage, errorMessage);
}
if (combinedErrorMessage != null)
{
routingConfigurationErrors.Add(actionDescriptor.MethodInfo, combinedErrorMessage);
}
}
private static void ValidateActionHttpMethodProviders(
ActionModel reflectedAction,
IDictionary<ActionModel, IEnumerable<string>> invalidHttpMethodActions)
{
var invalidHttpMethodProviderAttributes = reflectedAction.Attributes
.Where(attr => attr is IActionHttpMethodProvider &&
!(attr is IRouteTemplateProvider))
.Select(attr => attr.GetType().FullName);
if (invalidHttpMethodProviderAttributes.Any())
{
invalidHttpMethodActions.Add(
reflectedAction,
invalidHttpMethodProviderAttributes);
}
}
private static string CombineErrorMessage(string combinedErrorMessage, string errorMessage)
{
if (combinedErrorMessage == null)
{
combinedErrorMessage = errorMessage;
}
else
{
combinedErrorMessage = string.Join(
Environment.NewLine,
combinedErrorMessage,
errorMessage);
}
return combinedErrorMessage;
}
private static string CreateInvalidActionHttpMethodProviderErrorMessage(
ControllerActionDescriptor actionDescriptor,
IDictionary<ActionModel, IEnumerable<string>> invalidHttpMethodActions,
IDictionary<ActionModel, IList<ControllerActionDescriptor>> actionsForMethod)
{
var messagesForMethodInfo = new List<string>();
foreach (var invalidAction in invalidHttpMethodActions)
{
var invalidAttributesList = string.Join(", ", invalidAction.Value);
foreach (var descriptor in actionsForMethod[invalidAction.Key])
{
// We only report errors in attribute routed actions. For example, an action
// that contains [HttpGet("Products")], [HttpPost] and [HttpHead], where [HttpHead]
// only implements IHttpActionMethodProvider and restricts the action to only allow
// the head method, will report that the action contains invalid IActionHttpMethodProvider
// attributes only for the action generated by [HttpGet("Products")].
// [HttpPost] will be treated as an action that produces a conventionally routed action
// and the fact that the method generates attribute and non attributed actions will be
// reported as a different error.
if (IsAttributeRoutedAction(descriptor))
{
var messageItem = Resources.FormatAttributeRoute_InvalidHttpConstraints_Item(
descriptor.DisplayName,
descriptor.AttributeRouteInfo.Template,
invalidAttributesList,
typeof(IActionHttpMethodProvider).FullName);
messagesForMethodInfo.Add(messageItem);
}
}
}
var methodFullName = string.Format(
CultureInfo.InvariantCulture,
"{0}.{1}",
actionDescriptor.MethodInfo.DeclaringType.FullName,
actionDescriptor.MethodInfo.Name);
// Sample message:
// A method 'MyApplication.CustomerController.Index' that defines attribute routed actions must
// not have attributes that implement 'Microsoft.AspNet.Mvc.IActionHttpMethodProvider'
// and do not implement 'Microsoft.AspNet.Mvc.Routing.IRouteTemplateProvider':
// Action 'MyApplication.CustomerController.Index' has 'Namespace.CustomHttpMethodAttribute'
// invalid 'Microsoft.AspNet.Mvc.IActionHttpMethodProvider' attributes.
return
Resources.FormatAttributeRoute_InvalidHttpConstraints(
methodFullName,
typeof(IActionHttpMethodProvider).FullName,
typeof(IRouteTemplateProvider).FullName,
Environment.NewLine,
string.Join(Environment.NewLine, messagesForMethodInfo));
}
private static string CreateMixedRoutedActionDescriptorsErrorMessage(
@ -748,12 +636,22 @@ namespace Microsoft.AspNet.Mvc
// Text to show as the attribute route template for conventionally routed actions.
var nullTemplate = Resources.AttributeRoute_NullTemplateRepresentation;
var actionDescriptions = actionsForMethod
.SelectMany(a => a.Value)
.Select(ad =>
var actionDescriptions = new List<string>();
foreach (var action in actionsForMethod.SelectMany(kvp => kvp.Value))
{
var routeTemplate = action.AttributeRouteInfo?.Template ?? nullTemplate;
var verbs = action.ActionConstraints.OfType<HttpMethodConstraint>().FirstOrDefault()?.HttpMethods;
var formattedVerbs = string.Join(", ", verbs.OrderBy(v => v, StringComparer.Ordinal));
var description =
Resources.FormatAttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod_Item(
ad.DisplayName,
ad.AttributeRouteInfo != null ? ad.AttributeRouteInfo.Template : nullTemplate));
action.DisplayName,
routeTemplate,
formattedVerbs);
actionDescriptions.Add(description);
}
var methodFullName = string.Format(
CultureInfo.InvariantCulture,
@ -762,10 +660,14 @@ namespace Microsoft.AspNet.Mvc
actionDescriptor.MethodInfo.Name);
// Sample error message:
//
// A method 'MyApplication.CustomerController.Index' must not define attributed actions and
// non attributed actions at the same time:
// Action: 'MyApplication.CustomerController.Index' - Template: 'Products'
// Action: 'MyApplication.CustomerController.Index' - Template: '(none)'
// Action: 'MyApplication.CustomerController.Index' - Route Template: 'Products' - HTTP Verbs: 'PUT'
// Action: 'MyApplication.CustomerController.Index' - Route Template: '(none)' - HTTP Verbs: 'POST'
//
// Use 'AcceptVerbsAttribute' to create a single route that allows multiple HTTP verbs and defines a route,
// or set a route template in all attributes that constrain HTTP verbs.
return
Resources.FormatAttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod(
methodFullName,

View File

@ -1419,39 +1419,7 @@ namespace Microsoft.AspNet.Mvc.Core
}
/// <summary>
/// A method '{0}' that defines attribute routed actions must not have attributes that implement '{1}' and do not implement '{2}':{3}{4}
/// </summary>
internal static string AttributeRoute_InvalidHttpConstraints
{
get { return GetString("AttributeRoute_InvalidHttpConstraints"); }
}
/// <summary>
/// A method '{0}' that defines attribute routed actions must not have attributes that implement '{1}' and do not implement '{2}':{3}{4}
/// </summary>
internal static string FormatAttributeRoute_InvalidHttpConstraints(object p0, object p1, object p2, object p3, object p4)
{
return string.Format(CultureInfo.CurrentCulture, GetString("AttributeRoute_InvalidHttpConstraints"), p0, p1, p2, p3, p4);
}
/// <summary>
/// Action '{0}' with route template '{1}' has '{2}' invalid '{3}' attributes.
/// </summary>
internal static string AttributeRoute_InvalidHttpConstraints_Item
{
get { return GetString("AttributeRoute_InvalidHttpConstraints_Item"); }
}
/// <summary>
/// Action '{0}' with route template '{1}' has '{2}' invalid '{3}' attributes.
/// </summary>
internal static string FormatAttributeRoute_InvalidHttpConstraints_Item(object p0, object p1, object p2, object p3)
{
return string.Format(CultureInfo.CurrentCulture, GetString("AttributeRoute_InvalidHttpConstraints_Item"), p0, p1, p2, p3);
}
/// <summary>
/// A method '{0}' must not define attribute routed actions and non attribute routed actions at the same time:{1}{2}
/// A method '{0}' must not define attribute routed actions and non attribute routed actions at the same time:{1}{2}{1}Use 'AcceptVerbsAttribute' to create a single route that allows multiple HTTP verbs and defines a route, or set a route template in all attributes that constrain HTTP verbs.
/// </summary>
internal static string AttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod
{
@ -1459,7 +1427,7 @@ namespace Microsoft.AspNet.Mvc.Core
}
/// <summary>
/// A method '{0}' must not define attribute routed actions and non attribute routed actions at the same time:{1}{2}
/// A method '{0}' must not define attribute routed actions and non attribute routed actions at the same time:{1}{2}{1}Use 'AcceptVerbsAttribute' to create a single route that allows multiple HTTP verbs and defines a route, or set a route template in all attributes that constrain HTTP verbs.
/// </summary>
internal static string FormatAttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod(object p0, object p1, object p2)
{
@ -1467,7 +1435,7 @@ namespace Microsoft.AspNet.Mvc.Core
}
/// <summary>
/// Action: '{0}' - Template: '{1}'
/// Action: '{0}' - Route Template: '{1}' - HTTP Verbs: '{2}'
/// </summary>
internal static string AttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod_Item
{
@ -1475,11 +1443,11 @@ namespace Microsoft.AspNet.Mvc.Core
}
/// <summary>
/// Action: '{0}' - Template: '{1}'
/// Action: '{0}' - Route Template: '{1}' - HTTP Verbs: '{2}'
/// </summary>
internal static string FormatAttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod_Item(object p0, object p1)
internal static string FormatAttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod_Item(object p0, object p1, object p2)
{
return string.Format(CultureInfo.CurrentCulture, GetString("AttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod_Item"), p0, p1);
return string.Format(CultureInfo.CurrentCulture, GetString("AttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod_Item"), p0, p1, p2);
}
/// <summary>

View File

@ -388,20 +388,12 @@
<data name="TemplatedExpander_ValueFactoryCannotReturnNull" xml:space="preserve">
<value>The result of value factory cannot be null.</value>
</data>
<data name="AttributeRoute_InvalidHttpConstraints" xml:space="preserve">
<value>A method '{0}' that defines attribute routed actions must not have attributes that implement '{1}' and do not implement '{2}':{3}{4}</value>
<comment>{0} is the MethodInfo.FullName, {1} is typeof(IActionHttpMethodProvider).FullName, {2} is typeof(IRouteTemplateProvider).FullName, {3} is Environment.NewLine, {4} is the list of actions and their respective invalid IActionHttpMethodProvider attributes formatted using AttributeRoute_InvalidHttpMethodConstraints_Item</comment>
</data>
<data name="AttributeRoute_InvalidHttpConstraints_Item" xml:space="preserve">
<value>Action '{0}' with route template '{1}' has '{2}' invalid '{3}' attributes.</value>
<comment>{0} The display name of the action, {1} the route template, {2} the formatted list of invalid attributes using string.Join(", ", attributes), {3} is typeof(IActionHttpMethodProvider).FullName.</comment>
</data>
<data name="AttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod" xml:space="preserve">
<value>A method '{0}' must not define attribute routed actions and non attribute routed actions at the same time:{1}{2}</value>
<value>A method '{0}' must not define attribute routed actions and non attribute routed actions at the same time:{1}{2}{1}{1}Use 'AcceptVerbsAttribute' to create a single route that allows multiple HTTP verbs and defines a route, or set a route template in all attributes that constrain HTTP verbs.</value>
<comment>{0} is the MethodInfo.FullName, {1} is Environment.NewLine, {2} is the formatted list of actions defined by that method info.</comment>
</data>
<data name="AttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod_Item" xml:space="preserve">
<value>Action: '{0}' - Template: '{1}'</value>
<value>Action: '{0}' - Route Template: '{1}' - HTTP Verbs: '{2}'</value>
</data>
<data name="AttributeRoute_NullTemplateRepresentation" xml:space="preserve">
<value>(none)</value>

View File

@ -576,6 +576,89 @@ namespace Microsoft.AspNet.Mvc.ApplicationModels
Assert.Single(actions, ai => ai.AttributeRouteModel.Template.Equals("All"));
}
[Fact]
public void GetActions_MixedHttpVerbsAndRoutes_EmptyVerbWithRoute()
{
// Arrange
var builder = new DefaultActionModelBuilder();
var typeInfo = typeof(MixedHttpVerbsAndRouteAttributeController).GetTypeInfo();
var actionName = nameof(MixedHttpVerbsAndRouteAttributeController.VerbAndRoute);
// Act
var actions = builder.BuildActionModels(typeInfo, typeInfo.GetMethod(actionName));
// Assert
var action = Assert.Single(actions);
Assert.Equal(new string[] { "GET" }, action.HttpMethods);
Assert.Equal("Products", action.AttributeRouteModel.Template);
}
[Fact]
public void GetActions_MixedHttpVerbsAndRoutes_MultipleEmptyVerbsWithMultipleRoutes()
{
// Arrange
var builder = new DefaultActionModelBuilder();
var typeInfo = typeof(MixedHttpVerbsAndRouteAttributeController).GetTypeInfo();
var actionName = nameof(MixedHttpVerbsAndRouteAttributeController.MultipleVerbsAndRoutes);
// Act
var actions = builder.BuildActionModels(typeInfo, typeInfo.GetMethod(actionName));
// Assert
Assert.Equal(2, actions.Count());
var action = Assert.Single(actions, a => a.AttributeRouteModel.Template == "Products");
Assert.Equal(new string[] { "GET", "POST" }, action.HttpMethods);
action = Assert.Single(actions, a => a.AttributeRouteModel.Template == "v2/Products");
Assert.Equal(new string[] { "GET", "POST" }, action.HttpMethods);
}
[Fact]
public void GetActions_MixedHttpVerbsAndRoutes_MultipleEmptyAndNonEmptyVerbsWithMultipleRoutes()
{
// Arrange
var builder = new DefaultActionModelBuilder();
var typeInfo = typeof(MixedHttpVerbsAndRouteAttributeController).GetTypeInfo();
var actionName = nameof(MixedHttpVerbsAndRouteAttributeController.MultipleVerbsWithAnyWithoutTemplateAndRoutes);
// Act
var actions = builder.BuildActionModels(typeInfo, typeInfo.GetMethod(actionName));
// Assert
Assert.Equal(3, actions.Count());
var action = Assert.Single(actions, a => a.AttributeRouteModel.Template == "Products");
Assert.Equal(new string[] { "GET" }, action.HttpMethods);
action = Assert.Single(actions, a => a.AttributeRouteModel.Template == "v2/Products");
Assert.Equal(new string[] { "GET" }, action.HttpMethods);
action = Assert.Single(actions, a => a.AttributeRouteModel.Template == "Products/Buy");
Assert.Equal(new string[] { "POST" }, action.HttpMethods);
}
[Fact]
public void GetActions_MixedHttpVerbsAndRoutes_MultipleEmptyAndNonEmptyVerbs()
{
// Arrange
var builder = new DefaultActionModelBuilder();
var typeInfo = typeof(MixedHttpVerbsAndRouteAttributeController).GetTypeInfo();
var actionName = nameof(MixedHttpVerbsAndRouteAttributeController.Invalid);
// Act
var actions = builder.BuildActionModels(typeInfo, typeInfo.GetMethod(actionName));
// Assert
Assert.Equal(2, actions.Count());
var action = Assert.Single(actions, a => a.AttributeRouteModel?.Template == "Products");
Assert.Equal(new string[] { "POST" }, action.HttpMethods);
action = Assert.Single(actions, a => a.AttributeRouteModel?.Template == null);
Assert.Equal(new string[] { "GET" }, action.HttpMethods);
}
private class AccessibleActionModelBuilder : DefaultActionModelBuilder
{
public new bool IsAction([NotNull] TypeInfo typeInfo, [NotNull]MethodInfo methodInfo)
@ -765,6 +848,42 @@ namespace Microsoft.AspNet.Mvc.ApplicationModels
public void Delete() { }
}
private class MixedHttpVerbsAndRouteAttributeController : Controller
{
// Should produce a single action constrained to GET
[HttpGet]
[Route("Products")]
public void VerbAndRoute() { }
// Should produce two actions constrained to GET,POST
[HttpGet]
[HttpPost]
[Route("Products")]
[Route("v2/Products")]
public void MultipleVerbsAndRoutes() { }
// Produces:
//
// Products - GET
// v2/Products - GET
// Products/Buy - POST
[HttpGet]
[Route("Products")]
[Route("v2/Products")]
[HttpPost("Products/Buy")]
public void MultipleVerbsWithAnyWithoutTemplateAndRoutes() { }
// Produces:
//
// (no route) - GET
// Products - POST
//
// This is invalid, and will throw during the ADP construction phase.
[HttpGet]
[HttpPost("Products")]
public void Invalid() { }
}
// Here the constraints on the methods are acting as an IActionHttpMethodProvider and
// not as an IRouteTemplateProvider given that there is no RouteAttribute
// on the controller and the template for all the constraints on a method is null.

View File

@ -78,37 +78,20 @@ namespace Microsoft.AspNet.Mvc.Test
}
[Fact]
public void GetDescriptors_ThrowsIfHttpMethodConstraints_OnAttributeRoutedActions()
public void GetDescriptors_HttpMethodConstraint_RouteOnController()
{
// Arrange
var expectedExceptionMessage =
"The following errors occurred with attribute routing information:" + Environment.NewLine +
Environment.NewLine +
"Error 1:" + Environment.NewLine +
"A method 'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+" +
"AttributeRoutedHttpMethodController.PutOrPatch'" +
" that defines attribute routed actions must not have attributes that implement " +
"'Microsoft.AspNet.Mvc.IActionHttpMethodProvider' and do not implement " +
"'Microsoft.AspNet.Mvc.Routing.IRouteTemplateProvider':" + Environment.NewLine +
"Action 'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+" +
"AttributeRoutedHttpMethodController.PutOrPatch' with route template 'Products' has " +
"'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+CustomHttpMethodConstraintAttribute'" +
" invalid 'Microsoft.AspNet.Mvc.IActionHttpMethodProvider' attributes." + Environment.NewLine +
"Action 'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+" +
"AttributeRoutedHttpMethodController.PutOrPatch' with route template 'Items' has " +
"'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+CustomHttpMethodConstraintAttribute'" +
" invalid 'Microsoft.AspNet.Mvc.IActionHttpMethodProvider' attributes.";
var provider = GetProvider(
typeof(AttributeRoutedHttpMethodController)
.GetTypeInfo());
var provider = GetProvider(typeof(AttributeRoutedHttpMethodController).GetTypeInfo());
// Act
var ex = Assert.Throws<InvalidOperationException>(
() => provider.GetDescriptors());
var descriptors = provider.GetDescriptors();
var descriptor = Assert.Single(descriptors);
// Act
VerifyMultiLineError(expectedExceptionMessage, ex.Message);
// Assert
Assert.Equal("Items", descriptor.AttributeRouteInfo.Template);
var constraint = Assert.IsType<HttpMethodConstraint>(Assert.Single(descriptor.ActionConstraints));
Assert.Equal(new string[] { "PUT", "PATCH" }, constraint.HttpMethods);
}
[Fact]
@ -575,7 +558,7 @@ namespace Microsoft.AspNet.Mvc.Test
var ex = Assert.Throws<InvalidOperationException>(() => { provider.GetDescriptors(); });
// Assert
VerifyMultiLineError(expectedMessage, ex.Message);
VerifyMultiLineError(expectedMessage, ex.Message, unorderedStart: 2, unorderedLineCount: 6);
}
[Fact]
@ -656,7 +639,7 @@ namespace Microsoft.AspNet.Mvc.Test
}
[Fact]
public void AttributeRouting_AcceptVerbsOnAction_DoesNotApplyHttpMethods_ToOtherAttributeRoutes()
public void AttributeRouting_AcceptVerbsOnAction_WithoutTemplate_MergesVerb()
{
// Arrange
var provider = GetProvider(typeof(MultiRouteAttributesController).GetTypeInfo());
@ -666,6 +649,45 @@ namespace Microsoft.AspNet.Mvc.Test
// Assert
var actions = descriptors.Where(d => d.Name == "AcceptVerbsRouteAttributeAndHttpPut");
Assert.Equal(4, actions.Count());
foreach (var action in actions)
{
Assert.Equal("MultiRouteAttributes", action.ControllerName);
Assert.NotNull(action.AttributeRouteInfo);
Assert.NotNull(action.AttributeRouteInfo.Template);
}
var constrainedActions = actions.Where(a => a.ActionConstraints != null);
Assert.Equal(4, constrainedActions.Count());
// Actions generated by PutAttribute
var putActions = constrainedActions.Where(
a => a.ActionConstraints.OfType<HttpMethodConstraint>().Single().HttpMethods.Single() == "PUT");
Assert.Equal(2, putActions.Count());
Assert.Single(putActions, a => a.AttributeRouteInfo.Template.Equals("v1/All"));
Assert.Single(putActions, a => a.AttributeRouteInfo.Template.Equals("v2/All"));
// Actions generated by RouteAttribute
var routeActions = actions.Where(
a => a.ActionConstraints.OfType<HttpMethodConstraint>().Single().HttpMethods.Single() == "POST");
Assert.Equal(2, routeActions.Count());
Assert.Single(routeActions, a => a.AttributeRouteInfo.Template.Equals("v1/List"));
Assert.Single(routeActions, a => a.AttributeRouteInfo.Template.Equals("v2/List"));
}
[Fact]
public void AttributeRouting_AcceptVerbsOnAction_WithTemplate_DoesNotMergeVerb()
{
// Arrange
var provider = GetProvider(typeof(MultiRouteAttributesController).GetTypeInfo());
// Act
var descriptors = provider.GetDescriptors();
// Assert
var actions = descriptors.Where(d => d.Name == "AcceptVerbsRouteAttributeWithTemplateAndHttpPut");
Assert.Equal(6, actions.Count());
foreach (var action in actions)
@ -764,17 +786,14 @@ namespace Microsoft.AspNet.Mvc.Test
"AttributeAndNonAttributeRoutedActionsOnSameMethodController.Method'" +
" must not define attribute routed actions and non attribute routed actions at the same time:" + Environment.NewLine +
"Action: 'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+" +
"AttributeAndNonAttributeRoutedActionsOnSameMethodController.Method' - Template: 'AttributeRouted'" + Environment.NewLine +
"AttributeAndNonAttributeRoutedActionsOnSameMethodController.Method' - Route Template: 'AttributeRouted' - " +
"HTTP Verbs: 'GET'" + Environment.NewLine +
"Action: 'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+" +
"AttributeAndNonAttributeRoutedActionsOnSameMethodController.Method' - Template: '(none)'" + Environment.NewLine +
"A method 'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+" +
"AttributeAndNonAttributeRoutedActionsOnSameMethodController.Method' that defines attribute routed actions must not" +
" have attributes that implement 'Microsoft.AspNet.Mvc.IActionHttpMethodProvider' and do not implement" +
" 'Microsoft.AspNet.Mvc.Routing.IRouteTemplateProvider':" + Environment.NewLine +
"Action 'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+" +
"AttributeAndNonAttributeRoutedActionsOnSameMethodController.Method' with route template 'AttributeRouted' has " +
"'Microsoft.AspNet.Mvc.Test.ControllerActionDescriptorProviderTests+CustomHttpMethodConstraintAttribute'" +
" invalid 'Microsoft.AspNet.Mvc.IActionHttpMethodProvider' attributes.";
"AttributeAndNonAttributeRoutedActionsOnSameMethodController.Method' - Route Template: '(none)' - " +
"HTTP Verbs: 'DELETE, PATCH, POST, PUT'" + Environment.NewLine +
Environment.NewLine +
"Use 'AcceptVerbsAttribute' to create a single route that allows multiple HTTP verbs and defines a " +
"route, or set a route template in all attributes that constrain HTTP verbs.";
var provider = GetProvider(
typeof(AttributeAndNonAttributeRoutedActionsOnSameMethodController).GetTypeInfo());
@ -783,7 +802,7 @@ namespace Microsoft.AspNet.Mvc.Test
var exception = Assert.Throws<InvalidOperationException>(() => provider.GetDescriptors());
// Assert
VerifyMultiLineError(expectedMessage, exception.Message);
VerifyMultiLineError(expectedMessage, exception.Message, unorderedStart: 1, unorderedLineCount: 2);
}
[Fact]
@ -1137,6 +1156,7 @@ namespace Microsoft.AspNet.Mvc.Test
var options = new MockMvcOptionsAccessor();
options.Options.ApplicationModelConventions.Add(applicationConvention.Object);
var applicationModel = new ApplicationModel();
var controller = new ControllerModel(typeof(ConventionsController).GetTypeInfo(),
@ -1345,7 +1365,7 @@ namespace Microsoft.AspNet.Mvc.Test
}
private ControllerActionDescriptorProvider GetProvider(
TypeInfo type,
TypeInfo type,
IOptions<MvcOptions> options)
{
var modelBuilder = new StaticControllerModelBuilder(type);
@ -1380,14 +1400,48 @@ namespace Microsoft.AspNet.Mvc.Test
return provider.GetDescriptors();
}
private static void VerifyMultiLineError(string expectedMessage, string actualMessage)
private static void VerifyMultiLineError(
string expectedMessage,
string actualMessage,
int unorderedStart,
int unorderedLineCount)
{
// The error message depends on the order of attributes returned by reflection which is not consistent across
// platforms. We'll compare them individually instead.
Assert.Equal(expectedMessage.Split(new[] { Environment.NewLine }, StringSplitOptions.None)
.OrderBy(m => m, StringComparer.Ordinal),
actualMessage.Split(new[] { Environment.NewLine }, StringSplitOptions.None)
.OrderBy(m => m, StringComparer.Ordinal));
var expectedLines = expectedMessage
.Split(new[] { Environment.NewLine }, StringSplitOptions.None)
.ToArray();
var actualLines = actualMessage
.Split(new[] { Environment.NewLine }, StringSplitOptions.None)
.ToArray();
for (var i = 0; i < unorderedStart; i++)
{
Assert.Equal(expectedLines[i], actualLines[i]);
}
var orderedExpectedLines = expectedLines
.Skip(unorderedStart)
.Take(unorderedLineCount)
.OrderBy(l => l, StringComparer.Ordinal)
.ToArray();
var orderedActualLines = actualLines
.Skip(unorderedStart)
.Take(unorderedLineCount)
.OrderBy(l => l, StringComparer.Ordinal)
.ToArray();
for (var i = 0; i < unorderedLineCount; i++)
{
Assert.Equal(orderedExpectedLines[i], orderedActualLines[i]);
}
for (var i = unorderedStart + unorderedLineCount; i < expectedLines.Length; i++)
{
Assert.Equal(expectedLines[i], actualLines[i]);
}
Assert.Equal(expectedLines.Length, actualLines.Length);
}
private class HttpMethodController
@ -1398,7 +1452,6 @@ namespace Microsoft.AspNet.Mvc.Test
}
}
[Route("Products")]
[Route("Items")]
private class AttributeRoutedHttpMethodController
{
@ -1588,6 +1641,11 @@ namespace Microsoft.AspNet.Mvc.Test
[Route("List")]
[HttpPut("All")]
public void AcceptVerbsRouteAttributeAndHttpPut() { }
[AcceptVerbs("POST", Route = "")]
[Route("List")]
[HttpPut("All")]
public void AcceptVerbsRouteAttributeWithTemplateAndHttpPut() { }
}
[Route("Products")]
@ -1757,7 +1815,7 @@ namespace Microsoft.AspNet.Mvc.Test
public void Edit() { }
}
private class ApiExplorerNameOnActionController
{
[ApiExplorerSettings(GroupName = "Blog")]

View File

@ -1324,6 +1324,54 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
Assert.Null(result.Link);
}
[Theory]
[InlineData("/Bank/Deposit", "PUT", "Deposit")]
[InlineData("/Bank/Deposit", "POST", "Deposit")]
[InlineData("/Bank/Deposit/5", "PUT", "Deposit")]
[InlineData("/Bank/Deposit/5", "POST", "Deposit")]
[InlineData("/Bank/Withdraw/5", "POST", "Withdraw")]
public async Task AttributeRouting_MixedAcceptVerbsAndRoute_Reachable(string path, string verb, string actionName)
{
// Arrange
var server = TestServer.Create(_services, _app);
var client = server.CreateClient();
var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path);
// Act
var response = await client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<RoutingResult>(body);
Assert.Contains(path, result.ExpectedUrls);
Assert.Equal("Banks", result.Controller);
Assert.Equal(actionName, result.Action);
}
// These verbs don't match
[Theory]
[InlineData("/Bank/Deposit", "GET")]
[InlineData("/Bank/Deposit/5", "DELETE")]
[InlineData("/Bank/Withdraw/5", "GET")]
public async Task AttributeRouting_MixedAcceptVerbsAndRoute_Unreachable(string path, string verb)
{
// Arrange
var server = TestServer.Create(_services, _app);
var client = server.CreateClient();
var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path);
// Act
var response = await client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private static LinkBuilder LinkFrom(string url)
{
return new LinkBuilder(url);

View File

@ -1,5 +1,7 @@
using Microsoft.AspNet.Mvc;
using System;
// 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
{
@ -31,5 +33,20 @@ namespace RoutingWebSite
Url.Action(),
Url.RouteUrl(new { }));
}
[AcceptVerbs("PUT", "POST")]
[Route("Bank/Deposit")]
[Route("Bank/Deposit/{amount}")]
public ActionResult Deposit()
{
return _generator.Generate("/Bank/Deposit", "/Bank/Deposit/5");
}
[HttpPost]
[Route("Bank/Withdraw/{id}")]
public ActionResult Withdraw(int id)
{
return _generator.Generate("/Bank/Withdraw/5");
}
}
}