MVC: Action Filter for Handling Errors

April 2, 2008 12:29 AM

A few months ago I posted an article and some code that contained filters for forms authentication and error handling for the Preview 1 (CTP) release of the MVC framework. Unfortunately the Preview 2 release that was made available a few weeks ago changed enough that the code I posted no longer works.

What the Preview 2 release did provide, however, was a new built-in filter framework. Rob Conery has already gone through the trouble of creating authentication filters that cover most of the functionality I had before, but I have yet to see an implementation of a filter for error handling that I like. I've gone ahead and started from scratch, throwing away my old filters, and created a new filter that I think covers most of the same scenarios as my old ErrorHandler filters while being much simpler to implement and use. Hopefully you'll find it useful.

First let's take a look at a simple use case scenario:

   1: public void Product( int? id )
   2: {
   3:     if( id == null )
   4:         throw new ArgumentNullException( "No Product ID" );
   5:     RenderView( "DisplayProduct", GetProduct(id.Value) );
   6: }

In the code above, we have a simple action that displays a product based upon the ID specified. What do we do when no ID is specified though? The "correct" thing to do seems to be to throw an exception, as we've done, but now the user will see either (a) an ugly 500 error screen [worst case] or (b) be redirected to the generic error page [best case]. Sometimes we'd like a bit more control than that though...

Let's go ahead and add our error handling filter to this action and tell it that whenever ArgumentNullException is thrown, redirect to the "Products" page, where the user can select a product with a valid ID.

   1: [RedirectToUrlOnError(Type=typeof(ArgumentNullException),Url="/Products")]
   2: public void Product( int? id )
   3: {
   4:     if( id == null )
   5:         throw new ArgumentNullException( "No Product ID" );
   6:     RenderView( "DisplayProduct", GetProduct(id.Value) );
   7: }

So we've added a [RedirectToUrlOnError] attribute and supplied it with a Type property - detailing the exception to catch - and a Url property - specifying the Url to navigate to upon a matched exception. You'll notice we are making a call to the GetProduct(int) method to retrieve the product's model so that we can pass it into the view's ViewData. What if this method were to fail? What if we weren't entirely certain what exception it would throw, or maybe we didn't care, we just want to handle any exception except for ArgumentNullException (which is already being handled). In this case we'll add another filter, but this time we will not specify the Type of exception that it should catch and just tell it that if anything isn't caught by another error handler redirect to the homepage.

   1: [RedirectToUrlOnError(Type=typeof(ArgumentNullException),Url="/Products")]
   2: [RedirectToUrlOnError(Url="/")]
   3: public void Product( int? id )
   4: {
   5:     if( id == null )
   6:         throw new ArgumentNullException( "No Product ID" );
   7:     RenderView( "DisplayProduct", GetProduct(id.Value) );
   8: }

You can have as many error handler filters attached to an action as you need, but only one may have no Type specified.

Now let's take a look at the code for the filter itself:

   1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Web.Mvc;
   5:  
   6: namespace SquaredRoot.Mvc.Filters.ErrorHandling
   7: {
   8:     public class RedirectToUrlOnErrorAttribute : RedirectOnErrorAttribute
   9:     {
  10:  
  11:         public string Url{ get; set; }
  12:  
  13:         protected override bool Validate( FilterExecutedContext filterContext )
  14:         {
  15:  
  16:             //### the url property is always needed
  17:             if( string.IsNullOrEmpty( Url ) || Url.Trim() == string.Empty )
  18:                 throw new ArgumentNullException( "RedirectToUrlOnErrorAttribute's Url property must have a value." );
  19:  
  20:             //### continue execution
  21:             return true;
  22:  
  23:         }
  24:  
  25:         protected override void Redirect( FilterExecutedContext filterContext )
  26:         {
  27:             filterContext.ExceptionHandled = true;
  28:             filterContext.HttpContext.Response.Redirect( Url, true );
  29:         }
  30:  
  31:     }
  32: }

