Random Code

March 3, 2008

Adding Action Filters to MonoRail Controllers

Filed under: MonoRail — Neal @ 12:06 pm

Update: See this post for making the cancel buttons work with client side validation

MonoRail has a rather nifty technique for adding cross-cutting concerns to controllers, it allows you to specify one or more filters to be run before or after the action, after rendering or always. Filters are commonly used for tasks like authentication and localisation, however by default they run for all actions in the controller and you must specify which actions to exclude using the [SkipFilter] attribute. In a majority of cases this works well as the filter is designed to handle cross-cutting concerns (which by definition affect most if not all actions), however there are times when you want to apply the filter to a minority of the actions in a controller, or add action specific metadata that may vary the way the filter executes depending on the action. Recently I came across just such a scenario…

In the surveying application that I am building, I needed to add a cancel button to a number of pages and be able to execute some cleanup logic or simply redirect the user to a different page. The buttons were embedded in the html as:

 <button name="cancel" type="submit">Cancel</button>

This meant that if the user pressed the cancel button, “cancel” will appear in the request parameters. Handling the cancel in the action specified in the form’s target is reasonably simple but I ended up with a lot of duplicated code at the top of my actions that wasn’t directly related to the goal of the action:

 bool isRequestCancelled = IsRequestCancelled( Request.Form );

if( isRequestCancelled )
{
  // do some cleanup here if required and redirect to cancel page
  return;
}

// do the actual work for the action

So I decided to split out the handling of the cancel button and put it into a filter. Sounds simple and after about half an hour of testing and coding I had something that resembled what I wanted, BUT, when I applied the filter directly to my actions it didn’t work. Back to the documentation and a bit of digging in the source code and I figure out that filter attributes are not parsed when the action metadata for a controller is created, meaning filters can be applied at the controller level only. While this is great for a majority of the cases where you use filters, I needed to be able to control the application of the filter at a much finer level of detail for each method (and the cancel button was an exception rather than a rule so adding [SkipFilter] everywhere would have led to some ugly controllers).Taking a step back, I realised the problem was essentially in 2 parts,

  1. processing the canceled request, and
  2. indicating how the canceled request was to be handled for each action.

This led me to create a filter to enable the cancellation of an action and a set of attributes that provided the additional metadata to the filter indicating how the cancellation should be handled.

The code for the filter then became relatively simple, check for cancel in the request, check for the attribute on the current action, redirect according to the attribute (note the attribute below the filter shows my preferred method for attaching filters to controllers):

public class ExecuteRedirectOnCancelFilter : IFilter
    {
        public bool Perform( ExecuteWhen exec, IEngineContext context, IController controller, IControllerContext controllerContext )
        {
            bool isRequestCancelled = FormContainsKey( context.Request.Form, "cancel" );
            if(!isRequestCancelled)
            {
                return true;
            }

            OnCancelRedirectToAttribute onCancel = GetCancellationDetails(controller);
            if(onCancel == null)
            {
                return true;
            }

            onCancel.Redirect( context.Response, controllerContext.Name);
            return false;
        }

        private bool FormContainsKey(NameValueCollection form, string key)
        {
            foreach (string formKey in form.AllKeys)
            {
                if (formKey.Equals(key))
                {
                    return true;
                }
            }

            return false;
        }

        private OnCancelRedirectToAttribute GetCancellationDetails(IController controller)
        {
            Controller mrController = controller as Controller;
            if (mrController == null)
            {
                throw new InvalidOperationException("The type of controller that the ExecuteRedirectOnCancelFilter has been applied to does not derive from Castle.Monorail.Framework.Controller.  The filter is unable to access the information necessary to execute.");
            }

            string currentActionName = mrController.Action;
            MethodInfo currentAction = mrController.MetaDescriptor.Actions[currentActionName] as MethodInfo;
            if (currentAction == null)
            {
                throw new InvalidOperationException("The method info for the currently executing action could not be retrieved.");
            }

            object[] redirectInfoAttributes =
                currentAction.GetCustomAttributes(typeof(OnCancelRedirectToAttribute), true);

            if (redirectInfoAttributes.Length == 0)
            {
                return null;
            }

            OnCancelRedirectToAttribute redirectInfoAttribute = (OnCancelRedirectToAttribute)redirectInfoAttributes[0];
            return redirectInfoAttribute;
        }
    }

    public class EnableRedirectOnCancelAttribute : FilterAttribute
    {
        public EnableRedirectOnCancelAttribute() : base( ExecuteWhen.BeforeAction, typeof(ExecuteRedirectOnCancelFilter) )
        {
        }
    }

The attribute simply allows us to specify a controller and action:

[AttributeUsage(AttributeTargets.Method, AllowMultiple=false)]
    public class OnCancelRedirectToAttribute : Attribute
    {
        private string _controller;
        private readonly string _action;

        public string Controller
        {
            get
            {
                return _controller;
            }
        }

        public string Action
        {
            get
            {
                return _action;
            }
        }

        public bool IsControllerSpecified
        {
            get
            {
                return !String.IsNullOrEmpty( _controller );
            }
        }

        public bool IsActionSpecified
        {
            get
            {
                return !String.IsNullOrEmpty( _action );
            }
        }

        public OnCancelRedirectToAttribute( string redirectToController, string redirectToAction )
        {
            _controller = redirectToController;
            _action = redirectToAction;
        }

        public virtual void Redirect(IRedirectSupport redirector, string currentController)
        {
            if(!IsControllerSpecified)
            {
                _controller = currentController;
            }

            if(!IsControllerSpecified || !IsActionSpecified)
            {
                throw new InvalidOperationException( "Unable to redirect the user after request was cancelled as the controller or action was not specified");
            }

            redirector.Redirect( _controller, _action );
        }
    }

    public class OnCancelRedirectToActionAttribute : OnCancelRedirectToAttribute
    {
        public OnCancelRedirectToActionAttribute( string redirectToAction ) : base( null, redirectToAction )
        {
        }
    }

    public class OnCancelRedirectToIndexAttribute : OnCancelRedirectToAttribute
    {
        public OnCancelRedirectToIndexAttribute( ) : base( null, "Index" )
        {
        }
    }

I have not performance tested this yet, however caching the attributes against the actions or parsing all of the controllers to generate a list of actions with the cancel attributes should be relatively easy to add at a later date if it is required.The technique could also be easily extended to a simple EnableActionFiltersFilter that simply determined if the current action had an ActionFilter attribute attached and delegated execution of the filter directly to the attribute, as demonstrated by the following aircode:

 public class EnableActionFiltersFilter : IFilter
    {
        public bool Perform( ExecuteWhen exec, IEngineContext context, IController controller, IControllerContext controllerContext )
        {
            ActionFilterAttribute actionFilter = GetActionFilter(controller);

            if(actionFilter == null)
            {
                return true;
            }

            return actionFilter.Perform( ExecuteWhen exec, IEngineContext context, IController controller, IControllerContext controllerContext );
        }

        private ActionFilterAttribute GetActionFilter(IController controller)
        {
            Controller mrController = controller as Controller;

            if (mrController == null)
            {
                throw new InvalidOperationException("The type of controller that the ActionFilter has been applied to does not derive from Castle.Monorail.Framework.Controller.  The filter is unable to access the information necessary to execute.");
            }

            string currentActionName = mrController.Action;
            MethodInfo currentAction = mrController.MetaDescriptor.Actions[currentActionName] as MethodInfo;
            if (currentAction == null)
            {
                throw new InvalidOperationException("The method info for the currently executing action could not be retrieved.");
            }

            object[] actionFilterAttributes = currentAction.GetCustomAttributes(typeof(ActionFilterAttribute), true);
            if (actionFilterAttributes .Length == 0)
            {
                return null;
            }

            ActionFilterAttribute actionFilter = (ActionFilterAttribute )actionFilterAttributes[0];
            return actionFilter;
        }
    }

    public class EnableActionFiltersAttribute : FilterAttribute
    {
        public EnableActionFiltersAttribute () : base( ExecuteWhen.Always, typeof(EnableActionFiltersFilter) )
        {
        }
    }

And the ActionFilterAttribute to perform the filter action:

[AttributeUsage(AttributeTargets.Method, AllowMultiple=false)]
    public abstract class ActionFilterAttribute : Attribute
    {
        public abstract bool Perform( ExecuteWhen exec, IEngineContext context, IController controller, IControllerContext controllerContext );
    }

Now you can simply extend the ActionFilterAttribute, override the Perform method and you have action level filters in your controller (you would also have to enable the action level filters on your controller using the EnableActionFilters attribute).

3 Comments »

  1. [...] Adding Action Filters to MonoRail Controllers Neal Blomfield made interesting use of MonoRail filters.  Check it out. [...]

    Pingback by Adding Action Filters to MonoRail Controllers - Patrick Steele's .NET Blog — April 16, 2008 @ 10:51 am

  2. [...] Neal Blomfield made interesting use of MonoRail filters.  Check it out. [...]

    Pingback by Adding Action Filters to MonoRail Controllers - Patrick Steele — April 16, 2008 @ 10:51 am

  3. [...] and multiple submit buttons Filed under: MonoRail, Prototype — Neal @ 2:50 pm Previously I have discussed how I added action level filters to allow me to drop a cancel button on the page and worry about how the cancel was handled in the [...]

    Pingback by MonoRail, Prototype validation and multiple submit buttons « Random Code — May 2, 2008 @ 2:51 pm

RSS feed for comments on this post. TrackBack URI

Leave a comment

Blog at WordPress.com.