Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

POST-REDIRECT-GET using TempData in ASP.NET Core

$
0
0

In this post I will show how you can use Session state and TempData to implement the POST-REDIRECT-GET (PRG) design pattern in your ASP.NET Core application.

Disclaimer - The technique shown here, while working very well in the previous version of ASP.NET, is not as simple in ASP.NET Core. This is due to the fact that the TempData object is a wrapper around Session which is itself a wrapper around the IDistributedCache interface. This interface requires you to serialise your objects to and from a byte array before storage, where previously serialisation was not necessary. Consequently there are some trade-offs required in this implementation, so be sure you understand the implications.

What is PRG?

The POST-REDIRECT-GET (PRG) design pattern states that a POST should be answered with a REDIRECT response, to which the user's browser will follow with a GET request. It is designed to reduce the number of duplicate form submissions caused by users refreshing their browser and navigating back and forth.

No doubt in your general internet travels you will have refreshed a page and seen a popup similar to the following:

Resend warning popup

This occurs when the response returned from a POST is just content, with no REDIRECT. When you click reload, the browser attempts to resend the last request, which in this case was a POST. In some cases this may be the desired behaviour, but in my experience it invariably is not!

Luckily, as suggested, handling this case is simple when the form data submitted in the post is valid and can be handled correctly. Simply return a redirect response from your controller actions to a new page. So for example, consider we have a simple form on our home page which can POST an EditModel. If the form is valid, then we redirect to the Success action, instead of returning a View result directly. That way if the user reloads the screen, they replay the GET request to Success instead of the POST to Index.

public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View(new EditModel());
    }

    [HttpPost]
    public IActionResult Index(EditModel model)
    {
        if (!ModelState.IsValid)
        {
            return View(model);
        }
        return RedirectToAction("Success");
    }

    public IActionResult Success()
    {
        return View();
    }
}

Handling invalid forms

Unfortunately the waters get a little more muddy when the form data you have submitted is not valid. As PRG is primarily intended to prevent double form submissions, it does not necessarily follow that you should REDIRECT a user if the form is invalid. In that case, the request should not be modifying state, and so it is valid to submit the form again.

In MVC, this has generally been the standard way of handling invalid forms. In the example above, we check the ModelState.IsValid property in our POST handler, and if not valid, we simply redisplay the form, using the current ModelState to populate the validation helpers etc. This is a conceptually simple solution, that still allows us to use PRG when the post is successful.

Unfortunately, this approach has some drawbacks. It is still quite possible for users to be hit with the (for some, no-doubt confusing) 'Confirm form resubmission' popup.

Consider the controller above. A user can submit the form, where if invalid we return the validation errors on the page. The user then reloads the page and is shown the 'Confirm form resubmission' popup:

Confirm dialog when submitting invalid form

It is likely the user expected reloading the page to actually reload the page and clear the previously entered values, rather than resubmitting the form. Luckily, we can use PRG to produce that behaviour and to provide a cleaner user experience.

Using TempData to save ModelState

The simple answer may seem to be changing the View(model) statement to be RedirectToAction("Index") - that would satisfy the PRG requirement and prevent form resubmissions. However doing that would cause a 'fresh' GET on the Index page, so we would lose the previously entered input fields and all of the validation errors - not a nice user experience at all!

In order to display the validation messages and input values we need to somehow preserve the ModelStateDictionary exposed as ModelState in the controller. In ASP.NET 4.X, that is relatively easy to do using the TempData structure, which stores data in the Session for the current request and the next one, after which it is deleted.

Matthew Jones has an excellent post on using TempData to store and rehydrate the ModelState when doing PRG in ASP.NET 4.X, which was the inspiration for this post. Unfortunately there are some limitations in ASP.NET Core which make the application of his example slightly less powerful, but hopefully still sufficient in the majority of cases.

Serialising ModelState to TempData

The biggest problem here is that ModelState is not generally serialisable. As discussed in this GitHub issue, ModelState can contain Exceptions which themselves may not be serialisable. This was not an issue in ASP.NET 4.X as TempData would just store the ModelState object itself, rather than having to serialise at all.

To get around this, we have to extract the details we care about from the ModelStateDictionary, serialise those details, and then rebuild the ModelStateDictionary from the serialised representation on the next request.

To do this, we can create a simple serialisable transport class, which contains only the details we need to redisplay the form inputs correctly:

public class ModelStateTransferValue
{
    public string Key { get; set; }
    public string AttemptedValue { get; set; }
    public object RawValue { get; set; }
    public ICollection<string> ErrorMessages { get; set; } = new List<string>();
}

All we store is the Key (the field name) the RawValue and AttemptedValue (the field values) and the ErrorMessages associated with the field. These map directly to the equivalent fields in ModelStateDictionary.

Note that the RawValue type is an object, which again leaves us with the problem that ModelStateTransferValue may not be serialisable. I haven't come across any times where this is the case but it is something to be aware of if you are using some complex objects in your view models.

We then create a helper class to allow us to serialise the ModelSateDictionary to and from TempData. When serialising, we first convert it to a collection of ModelStateTransferValue and then serialise these to a string. On deserialisation, we simply perform the process in reverse:

public static class ModelStateHelpers
{
    public static string SerialiseModelState(ModelStateDictionary modelState)
    {
        var errorList = modelState
            .Select(kvp => new ModelStateTransferValue
            {
                Key = kvp.Key,
                AttemptedValue = kvp.Value.AttemptedValue,
                RawValue = kvp.Value.RawValue,
                ErrorMessages = kvp.Value.Errors.Select(err => err.ErrorMessage).ToList(),
            });

        return JsonConvert.SerializeObject(errorList);
    }

    public static ModelStateDictionary DeserialiseModelState(string serialisedErrorList)
    {
        var errorList = JsonConvert.DeserializeObject<List<ModelStateTransferValue>>(serialisedErrorList);
        var modelState = new ModelStateDictionary();

        foreach (var item in errorList)
        {
            modelState.SetModelValue(item.Key, item.RawValue, item.AttemptedValue);
            foreach (var error in item.ErrorMessages)
            {
                modelState.AddModelError(item.Key, error);
            }
        }
        return modelState;
    }
}

ActionFilters for exporting and importing

With these helpers in place, we can now create the ActionFilters where we will store and rehydrate the model data. These filters are almost identical to the ones proposed by Matthew Jones in his post, just updated to ASP.NET Core constructs, and calling our ModelStateHelpers as required:

public abstract class ModelStateTransfer : ActionFilterAttribute
{
    protected const string Key = nameof(ModelStateTransfer);
}

public class ExportModelStateAttribute : ModelStateTransfer
{
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        //Only export when ModelState is not valid
        if (!filterContext.ModelState.IsValid)
        {
            //Export if we are redirecting
            if (filterContext.Result is RedirectResult 
                || filterContext.Result is RedirectToRouteResult 
                || filterContext.Result is RedirectToActionResult)
            {
                var controller = filterContext.Controller as Controller;
                if (controller != null && filterContext.ModelState != null)
                {
                    var modelState = ModelStateHelpers.SerialiseModelState(filterContext.ModelState);
                    controller.TempData[Key] = modelState;
                }
            }
        }

        base.OnActionExecuted(filterContext);
    }
}

public class ImportModelStateAttribute : ModelStateTransfer
{
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        var controller = filterContext.Controller as Controller;
        var serialisedModelState = controller?.TempData[Key] as string;

        if (serialisedModelState != null)
        {
            //Only Import if we are viewing
            if (filterContext.Result is ViewResult)
            {
                var modelState = ModelStateHelpers.DeserialiseModelState(serialisedModelState);
                filterContext.ModelState.Merge(modelState);
            }
            else
            {
                //Otherwise remove it.
                controller.TempData.Remove(Key);
            }
        }

        base.OnActionExecuted(filterContext);
    }
}

The ExportModelStateAttribute runs after an Action has executed, checks whether the ModelState was invalid and if the returned result was a redirect result. If it was, then it serialises the ModelState and stores it in TempData.

The ImportModelStateAttribute also runs after an Action has executed, checks we have a serialised model state and that we are going to execute a ViewResult. If so, then it deserialises to state to a ModelStateDictionary and merges it into the existing ModelState.

We can simply apply these attributes to our HomeController to give PRG on invalid forms, if we also update the !ModelState.IsValid case to redirect to Index:

public class HomeController : Controller
{
    [ImportModelState]
    public IActionResult Index()
    {
        return View(new EditModel());
    }

    [HttpPost]
    [ExportModelState]
    public IActionResult Index(EditModel model)
    {
        if (!ModelState.IsValid)
        {
            return RedirectToAction("Index");
        }
        return RedirectToAction("Success");
    }
}

The result

We're all set to give this a try now. Previously, if we submitted a form with errors, then reloading the page would give us the 'Confirm form resubmission' popup. This was because the POST was being resent to the server, as we can see by viewing the Network tab in Chrome:

Multiple POSTs sent

See those POSTS returning a 200? That's what we're trying to avoid. With our new approach, errors in the form cause a redirect to the Index page, followed by a GET request by the browser. The form fields and validation errors are all still visible, even though this is a normal GET request.

GET request

Out POST now returns a 302, which is followed by a GET. Now if the user refreshes the page, the page will actually refresh, clearing all the input values and validation errors and giving you a nice clean form, with no confusing popups!

Reloading the page after a PRG

Summary

This post shows how you can implement PRG for all your POSTs in ASP.NET Core. Whether you actually want to have this behaviour is another question which is really up to you. It allows you to avoid the annoying popups, but on the other hand it is not (and likely will not be) a pattern that is directly supported by the ASP.NET Core framework itself. The ModelState serialisation requirement is a tricky problem which may cause issues for you in some cases, so use it with caution!

To be clear, you absolutely should be using the PRG pattern for successful POSTs, and this approach is completely supported - just return a RedirectResult from your Action method. The choice of whether to use PRG for invalid POSTs is down to you.


Viewing all articles
Browse latest Browse all 743

Trending Articles