Allow me to preface this by saying I knew how everything described in this post worked – at least on one level. What dawned on me the other day was the underlying connection between these facts, a deeper understanding of how MVC works. This post will fall into the bucket of "Mark was a little late to the enlightened party – again."
Let’s say I have a controller called "Home", with an action that looks like this:
Function Index() As ActionResult
Return View()
End Function
By default, the controller actions are available using any HTTP verb. In the above case, both "POST /home/index" and "GET /home/index" (or any other HTTP verb for that matter) will result in this controller action being invoked. You can, however, explicitly declare that the method is only allowed for GETs (for example) by adding the HttpGet attribute to the action:
<HttpGet()> _
Function Index() As ActionResult
Return View()
End Function
Alternatively you can decorate the action with an attribute to make it respond only to an HTTP POST:
<HttpPost()> _
Function Index() As ActionResult
Return View()
End Function
By adding these attributes, you’re telling MVC when to invoke this particular action – “only on GETs” or “only on POSTs”. So far so good. Now, let’s say you have two different submit buttons on the Index view’s form.
<form action="/Home/Action" method="post">
<input type="submit" id="Edit" name="Edit" value="Edit" />
<input type="submit" id="Save" name="Save" value="Save" />
</form>
How could you wire those two submit buttons up so that they would invoke two different controller actions, such as the following:
<HttpPost()> _
Function Edit() As ActionResult
ViewData("Message") = "Edit clicked!"
Return View("About")
End Function
<HttpPost()> _
Function Save() As ActionResult
ViewData("Message") = "Save clicked!"
Return View("About")
End Function
The problem here is that MVC doesn’t have a way to distinguish between these two actions. They both configured to respond to HTTP POSTs. You can’t change one of them to use HTTP GET because you will presumably be POSTing data in both cases. MVC needs some other way to determine which action is appropriate. Enter the HttpParamAction attribute, by Andrey Shchekin. I won’t go into depth how this attribute works (Shchekin does a fine job of that), but here’s the 30-second explanation: it allows you to match the names of buttons on your form to the action methods in the controller, and therefore respond to those buttons individually.
So, let’s modify our methods’ attributes:
<HttpPost()> _
<HttpParamAction()> _
Function Edit() As ActionResult
ViewData("Message") = "Edit clicked!"
Return View("About")
End Function
<HttpPost()> _
<HttpParamAction()> _
Function Save() As ActionResult
ViewData("Message") = "Save clicked!"
Return View("About")
End Function
Now, when the user clicks the "Edit" button, the MVC framework starts by going through the actions defined in the controller looking for the one to invoke. The HttpPost and HttpParamAction attributes work together to force MVC to only invoke the Edit() action if
- the form is being POSTed, and
- the "Edit" button was the one used to do the POSTing
Likewise with the Save() action and the "Save" button.
On a side-note, did you notice the form’s action attribute was defined as "/home/action"? Where did the "action" come from? It is just a made-up name. The important thing to note is that whatever it is needs to match what is defined in the attribute itself. In his post above, Shchekin mentions that his original solution just hard-coded the name of the action name into the attribute, but an improvement would be to pass that in as a parameter to the attribute. The action name is basically another way to allow MVC to filter the incorrect methods out – if you changed the HTML to post to /home/blah, neither the Edit() nor the Save() methods would be matched.
***
Now, let’s say I had a Home controller action that looked like this:
<HttpGet()> _
Function Detail(id As Long) As ActionResult
ViewData("Message") = String.Format("Product ID {0} coming right up", id)
Return View("Detail")
End Function
That would accept requests such as
GET /Home/Detail?id=12345
When I do a GET on this URI, MVC maps the "id" querystring value to my method’s "ID" parameter, and all is well with the world. But what if I wanted to also allow requests like this:
GET /Home/Detail?sku=9876545134&locale=en-us
My first instinct would be to create a second controller action, with those other parameters:
<HttpGet()> _
Function Detail(id As Long) As ActionResult
ViewData("Message") = String.Format("Product ID {0} coming right up", id)
Return View("Detail")
End Function
<HttpGet()> _
Function Detail(sku As Long, locale As String) As ActionResult
ViewData("Message") = String.Format("Product SKU {0} for Locale {1} coming right up", sku, locale)
Return View("Detail")
End Function
Here’s where I run into trouble. With these two actions defined, both of these requests:
GET /Home/Detail?id=12345
GET /Home/Detail?sku=9876545134&locale=en-us
Result in what I call the "ambiguous" error, or more precisely, the AmbiguousMatchException:
The current request for action ‘Detail’ on controller type ‘HomeController’ is ambiguous between the following action methods:
System.Web.Mvc.ActionResult Detail(Int64) on type MvcApplication1.HomeController
System.Web.Mvc.ActionResult Detail(Int64, System.String) on type MvcApplication1.HomeController
What happened here? MVC finds two different methods marked as HttpGet, but can’t figure out which one to invoke for the above requests. If this were a "regular" class these would be two overloaded variants of the same method, and the .NET runtime would have no problem distinguishing them purely based on their signatures.
Ok, so what if I were to merge the two methods, so there was only one to find?
<HttpGet()> _
Function Detail(id As Long, sku As Long, locale As String) As ActionResult
ViewData("Message") = String.Format("Product ID {0}, SKU {1} for Locale {2} coming right up", id, sku, locale)
Return View("Detail")
End Function
That brings up a different problem. A request such as
GET /Home/Detail?id=12345
Will fail with an ArgumentException:
The parameters dictionary contains a null entry for parameter ‘sku’ of non-nullable type ‘System.Int64’ for method ‘System.Web.Mvc.ActionResult Detail(Int64, Int64, System.String)’ in ‘MvcApplication1.HomeController’. An optional parameter must be a reference type, a nullable type, or be declared as an optional parameter.
Parameter name: parameters
Objects of type "Long" are not nullable, but the request only included a parameter of "id". There wasn’t anything passed in for the "sku", and since it wasn’t declared as "Nullable", it fails. The "locale" parameter, being a string, is nullable, so this doesn’t cause any problems.
Likewise, a request for
GET /Home/Detail?sku=9876545134&locale=en-us
Will fail with a similar exception:
The parameters dictionary contains a null entry for parameter ‘id’ of non-nullable type ‘System.Int64’ for method ‘System.Web.Mvc.ActionResult Detail(Int64, Int64, System.String)’ in ‘MvcApplication1.HomeController’. An optional parameter must be a reference type, a nullable type, or be declared as an optional parameter.
Parameter name: parameters
Notice that the "id" property is referenced in this message, rather than the "sku" parameter.
Ok, so why not just make the two Long parameters nullable?
<HttpGet()> _
Function Detail(id As Long?, sku As Long?, locale As String) As ActionResult
ViewData("Message") = String.Format("Product ID {0}, SKU {1} for Locale {2} coming right up", id, sku, locale)
Return View("Detail")
End Function
Well, that avoids the exceptions. With both "id" and "sku" marked as "nullable" (notice the question marks after Long), both of our requests will go through, and the named parameters will be populated with data from the querystring.
However, this is a pretty poor design pattern, and you should avoid it. With some parameters being populated for some requests, and other parameters for other requests, you have to manually examine the incoming data to figure out what to do. There should be a better way to distinguish the requests.
And there is.
As this StackOverflow answer points out, you can overload MVC actions based on the parameters, you just have to do it through an attribute that looks at what gets included in the HTTP request, rather than the method signature. The RequireRequestValue attribute allows us to force MVC to match based on a parameter passed in with the request. If it doesn’t see the named parameter, then the current action being evaluated can’t be the "right" one. With this in place, I can now decorate my methods to distinguish them:
<HttpGet()> _
<RequireRequestValue({"id"})> _
Function Detail(id As Long) As ActionResult
ViewData("Message") = String.Format("Product ID {0} coming right up", id)
Return View("Detail")
End Function
<HttpGet()> _
<RequireRequestValue({"sku", "locale"})> _
Function Detail(sku As Long, locale As String) As ActionResult
ViewData("Message") = String.Format("Product SKU {0} for Locale {1} coming right up", sku, locale)
Return View("Detail")
End Function
Full Disclosure: I did have to modify their solution a bit to allow the parameters to be named in the querystring (the original looked for the data in the route only). Here is the VB-ported solution, with the modification:
Public Class RequireRequestValueAttribute
Inherits ActionMethodSelectorAttribute
Public Property ValueNames As String()
Public Sub New(valueNames As String())
Me.ValueNames = valueNames
End Sub
Public Overrides Function IsValidForRequest(controllerContext As System.Web.Mvc.ControllerContext, methodInfo As System.Reflection.MethodInfo) As Boolean
Dim contains As Boolean = False
For Each value As String In Me.ValueNames
contains = controllerContext.RequestContext.RouteData.Values.ContainsKey(value) Or Not IsNothing(controllerContext.RequestContext.HttpContext.Request.QueryString.Item(value))
If Not contains Then Exit For
Next
Return contains
End Function
End Class
The modification is the addition of the "Or Not IsNothing(…)" clause in the "contains" variable assignment. The first half looks for the parameters in the route itself (for example, if our routing allowed things like "GET /home/detail/{id}"), while the addition looks for these in the querystring.
With the addition of the RequireRequestValueAttribute attribute, we can now tell MVC how to distinguish between our overloaded variants.
Incidentally, adding these also means that a request like this:
GET /home/detail?blah=12132123
Will match neither variant, and unless you have a default route for "controller/action/" defined you’ll get a 404.
***
To sum up, then:
- MVC looks at the attributes on a controller action – not the method signatures – when it is trying to determine which one to invoke.
- You can stack the attributes on a controller action, and they will be AND-ed together. Only if all conditions are met will that particular controller action be invoked by the MVC framework.
Now that I’ve joined the party, do you think I can get away with claiming that I was just "fashionably" late?
Yeah, ok, I get it. You can stop laughing now.