arrow Created with Sketch. Insights Blog

Jun 04 / 2018

Workstate Codes: Sitecore – Post-Redirect-Get (PRG)

Christian Duvall (@christianduvall)

Vice President, Enterprise Services


When building forms for the web, it's often wise to follow a Post-Redirect-Get (PRG) pattern, in order to prevent duplicate submissions and provide an overall better user experience. But when it comes to developing these capabilities inside of a Web CMS, it's rarely a straight-forward task, given the modular (e.g., renderings, widgets) approach that a CMS provides.

In this blog, we'll tackle a quick and easy way to perform a PRG, without losing valuable state information.


Features of the Code

  1. Adding a custom attribute that translates POST requests into GET responses.
  2. Retaining ModelState and Model between POST and GET.
  3. Create an automatic redirect to the matching GET, while providing a developer override.



Creating the Attribute

The first thing we'll do is create the attribute that will decorate our POST and GET actions. We are going to add a parameter to allow the developer to decide if they want the form to autoredirect or if they want to handle it themselves.

public class PostRedirectGetAttribute : ActionFilterAttribute
{
    private readonly bool _autoRedirect;
    public PostRedirectGetAttribute(bool autoRedirect = true)
    {
        _autoRedirect = autoRedirect;
    }
}


Handling the POST Side

Next we're going to wire up some handlers for the POST. Before the action executes, we will grab the model (which we assume is the first parameter in the form post) and store it away in the session (temporarily). We use Session, rather than something like Sitecore.Context.Items, because it will survive the redirect. We don't use something like cache, because we don't want to manage unique keys or anything with too long of a lifespan.

Once the action is completed, we check the model state. If it's not valid, we'll store it so that the GET will have access to these values.

If autoredirect is in play, we redirect the the original request URL, which will provide us with our GET.

public override void OnActionExecuting(ActionExecutingContext filterContext)
{
    // These can be any static key; it's only used once per call.
    var modelStateKey = Constants.PageState.ModelState;
    var modelKey = Constants.PageState.Model;
    switch (filterContext.HttpContext.Request.HttpMethod)
    {
        case "POST":
            filterContext.HttpContext.Session[modelKey] = filterContext.ActionParameters.Values.FirstOrDefault();
            break;
    }
    base.OnActionExecuting(filterContext);
}
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
    // These can be any static key; it's only used once per call.  
    var modelStateKey = Constants.PageState.ModelState; 
    var modelKey = Constants.PageState.Model;
    // If _autoRedirect is set or if the result of our POST is a Redirect, manage our session data.
    if (_autoRedirect || (_autoRedirect == false && filterContext.Result is RedirectResult || filterContext.Result is RedirectToRouteResult))
    {
        switch (filterContext.HttpContext.Request.HttpMethod)
        {
            case "POST":
                if (!filterContext.Controller.ViewData.ModelState.IsValid)
                    filterContext.HttpContext.Session[modelStateKey] = filterContext.Controller.ViewData.ModelState;
                if (_autoRedirect)
                {
                    filterContext.Result = new RedirectResult(filterContext.HttpContext.Request.RawUrl);
                }
                break;
        }
    }
    base.OnActionExecuted(filterContext);
}


Handling the GET Side

On the GET side of the process, we'll retrieve the ModelState from our session, merge it with the ModelState of the current request, then remove the item from our session cache. Once the GET is complete, we can clear the model out of the session as well.

public override void OnActionExecuting(ActionExecutingContext filterContext)
{
    var modelStateKey = Constants.PageState.ModelState;
    var modelKey = Constants.PageState.Model;
    switch (filterContext.HttpContext.Request.HttpMethod)
    {
        case "GET":
            var modelState = filterContext.HttpContext.Session[modelStateKey] as ModelStateDictionary ?? new ModelStateDictionary();
            filterContext.Controller.ViewData.ModelState.Merge(modelState);
            filterContext.HttpContext.Session.Remove(modelStateKey);
            break;
    }
    base.OnActionExecuting(filterContext);
}
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
    // These can be any static key; it's only used once per call.  
    var modelStateKey = Constants.PageState.ModelState; 
    var modelKey = Constants.PageState.Model;
    // If _autoRedirect is set or if the result of our POST is a Redirect, manage our session data.
    if (_autoRedirect || (_autoRedirect == false && filterContext.Result is RedirectResult || filterContext.Result is RedirectToRouteResult))
    {
        switch (filterContext.HttpContext.Request.HttpMethod)
        {
            case "GET":
                // Remove the model; completed the cycle.
                filterContext.HttpContext.Session.Remove(modelKey);
                break;
        }
    }
    base.OnActionExecuted(filterContext);
}


