ASP.NET Core ModelState Validation

It’s not too difficult to perform model state validation for a JSON API because you only need to return validation errors, especially when using the ApiController attribute. It’s still a little difficult for those who are still using Razor and server-side rendering, though.

It’s difficult because after posting a form, you have to return the right View. Writing this kind of code in each Action is the simplest way to accomplish the task.

if (ModelState.IsValid)
{
    return RenderTheGetForm();
}

It accomplishes the task, but there is typically a lot of extra, tiresome code.

Instead, by moving this code into an Action Filter attribute that can be used similarly to the ApiController attribute, we can keep the Controllers lean. So I made the decision to write one and share it.

This is how it goes. You drop the AutoValidateModel attribute on the action that uses a View Model and validation after installing the AutomaticModelStateValidation package.

[Route("/[controller]")]
public class SubmissionController : Controller
{
    [HttpGet]
    public ActionResult NewSubmission()
    {
       // Load data for the blank form
       return View(new NewSubmissionViewModel(...));
    }

    [HttpPost()]
    [AutoValidateModel(nameof(NewSubmission))]
    public RedirectToActionResult SaveSubmission(SaveSubmissionViewModel model)
    {
        // Save submission to database
        return RedirectToAction(nameof(ViewSubmission), new { Id = 1 });
    }

    [HttpGet]
    public ActionResult ViewSubmission(int id)
    {
        // Load submission from database
        return View(new ViewSubmissionViewModel(...));
    }
}

The ModelState.IsValid check is carried out by the AutoValidateModel attribute on the SaveSubmission Action. The specified fallback Action is invoked and the ModelState from the prior Action is merged in if the model is invalid. Otherwise, everything proceeds as usual.

This means that with just one line of code, you can render the invalid Form along with validation messages and the appropriate HTTP status code!

Deep Diving

One of the features of this attribute is the ability to programmatically call the fallback action without making a second trip to the client. By temporarily storing the ModelState and rerouting to the prior action, I’ve previously accomplished similar functionality. But this time, I completely skipped that step by utilizing AspNetCore's advancements.

The ActionFilterAttribute class is implemented primarily by the AutoValidateModelAttribute. The next step is to choose which check controller action to execute after first determining whether the ModelState is invalid.

var controllerName = SansController(controller ?? context.Controller.GetType().Name);

The controller’s Type name will be used as a fallback if it isn’t explicitly specified. I also remove the suffix “Controller” in this case.

The following step involves getting the IActionDescriptorCollectionProvider and locating the appropriate ActionDescriptor.

var controllerActionDescriptor =
    actionDescriptorCollectionProvider
    .ActionDescriptors.Items
    .OfType<ControllerActionDescriptor>()
    .FirstOrDefault(x => x.ControllerName == controllerName && x.ActionName == action);

The next thing I’ll need is an ActionContext so I can call the ActionDescriptor. Additionally, I transmit the previous ModelState at this point to ensure that the errors in the new Action‘s validation are preserved.

var actionContext = new ActionContext(context.HttpContext, context.RouteData, controllerActionDescriptor, context.ModelState);

The last major piece is getting an IActionInvokerFactory to create a ControllerActionInvoker and then invoking it.

var actionInvokerFactory = GetService<IActionInvokerFactory>();
var invoker = actionInvokerFactory.CreateInvoker(actionContext);
await invoker.InvokeAsync();
There was only one more issue to be resolved after that. When returning a ViewResult, AspNet Mvc will default to using the action name from RouteData if no View is explicitly specified. If I don’t update the RouteData to correspond, the incorrect view will be used because I’m invoking a second Action within a single Request. So, before invoking the ControllerActionInvoker, I also set the action name to the name of the fallback action.
if (context.RouteData.Values.ContainsKey(ActionNameKey)) {
    context.RouteData.Values[ActionNameKey] = controllerActionDescriptor.ActionName;
}

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *