11 Grudzień 2020

POST-REDIRECT-GET in ASP.NET Razor Pages

Bez kategorii

Autor: Tomasz Malinowski

POST-REDIRECT-GET in ASP.NET Razor Pages

POST-REDIRECT-GET (PRG) is a design pattern that states that a POST request to the server should be answered with a REDIRECT response that points the user to a GET request what will yield some summary of the POST-ed data.

To quote Wikipedia,

When a web form is submitted to a server through an HTTP POST request, attempts to refresh the server response can cause the contents of the original POST to be resubmitted, possibly causing undesired results, such as a duplicate web purchase.
To avoid this problem, many web developers use the PRG pattern — instead of returning a web page directly, the POST returns a redirect. The HTTP 1.1 specification introduced the HTTP 303 („See other”) response code to ensure that in this situation, browsers can safely refresh the server response without causing the initial POST request to be resubmitted.

In most cases, failure to implement PRG properly leads to user seeing an unfriendly warning message when trying to refresh the page with the form. If the user chooses to continue with the refresh operation (which they are naturally tempted to do), it can lead to them executing duplicate request – with results ranging from inconvenient (like registering two edits with the same data) to downright dangerous (like charging the payment twice).

The solution, as we said, is to respond to POST request with a REDIRECT that points the user to a GET page. When the form in question is filled correctly, it’s as simple as returning a RedirectToPageResult from the page handler.

public class MyPage : PageModel
{
    public IActionResult OnPost(MyCommand command)
    {
        _commandProcessor.Execute(command);
        return RedirectToPage("/success");
    }
}

If the user then refreshes the page, he only reloads the success page without resubmitting the form. Obviously, some bandwidth is lost for this request, but that’s a small loss compared to for example charging the user’s credit card twice and having to deal with angry customers wanting their money back.

But what if there were some errors in processing the form? For example, the user entered an invalid email address, or it turns out that his credit card has expired. In that case we should REDIRECT the user back to the form in question using GET request, but at the same time preserve both their current inputs (to pre-fill the form with already provided data) and all the relevant error messages. The problem is that in out-of-the-box ASP.NET Core, during POST request the user data and inputs are preserved along with all the error messages, but they are not preserved on REDIRECT – and thus lost forever.

ASP.NET Core gives us the ModelStateDictionary that holds all the user’s inputs and respective validation results, including possible errors. The obvious solution would be to serialize ModelStateDictionary into TempData and then rehydrate the form data from that, but it is not serializable in out-of-the-box ASP.NET Core (see https://github.com/dotnet/aspnetcore/issues/4816), at least not without some work on our part.

Andrew Lock has a [great solution for this problem https://andrewlock.net/post-redirect-get-using-tempdata-in-asp-net-core/], with one caveat: that solution works in ASP.NET MVC only.

If you’re like me and prefer Razor Pages over MVC in your ASP.NET Core work, you’ll have to make some modifications to Andrew Lock’s solution to make it work with Razor Pages’ IPageFilter. I’ve also simplified it somewhat: the original code was split into multiple classes and I’ve arranged it into a single one. The original code also restored the ModelState only if explicitly told to via a controller attribute – this version restores data without the need to use any attributes, and can be told to serialize it with a fluent .WithModelState(this) extension method.

Anyway, here’s the code.

First we need to be able to serialize and deserialize ModelStateDictionary to and form string, preserving attempted values, raw values and error messages associated with the model. This version uses System.Text.Json as serialization library, but you can use Newtonsoft.Json if you prefer.

public static class ModelStateSerializer
{
    private 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>();
    }

    public static string Serialize(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 System.Text.Json.JsonSerializer.Serialize(errorList);
    }

    public static ModelStateDictionary Deserialize(string serializedErrorList)
    {
        var errorList = System.Text.Json.JsonSerializer.Deserialize<List<ModelStateTransferValue>>(serializedErrorList);
        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;
    }
}

Next, we need the actual filter to deserialize the data and inject it into Razor Page’s ModleState.

public class SerializeModelStateFilter : IPageFilter
{
    public static readonly string Key = $"{nameof(SerializeModelStateFilter)}Data";
    public void OnPageHandlerSelected(PageHandlerSelectedContext context)
    {
        if (!(context.HandlerInstance is PageModel page))
            return;

        var serializedModelState = page.TempData[Key] as string;
        if (serializedModelState.IsNullOrEmpty())
            return;

        var modelState = ModelStateSerializer.Deserialize(serializedModelState);
        page.ModelState.Merge(modelState);
    }

    public void OnPageHandlerExecuting(PageHandlerExecutingContext context) { }

    public void OnPageHandlerExecuted(PageHandlerExecutedContext context) { }
}

And finally, we need a way to actually serialize the data and save it to TempData dictionary. Andrew Lock’s example did it via attributes, I find a fluent extension method a cleaner and more intuitive way.

public static class KeepTempDataResultExtensions
{
    public static IKeepTempDataResult WithModelStateOf(this IKeepTempDataResult actionResult, PageModel page)
    {
        if (page.ModelState.IsValid)
            return actionResult;
        var modelState = ModelStateSerializer.Serialize(page.ModelState);
        page.TempData[SerializeModelStateFilter.Key] = modelState;
        return actionResult;
    }
}

How does it actually work?

  • IKeepTempDataResult is an interface implemented by all standard ASP.NET Core redirect results (HTTP codes 301 Moved Permanently, 302 Found, 307 Temporary Redirect and 308 Permanent Redirect)
  • we use .WithModelStateOf(this) extension method on the IActionResult to serialize the ModelState into TempData, so it’s available for the subsequent GET request
  • on the next request the filter checks if there is any serialized ModelState in current TempData and if so, restores it

How to use it?
First of all, you have to register SerializeModelStateFilter in your Startup.ConfigureServices() method like this:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddRazorPages()
            .AddMvcOptions(options =>
            {
                options.Filters.Add<SerializeModelStateFilter>();
            });
    }
}

Then all you need to do is to call .WithModelStateOf(this) on your redirect result and you’ve good to go.

public class MyPage : PageModel
{
    public IActionResult OnPost(MyCommand command)
    {
        var result = _commandProcessor.Execute(command);
        if (result.IsSuccess)
            // success, so no point in persisting form data and errors here
            return RedirectToPage("/success");
        else
             // something went wrong, redisplay the form with pre-entered data and errors
            return RedirectToPage("/myPage").WithModelStateOf(this);
    }
}

W celu poprawy jakości naszych usług używamy ciasteczek.

Możesz je zablokować poprzez zmianę ustawień przeglądarki.