Putting It Together

Here's that attribute, all together.

/// <summary>
/// For POSTS:
/// - Grab the model and model state
/// For GETS
/// - Merge the model state
/// - Clear the session
/// </summary>
/// <remarks>
/// I was going to create unique keys for each method, but if you're running into two controls that are handling a
/// post-redirect-get at once, you done messed up.
/// </remarks>
public class PostRedirectGetAttribute : ActionFilterAttribute
{
    private readonly bool _autoRedirect;
    public PostRedirectGetAttribute(bool autoRedirect = true)
    {
        _autoRedirect = autoRedirect;
    }
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        // These can be any static key; it's only used once per call.  
        var modelStateKey = Constants.PageState.ModelState;
        var modelKey = Constants.PageState.Model;
        switch (filterContext.HttpContext.Request.HttpMethod)
        {
            case "POST":
                filterContext.HttpContext.Session[modelKey] = filterContext.ActionParameters.Values.FirstOrDefault();
                break;
            case "GET":
                var modelState = filterContext.HttpContext.Session[modelStateKey] as ModelStateDictionary ?? new ModelStateDictionary();
                filterContext.Controller.ViewData.ModelState.Merge(modelState);
                filterContext.HttpContext.Session.Remove(modelStateKey);
                break;
        }
        base.OnActionExecuting(filterContext);
    }
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        // These can be any static key; it's only used once per call.  
        var modelStateKey = Constants.PageState.ModelState; 
        var modelKey = Constants.PageState.Model;
        // If _autoRedirect is set or if the result of our POST is a Redirect, manage our session data.
        if (_autoRedirect || (_autoRedirect == false && filterContext.Result is RedirectResult || filterContext.Result is RedirectToRouteResult))
        {
            switch (filterContext.HttpContext.Request.HttpMethod)
            {
                case "POST":
                    if (!filterContext.Controller.ViewData.ModelState.IsValid)
                        filterContext.HttpContext.Session[modelStateKey] = filterContext.Controller.ViewData.ModelState;
                    if (_autoRedirect)
                    {
                        filterContext.Result = new RedirectResult(filterContext.HttpContext.Request.RawUrl);
                    }
                    break;
                case "GET":
                    // Remove the model; completed the cycle.
                    filterContext.HttpContext.Session.Remove(modelKey);
                    break;
            }
        }
        base.OnActionExecuted(filterContext);
    }
}


Using Our Attribute

For this to work as prescribed, your actions must be comparably named. We're not managing custom routing or names here, so have two separate handlers for POST and GET.

I generally recommend using Automapper, which is a fantastic tool for mapping objects.

[HttpPost, PostRedirectGet(autoRedirect:true)]
public void MyAction(OurSubmitModel submitModel)
{
    PerformPostAction(submitModel);
}
[HttpGet, PostRedirectGet]
public ActionResult MyAction()
{
    // Build the base view model from your datasource.  
    var viewModel = GetViewModel(RenderingContext.Current.Rendering.Item);
    // If the model state is invalid (from the POST), we can construct the form again from submitted data.
    if (!ModelState.IsValid)
    {
        // Get the model out of the session, then merge it into our viewmodel.
        // We're using Automapper here, but you can do this manually.
        if (Session[Constants.PageState.Model] is OurSubmitModel submitModel)
            viewModel = _mapper.Map(submitModel, viewModel);
        // Handle the way you wish to surface an error to your viewmodel.
        viewModel.Error = "...";
    }        
    return View("MyAction", viewModel);
}


In Conclusion

Now you have all you need to do your own Post-Redirect-Gets in Sitecore!

Like to learn more about customizing your Sitecore implementation? We'd love to talk.