When working on an ASP.Net RazorPages project this week I had a scenario where we were tracking a user choice in a cookie. At one point in the application workflow a user would select an active item and we would light up some additional ctions. Easy enough.
First Pass
To get warmed up I started setting the cookie in a post handler on my page.
public async Task<IActionResult> OnPostAsync()
{
var cookieOptions = new CookieOptions()
{
Path = "/",
IsEssential = true,
HttpOnly = true,
Secure = true
};
HttpContext.Response.Cookies.Append("SelectedValue", SelectedValue, cookieOptions);
return RedirectToPage("./Index");
}
Simple enough but I think we can do better.
Second Pass
Instead of setting the value directly let's move the value to a scoped user context service. This left my updated page looking something like this.
public class SelectModel : PageModel
{
private readonly ApplicationContextService _applicationContext;
public SelectModel(ApplicationContextService applicationContext)
{
_applicationContext = applicationContext;
}
[BindProperty]
public string SelectedValue{get;set;}
public void OnGet()
{
SelectedValue = _applicationContext.SelectedValue;
}
public IActionResult OnPost()
{
_applicationContext.SelectedValue = SelectedValue;
return RedirectToPage("./Select");
}
}
In my application context middleware I wrote some before and after code for reading and setting the cookie.
public async Task InvokeAsync(HttpContext context, ApplicationContextService appContext)
{
if (context.Request.Cookies.TryGetValue("SelectedValue", out var inValue))
{
appContext.SelectedValue = inValue;
}
else
{
appContext.SelectedValue = null;
}
await _next(context);
if (!string.IsNullOrWhiteSpace(appContext.SelectedValue) && appContext.SelectedValue != inValue)
{
var cookieOptions = new CookieOptions()
{
Path = "/",
IsEssential = true,
HttpOnly = true,
Secure = true
};
context.Response.Cookies.Append("SelectedValue", appContext.SelectedValue, cookieOptions);
}
else if (string.IsNullOrWhiteSpace(appContext.SelectedValue))
{
context.Response.Cookies.Delete("SelectedValue");
}
}
I ran my code again and was rudely given this error in chrome dev tools.
net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)
The server side code had responded with this more helpful error message
Connection id "0HM4V57QBU3H5", Request id "0HM4V57QBU3H5:00000008": An unhandled exception was thrown by the application.
System.InvalidOperationException: Headers are read-only, response has already started.
"Kick Myself" Pass
Of course, I should have known better than to try this simple approach. I know that I can' t set response headers after the response has already started. In my rush to refactor I had failed to recall that await _next(context) was executing the code in my page and returning the result. Now how do I set the cookie before the response starts?
- It worked in the page post handler
- I could add a 'Select' method to the context class that sets or clears the cookie (but I don't really want the context class to have a dependency on my http context)
- Hmm... I could google and read the docs?
Final Pass
My searching led met to the Response.OnStarting method of the HttpResponse class. This method accepts a delegate to be invoked just before response headers are sent.
I updated my middleware to set the cookie inside of a lambda passed to the 'OnStarting' method and everything started to work like expected.
public async Task InvokeAsync(HttpContext context, ApplicationContextService appContext)
{
if (context.Request.Cookies.TryGetValue("SelectedValue", out var inValue))
{
appContext.SelectedValue = inValue;
}
else
{
appContext.SelectedValue = null;
}
context.Response.OnStarting(() => {
if (!string.IsNullOrWhiteSpace(appContext.SelectedValue) && appContext.SelectedValue != inValue)
{
var cookieOptions = new CookieOptions()
{
Path = "/",
IsEssential = true,
HttpOnly = true,
Secure = true
};
context.Response.Cookies.Append("SelectedValue", appContext.SelectedValue, cookieOptions);
}
else if (string.IsNullOrWhiteSpace(appContext.SelectedValue))
{
context.Response.Cookies.Delete("SelectedValue");
}
return Task.FromResult(0);
});
await _next(context);
}
Notes from the docs:
Callbacks registered here run in reverse order. The last one registered is invoked first. The reverse order is done to replicate the way middleware works, with the inner-most middleware looking at the response first.
Conclusion
The "HttpResponse.OnStarting" method is our friend and there is also a "OnComplete" method that may come in handy someday.
Other Scenarios
Example code is rarely complete. So, here are some ways we could expand or improve on this topic.
- Prove that it works by adding injection IAppContext into a couple of different pages and changing the value in each page
- Modify other parts of the response in addition to setting or deleting a cookie
Learn More
- Using scoped services in ASP.Net middleware: https://hanson.io/aspnet-httpcontext-items-to-scoped-services
- HttpResponse class: https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.httpresponse
- We're setting cookies here so of course don't forget about our good friend GDPR: https://docs.microsoft.com/en-us/aspnet/core/security/gdpr
Cover image by Christina Branco on Unsplash