So the [RedirectToUrlOnError] attribute inherits from the [RedirectOnError] attribute, which is where most of the hard work is done. We'll take a look at that base class in a bit, but first let's look at the other attribute you can use to trap and respond to errors - the [RedirectToActionOnError] attribute. We'll continue with the Product(id) sample from above, but this time redirect to an action rather than a hardcoded Url:

   1: [RedirectToActionOnError(
   2:     Type=typeof(ArgumentNullException),
   3:     Controller=typeof(ProductController),
   4:     Action="Index" )]
   5: [RedirectToUrlOnError(Url="/")]
   6: public void Product( int? id )
   7: {
   8:     if( id == null )
   9:         throw new ArgumentNullException( "No Product ID" );
  10:     RenderView( "DisplayProduct", GetProduct(id.Value) );
  11: }

You can see that this time instead of providing the Url property we are using providing the type of the controller that contains our target action, and the name of the action as a string. (Unfortunately lambda expressions are not allowed as parameters to an attribute, so I was limited in my options here. If you have a better idea, please let me know!) Also note that the catch-all is still there as a [RedirectToUrlOnError] attribute. You may use the [RedirectToActionOnError] attribute as a catch-all and you can mix and match the two attribute types, but still only one catch-all attribute total is allowed per action (in other words, you cannot have one of each).

Now let's see the code for this filter:

   1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Web.Mvc;
   5: using System.Web.Routing;
   6:  
   7: namespace SquaredRoot.Mvc.Filters.ErrorHandling
   8: {
   9:     public class RedirectToActionOnErrorAttribute : RedirectOnErrorAttribute
  10:     {
  11:  
  12:         public Type Controller{ get; set; }
  13:         public string Action{ get; set; }
  14:  
  15:         protected override bool Validate( FilterExecutedContext filterContext )
  16:         {
  17:  
  18:             //### the url property is always needed
  19:             if(
  20:                 Controller == null ||
  21:                 ( string.IsNullOrEmpty( Action ) || Action.Trim() == string.Empty )
  22:             )
  23:                 throw new ArgumentNullException( "RedirectToUrlOnActionAttribute's Controller and Action properties must have values." );
  24:  
  25:             //### make sure the Contoller property is a Controller
  26:             if( !typeof(System.Web.Mvc.Controller).IsAssignableFrom( Controller ) )
  27:                 throw new ArgumentException( "RedirectToUrlOnActionAttribute's Controller property's value must derive from System.Web.Mvc.Controller." );
  28:  
  29:             //### continue processing
  30:             return true;
  31:  
  32:         }
  33:  
  34:         protected override void Redirect( FilterExecutedContext filterContext )
  35:         {
  36:  
  37:             //### turn "Foo.Foo.Foo.BarController" into "Bar"
  38:             string controllerName = Controller.ToString();
  39:             controllerName = controllerName.Substring( controllerName.LastIndexOf(".") + 1 );
  40:             controllerName = controllerName.Substring( 0, controllerName.LastIndexOf("Controller") );
  41:  
  42:             //### turn route data into url
  43:             RouteValueDictionary rvd = new RouteValueDictionary( new{
  44:                 controller = controllerName,
  45:                 action = Action
  46:             } );
  47:             ControllerContext ctx = new ControllerContext(
  48:                 filterContext.HttpContext,
  49:                 filterContext.RouteData,
  50:                 filterContext.Controller
  51:             );
  52:             VirtualPathData vpd = RouteTable.Routes.GetVirtualPath( ctx, rvd );
  53:             string url = vpd.VirtualPath;
  54:  
  55:             //### redirect
  56:             filterContext.ExceptionHandled = true;
  57:             filterContext.HttpContext.Response.Redirect( url, true );
  58:  
  59:         }
  60:  
  61:     }
  62: }

