Practical .NET

Handling Bad URLs in ASP.NET MVC

If users enter an invalid URL, then ASP.NET MVC will handle the problem by issuing a generic HTTP error. Here's how to give users more support (plus some advice on avoiding the problem altogether).

ASP.NET MVC is driven by the URLs your users provide to get to the Views they want to see. If those URLs don't include a controller or action method name, then you can provide the missing information through default values on your routes.

But if those URLs include an incorrect action or controller name, then ASP.NET MVC will simply return one of the standard HTTP error codes. You might prefer to provide a View with a friendlier message or even View with links leading to actual pages on your site. Alternatively, if you think users will be rewriting URLs in their browser's address bar (a practice called URL butchery), you might even want to take a guess at what the user really wanted and send them to the correct page by handling common misspellings.

I don't know how often these kinds of errors occur in production sites. I do know that I get my URLs wrong during development all. the. time. If nothing else, you can use these strategies to signal to yourself when you've provided an incorrect URL.

There are, essentially, three URL-related errors you need to handle:

  • The user's URL doesn't match any of your routing patterns
  • The action method doesn't exist in the controller
  • The controller doesn't exist

This column shows how to provide a custom View in each of those cases but, first, a discussion on avoiding the problem altogether.

Avoiding Errors
I have nothing against the default ASP.NET MVC route of {controller}/{action}/{id} that embeds class and method names in the URL, includes an optional third parameter (id), and has default values for both the controller and action names. Nothing, except that it's the worst routing pattern in the world.

The number of utilities that exist to help you check your ASP.NET MVC routes are doing what you think they're doing suggests one thing: Developers have a hard time thinking about routes. As long as all of your routes have a different number of forward slashes in them, it's pretty easy to see what URLs will match to which routes. However, once you start including optional parameters and default values … well, then, your chances of being surprised about how your URLs match to your controllers and action methods increases.

Many developers, for example, take a while to recognize that the default route actually absorbs four kinds of URLs: those with three parts (controller name, action method name, id), those with just two parts (controller name, action method name), those with one part (controller name) and those with no parts at all. Only a URL with four (or more) parts won't be matched to this route by ASP.NET MVC.

More importantly, from a design point of view, the default route tightly binds your internal class and method names to your public interface. I can't think of any other programming environment where it would be considered a good idea to embed class and method names in the UI (at least, no environment in which I'd want to work). Among other problems this binding creates, if you ever want to redirect a URL to another controller or action method, you'll have to jump through some hoops to avoid breaking any existing bookmarks/calls from other applications.

Instead of embedding class names in your URLs, use arbitrary strings. The recommended Microsoft practice in the ASP.NET Web API of prefixing all service URLs with "api" is an example of this strategy. The only replaceable parameters in your routing patterns should be the values that users provide to your action methods. Exile your controller and action method names to your route's default values.

This example ties URLs beginning with "Customer/Sales" to a controller called Sales and an action method called CustomerForYear:

routes.MapRoute( _
      name:="SalesForCustomer", _
      url:="Customer/Sales/{cust}/{year}", _
      defaults:=New With {.controller = "Sales", .action = "CustomerForYear"} _
  )

You know now that only those URLs with four parts beginning with "Customer/Sales" will match to this route. In addition, you can now redirect those URLs to any controller or action method without requiring any change to the URLs being used by your clients by changing the default values for controller and action. Yes, you will have one route for each action method. It's a small price to pay for actually knowing what's going on. If this is going too far for you, consider, at least, eliminating the controller name with routes like this:

routes.MapRoute( _
      name:="SalesForCustomer", _
      url:="Customer/{action}/{cust}/{year}", _
      defaults:=New With {.controller = "Sales", .action = "CustomerForYear"} _
  )

Handling the Easiest Problems
However, this doesn't stop users from providing URLs that don't match any of your routes. Fortunately, of the three kinds of URL-related errors, handling a non-matching route is the easiest to handle – you just need to do two things. First: Add a route pattern to the end of your routing table that matches to any URL. Second: Set the default controller and action values for this pattern to the controller/action method that will display the "more helpful" View you want to provide. (And, I guess, a third step: Provide that controller/action/View.)

This example defines a "matches any" route and ties it to a controller called Error containing an action method called Handler:

routes.MapRoute( _
      name:="CatchAll", _
      url:="{*any}", _
      defaults:=New With {.controller = "Error", .action = "Handler"} _
  )

