Guillaume Hanique
Guillaume Hanique

Reputation: 405

ASP.NET MVC: How to create ViewData for an Exception Filter

I'm not sure if you're familiar with the NerdDinner application. It adds a method GetRuleViolations() and a property IsValid to the Dinner object. When the object is saved, it checks whether the object is valid. If it isn't, it throws an exception. In the controller that exception is caught, the ViewData's ModelState is filled with the rule violations and the view is redisplayed. The Html.Validation helpers highlight the errors.

What I'd like to do is create a HandleRuleViolationExceptionAttribute, similar to the HandleExceptionAttribute (which is part of the MVC Framework). The problem is that this attribute has to repopulate the View's Modelstate.

A view can have any object type for its model. The code that throws the RuleViolationException fills sets the RuleViolationException.Object to the View's Model.

I looked up the code for the HandleExceptionAttribute in the MVC source code and modified it:

    <AttributeUsage(AttributeTargets.Class Or AttributeTargets.Method, _
    Inherited:=True, AllowMultiple:=False)> _
    Public Class HandleRuleViolationExceptionAttribute
        Inherits FilterAttribute
        Implements IExceptionFilter

        Private m_View As String
        Private m_MasterPage As String

        Public Property View() As String
            Get
                Return m_View
            End Get
            Set(ByVal value As String)
                m_View = value
            End Set
        End Property

        Public Property MasterPage() As String
            Get
                Return If(m_MasterPage, String.Empty)
            End Get
            Set(ByVal value As String)
                m_MasterPage = value
            End Set
        End Property

        Public Sub OnException(ByVal filterContext As System.Web.Mvc.ExceptionContext) _
                Implements System.Web.Mvc.IExceptionFilter.OnException
            If filterContext Is Nothing Then 
                Throw New ArgumentException("filterContext is null")
            End If

            'Ignore if the error is already handled.
            If filterContext.ExceptionHandled Then Return

            'Handle only ObjectIsInvalidExceptions.
            If Not TypeOf filterContext.Exception Is ObjectIsInvalidException Then
                Return
            End If

            Dim ex As ObjectIsInvalidException = DirectCast(filterContext.Exception, ObjectIsInvalidException)

            'If this is not an HTTP 500 (for example, if somebody throws an HTTP 404 from an action method),
            'ignore it.
            If (New HttpException(Nothing, ex).GetHttpCode()) <> 500 Then Return

            Dim actionName As String = CStr(filterContext.RouteData.Values("action"))
            Dim viewName As String = If(String.IsNullOrEmpty(View), actionName, View)

            Dim viewData = filterContext.Controller.ViewData
            viewData.Model = ex.Object
            For Each item As String In filterContext.HttpContext.Request.Form.Keys
                viewData.Add(item, filterContext.HttpContext.Request.Form.Item(item))
            Next
            For Each ruleViolation In ex.Object.GetRuleViolations()
                viewData.ModelState.AddModelError(ruleViolation.PropertyName, ruleViolation.ErrorMessage)
            Next
            filterContext.Result = New ViewResult() With _
            { _
                    .ViewName = viewName, _
                    .MasterName = MasterPage, _
                    .ViewData = viewData, _
                    .TempData = filterContext.Controller.TempData _
            }
            filterContext.ExceptionHandled = True
            filterContext.HttpContext.Response.Clear()
            filterContext.HttpContext.Response.StatusCode = 500

            'Certain versions of IIS will sometimes use their own error page when
            'they detect a server error. Setting this property indicates that we
            'want it to try to render ASP.NET MVC's error page instead.
            filterContext.HttpContext.Response.TrySkipIisCustomErrors = True
        End Sub
    End Class

To fill the View's Model I iterate over the request's form keys and add the key and its value to the ViewData instance. It now works, however, I don't believe this is the way to do it.

In the Controller's Action method I could update the model with the UpdateModel-method. This also updates the viewStates ModelState. I can include an array of strings with the property names that must be updated, or, when having the model as an Action parameter, I could use the Bind-attribute to in- or exclude some properties (as I do in the create-action above). My method does not adhere to this, possibly resulting in security problems.

Is there a better way of constructing the ViewData object in the OnException method, that works similarly to the UpdateModel-method of the controller? Is there a way to invoke the UpdateModel-method from the ExceptionHandlerAttribute?

Thanks, Guillaume Hanique

Upvotes: 1

Views: 2300

Answers (2)

Guillaume Hanique
Guillaume Hanique

Reputation: 405

Got it!

Dim methodInfo = GetType(Controller).GetMethod("View", _
        Reflection.BindingFlags.NonPublic Or Reflection.BindingFlags.Instance, Nothing, _
        New Type() {GetType(Object)}, Nothing)
Dim controller = DirectCast(filterContext.Controller, Controller)
Dim viewResult As ViewResult = _
        CType(methodInfo.Invoke(controller, New Object() {ex.Object}), ViewResult)

Dim viewData = viewResult.ViewData
For Each ruleViolation In ex.Object.GetRuleViolations()
    viewData.ModelState.AddModelError( _
            ruleViolation.PropertyName, ruleViolation.ErrorMessage)
Next
filterContext.Result = viewResult

In my case I know that filterContext.Controller always derives from Controller when this HandleRuleViolationsAttribute is used. In the Controller the ModelState is set by calling return View(theObject). The View method is protected, though, so in the HandleRuleViolationsAttribute I invoke it using reflection, which gives me a ViewResult instance with the ModelState correctly initialized. I can then add the RuleViolations to the ModelState using the AddModelError-method. I assign that viewResult to filterContext.Result to have it displayed.

Upvotes: 0

Tony Heupel
Tony Heupel

Reputation: 1053

Couple quick points:
1. You actually want to update the Controller's ModelState (which the View has access to as a property) 2. You want to set the result to a View where you pass in the model object even though it's invalid

From what you describe, it seems that you should be calling the UpdateModel method of the controller. You can do this from your OnException method by doing this:

filterContext.Controller.UpdateModel(ex.Object)
...
For Each ruleViolation In ex.Object.GetRuleViolations()
            filterContext.Controller.ModelState.AddModelError(ruleViolation.PropertyName, ruleViolation.ErrorMessage)
Next
...
filterContext.Result = filterContext.Controller.View(ex.Object)

You might consider exposing a property called "ViewName" on the attribute so the user can specify an alternate View to use in the case of an exception:

<HandleRuleViolationException(ViewName:="SomeErrorViewForThisControllerOrAction")>

This is a pretty neat idea. Please come back and update the post, mark answer, or comment as to the outcome. I'm very curious as to how this works out!

Upvotes: 1

Related Questions