Other than some complexity with determining the Url, everything is very similar to the other filter. Again it appears the base class is doing the heavy lifting. Let's finally take a look at that base class:

   1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Web.Mvc;
   5:  
   6: namespace SquaredRoot.Mvc.Filters.ErrorHandling
   7: {
   8:     public abstract class RedirectOnErrorAttribute : ActionFilterAttribute
   9:     {
  10:  
  11:         public Type Type { get; set; }
  12:  
  13:         public override void OnActionExecuted( FilterExecutedContext filterContext )
  14:         {
  15:  
  16:             //### check for errors
  17:             if( !Validate(filterContext) )
  18:                 return;
  19:  
  20:             //### make sure the Type property is an Exception
  21:             if( Type != null && !typeof(System.Exception).IsAssignableFrom( Type ) )
  22:                 throw new ArgumentException( "RedirectOnErrorAttribute's Type property's value must derive from System.Exception." );
  23:  
  24:             //### if no exception occurred, stop processing this filter
  25:             if( filterContext.Exception == null )
  26:                 return;
  27:  
  28:             //### get inner exception unless it is null (this should never happen?)
  29:             Exception ex = filterContext.Exception.InnerException ?? filterContext.Exception;
  30:  
  31:             //### if exception was thrown because of Response.Redirect, ignore it
  32:             if( ex.GetType() == typeof(System.Threading.ThreadAbortException) )
  33:                 return;
  34:             else if( Type == typeof(System.Threading.ThreadAbortException) )
  35:                 throw new ArgumentException( "Cannot catch exceptions of type 'ThreadAbortException'." );
  36:  
  37:             //### if the specified Type matches the thrown exception, process it
  38:             if( IsExactMatch(ex) )
  39:                 Redirect( filterContext );
  40:             //### if this attribute has no specified Type, investigate further (this attribute is a catch-all error handler)
  41:             else if( Type == null )
  42:             {
  43:  
  44:                 //### loop through all other RedirectToUrlOnErrorAttribute on this method
  45:                 foreach( RedirectOnErrorAttribute att in GetAllAttributes( filterContext ) )
  46:                     //### ignore self
  47:                     if( att.GetHashCode() == this.GetHashCode() )
  48:                         continue;
  49:                     //### if another catch-all attribute is found, throw an exception
  50:                     else if( att.Type == null )
  51:                         throw new ArgumentException( "Only one RedirectOnErrorAttribute per Action may be specified without its Type property provided." );
  52:                     //### if an exact match is found, stop processing the catch-all. that attribute has priority
  53:                     else if( att.IsExactMatch(ex) )
  54:                         return;
  55:  
  56:                 //### no exact matches were found. if the specified Type for the catch-all fits, process here
  57:                 Redirect(filterContext);
  58:  
  59:             }
  60:             else
  61:                 //### specified Type was not null, but did not match the thrown exception. don't process
  62:                 return;
  63:  
  64:         }
  65:  
  66:         public bool IsExactMatch( Exception exception )
  67:         {
  68:             if( Type != null && exception.GetType() == Type )
  69:                 return true;
  70:             else
  71:                 return false;
  72:         }
  73:  
  74:         private List<RedirectOnErrorAttribute> GetAllAttributes( FilterExecutedContext filterContext )
  75:         {
  76:             return filterContext.ActionMethod
  77:                 .GetCustomAttributes( typeof( RedirectOnErrorAttribute ), false )
  78:                 .Select( a => a as RedirectOnErrorAttribute )
  79:                 .ToList();
  80:         }
  81:  
  82:         protected abstract bool Validate( FilterExecutedContext filterContext );
  83:         protected abstract void Redirect( FilterExecutedContext filterContext );
  84:  
  85:     }
  86: }

That's all there is. Feel free to take it, use it, change it, whatever. I tried my best to document it thoroughly with comments, but if you have any questions just drop a comment below and I'll try to respond quickly. I do ask that if you make any improvements, please leave a comment here letting everyone know what you've changed so that we can all benefit.

Here are the filters in a downloadable format:

RedirectOnErrorAttributes.zip (2.63 kb)

Comments

Add comment


(Will show your Gravatar icon)  

  Country flag

biuquote
  • Comment
  • Preview
Loading




Troy Goode

Troy Goode
Microsoft Certified Professional Developer
AddThis Feed Button

Disclaimer

The opinions expressed herein are my own personal opinions and do not represent my employer's view in  anyway.

© Copyright 2008

Colophon

Powered by:
BlogEngine.NET 1.4.5
Template:
Designs by Darren
Header Font:
Stamper
Syntax Highlighting:
WLW Code Snippet Plugin
Filter by APML