If you're following my advice about building routes and not including action methods and controller names in your URLs, this is the only case you need to handle -- you can ignore the rest of this column because your users can never provide a bad controller or action method name.

But if you're embedding action method names in your URLs, then the good news is that the other two cases aren't much more difficult to handle. Handling the case where the URL provided does match one of your routes and the controller name is right but the action name isn't just requires you to override the controller's HandleUnknownAction method. The only problem is that the HandleUnknownAction method doesn't return a value so you can't just return the results of the View method, passing the name of your more helpful review.

There is a workaround, though, and you still get to use the controller's View method to generate a View. However, instead of returning the View to ASP.NET MVC so that it can process your View, you process the View yourself by calling the View's ExecuteResult method. The ExecuteResult method must be passed an MVC ControllerContext object, but, fortunately, you have access to one of those through the ControllerContext property on your Controller class.

This example of a HandleUnknownAction method returns a View called ErrorHandler to the user:

Protected Overrides Sub HandleUnknownAction(actionName As String)
  Dim vw As ViewResult
  vw = Me.View("ErrorHandler")
  vw.ExecuteResult(Me.ControllerContext)
End Sub

Handling a Bad Controller Name
The final and most complicated URL-related error to handle is when the URL provided matches one of your routes, but the controller name provided isn't correct. To handle that scenario, you need to insert a custom controller factory into your ASP.NET MVC processing pipeline (the controller factory is the class responsible for instantiating the controller specified by your route). While that sounds like a big, complicated deal, it's actually pretty easy to do (though following my earlier advice and eliminating controller names from your URLs is even easier)

Your first step is to create a class that inherits from DefaultControllerFactory:

Public Class PetersControllerFactory
  Inherits DefaultControllerFactory

End Class

Your next step is, within the class, to override the CreateController method (this method is called by ASP.NET MVC when it's time to instantiate a controller):

Public Overrides Function CreateController(
  requestContext As RequestContext, controllerName As String) As IController

End Function

Within that method, you need to check to see if the requested controller exists (the controller's name is passed to the method by ASP.NET MVC in the controllerName parameter). The easiest way to do that is to let the base controller class's CreateController method attempt to instantiate the controller and catch the resulting error if the controller doesn't exist:

Dim cont As IController
Try
  cont = MyBase.CreateController(requestContext, controllerName)
Catch ex As HttpException

End Try

Return cont

Normally, I'd suggest avoiding testing for errors by letting your code throw an exception (exceptions are hard on the Microsoft .NET Framework -- everything stops for tea while an exception is being processed). However, I'm willing to assume that a bad controller name is an infrequent-enough occurrence. If that assumption is true, then the impact on your application of raising this exception will be minimal. In my Try…Catch block, I only catch the HttpException so that if something other than "I can't find your controller" goes wrong, I'll still get the ASP.NET MVC error message.

Finally, in the Catch block, you need to instantiate the "no controller" controller that will send the helpful View. You can use the base CreateController method to handle that, too. This example instantiates a controller called Error to handle the "no controller" error:

cont = MyBase.CreateController(requestContext, "Error")

Of course, because the user didn't get the controller name right, you have no idea what action method name the user may (or may not) have provided. Rather than attempt to take control of the action method name, have just one method in your "no controller" controller class: the HandleUnknownAction method I discussed earlier.

Your final step for handling bad controller names is to tell ASP.NET MVC to use your custom controller factory. To make that happen, you just need to insert a line into the Application_Start method of your Global.asax file, passing the type of your custom controller factory (my custom factory was called PetersControllerFactory):

ControllerBuilder.Current.SetControllerFactory(
  GetType(PetersControllerFactory))

In C#, you'll need to use typeof rather than GetType in this statement.

There are, of course, many other things that can go wrong with your ASP.NET MVC application over and above the user getting the URL wrong. However, with this code, you can provide both you and your users with some helpful information when things do go wrong with their URLs.

About the Author

Peter Vogel is a system architect and principal in PH&V Information Services. PH&V provides full-stack consulting from UX design through object modeling to database design. Peter tweets about his VSM columns with the hashtag #vogelarticles. His blog posts on user experience design can be found at http://blog.learningtree.com/tag/ui/.

comments powered by Disqus

Featured

Subscribe on YouTube