In almost every MVC site I’ve developed on the Kentico Xperience Platform, there has been one feature that has never quite felt “right”: Error Pages.
Whether it’s due to confusing configuration (looking at you, System.Web), awkward routing and redirects in controllers, or multiple, sometimes ambiguous, patterns for handling exceptions (looking at you again, System.Web), creating user-friendly error pages in Kentico Xperience MVC sites don’t always seem straight-forward.
It’s time for a reckoning.
In this 3-part series, we’ll be covering the background to BizStream’s xperience-status-code-pages
packages, reviewing how the ASP.NET Core StatusCodePages pattern can be extended to serve user-friendly error pages from the Kentico Xperience Content Tree.
Within the ASP.NET Core framework, StatusCodePages is a pattern for providing a response body, based on the response’s status code.
The StatusCodePages pattern provides a variety of IApplicationBuilder
extension methods that configure the behavior of the underlying middleware, and allow developers some options as to how the response should be handled. Of these extension methods, UseStatusCodePagesWithReExecute
is particularly attractive, as it allows developers to specify a path upon which to re-execute the request pipeline.
An advantage to “ReExecute” over “Redirect”, is that the original URL is preserved in the client. A request to /not-a-real-url that produces a 404: Not Found
status code is returned to the requested URL, /not-a-real-url
, rather than redirecting the client to /error?code=404
. If you’re a veteran ASP.NET developer with IIS Rewrite Module experience: a “ReExecute” is similar to a “Rewrite”, as opposed to a “Redirect”
For Example:
public class Startup
{
// ...
public void Configure( IApplicationBuilder app )
{
app.UseStatusCodePagesWithReExecute( "/error", "?code={0}" );
// ...
}
}
public class ErrorController : Controller
{
[HttpGet( "error" )]
public IActionResult Error( int code )
=> Content( $"Http Error: {code}" );
}
GET https://localhost:5001/not-a-real-url Http Error: 404
An additional advantage to this pattern is that in other controllers, the base Controller.NotFound
method can be used, as opposed to redirecting to a custom “NotFound” action:
public class ProductController : Controller
{
[HttpGet( "products/{slug}" )]
public IActionResult Index(
[FromServices] IProductService productService,
string slug
)
{
var product = productService.GetProductBySlug( slug );
// NO MORE YUCK!
// if( product is null )
// {
// return RedirectToAction( "NotFound", "Error" );
// }
// Yay!
if( product is null )
{
return NotFound();
}
return View( product );
}
}
The RedirectToAction( "NotFound", "Error" )
is a common pattern I often see in Kentico Xperience MVC sites, wherein the NotFound
action queries the Kentico Xperience Content Tree for some type of NotFoundNode.
In many cases, we’d like to give Content Editors the ability to manage the content of Error Pages. This can be achieved by building upon the ErrorController
from the previous example to query and serve content from the Kentico Xperience Content Tree:
public class ErrorController : Controller
{
[HttpGet( "error" )]
public async Task Error( [FromServices] IPageRetriever pageRetriever, int code )
{
var page = (
await pageRetriever.RetrieveAsync(
nodes => nodes.WhereEquals( nameof( HttpErrorNode.HttpStatusCode ), code )
.Or( condition => condition.WhereEquals( nameof( HttpErrorNode.HttpStatusCode ), 500 ) ),
.TopN( 1 )
cache => cache.Dependencies( (_, __) => { } )
.Key( $"httperrornode|{code}" ),
HttpContext.RequestAborted
)
)?.FirstOrDefault();
var viewModel = new ErrorViewModel
{
Content = page?.Content ?? "There was an error processing the request.",
Heading = page?.Heading ?? "Internal Server Error"
}
return View( viewModel );
}
}
That’s quite a bit of query logic in the controller…
We can clean that up by defining a service to compose the IPageRetriever
usage and query logic. We’ll try to work with Kentico’s “Page Retrievers” pattern here by defining an IErrorPageRetriever
that describes the retrieval of an HttpErrorNode:
public interface IErrorPageRetriever
{
Task RetrieveAsync( int code, CancellationToken cancellation = default );
}
Refactoring the code from the ErrorController, the ErrorPageRetriever is as follows:
public class ErrorPageRetriever : IErrorPageRetriever
{
#region Fields
private readonly IPageRetriever pageRetriever;
#endregion
public ErrorPageRetriever( IPageRetriever pageRetriever )
=> this.pageRetriever => pageRetriever;
public async Task RetrieveAsync( int code, CancellationToken cancellation = default )
=> (
await pageRetriever.RetrieveAsync(
nodes => nodes.WhereEquals( nameof( HttpErrorNode.HttpStatusCode ), code )
.Or( condition => condition.WhereEquals( nameof( HttpErrorNode.HttpStatusCode ), 500 ) ),
.TopN( 1 )
cache => cache.Dependencies( (_, __) => { } )
.Key( $"httperrornode|{code}" ),
cancellation
)
)?.FirstOrDefault();
}
_The "no-op" (.Dependencies( (_, __) => { } )) call to IPageCacheBuilder.Dependencies( Action, IPageCacheDependencyBuilder> configureDependenciesAction, bool includeDefault = true )is required to ensure theHttpErrorNoderetrieved is cached using the defaults, via theincludeDefault flag._
Add our services to the IServiceCollection
in the Startup
, and the ErrorController
is a bit more concise.
public class Startup
{
public void ConfigureServices( IServiceCollection services )
{
// ...
services.AddTransient();
// ...
}
}
public class ErrorController : Controller
{
[HttpGet( "error" )]
public async Task Error( [FromServices] IErrorPageRetriever errorPageRetriever, int code )
{
var page = await errorPageRetriever.RetrieveAsync( code, HttpContext.RequestAborted );
var viewModel = new ErrorViewModel
{
Content = page?.Content ?? "There was an error processing the request.",
Heading = page?.Heading ?? "Internal Server Error"
}
return View( viewModel );
}
}
All of this is nice, except for one. little. thing. /error?code={0}
. All of the error pages are accessed at the /error
path with a query string, Yuck! An ideal solution would allow us to use Page Routing to give Content Editors the ability to configure the URLs of the error pages, just like any other node in the Content Tree. Page Routing would also give greater flexibility to support Redirect or ReExecute behaviors.
We are now presented with a new problem: UseStatusCodePagesWithReExecute
and UseStatusCodePagesWithRedirects
require a specific path string that must be known when Startup.Configure(IApplicationBuilder)
is invoked and the UseStatusCodePages*
method is called. In order to make Page Routing work, the StatusCodePages middleware requires a means of dynamically querying the Content Tree for the PageUrl
of the expected HttpErrorNode
in which the request should Redirect to or ReExecute.
Unfortunately, neither of the WithRedirects
or WithReExecute
extensions provide overloads that would allow dynamic retrieval of the path to Redirect to or ReExecute on. There is still hope, however, thanks to the UseStatusCodePages(IApplicationBuilder, Func<StatusCodeContext,Task>)
overload. This overload allows a Func<StatusCodeContext, Task>
delegate to be passed, which effectively functions as a Request Delegate via the StatusCodeContext
.
In Part II: Implementing Kentico Xperience StatusCodePages Middleware, we’ll explore how UseStatusCodePages(IApplicationBuilder, Func<StatusCodeContext,Task>)
can be used to implement our own WithRedirects/WithReExecute
extension methods that query the Kentico Xperience Content Tree for the PageUrl
to Redirect to or ReExecute on, in a manner that is compatible with the behavior of the extensions provided by the framework.
If you are looking for more help with your Kentico Xperience projects, feel free to reach out to BizStream directly.
We love to make cool things with cool people. Have a project you’d like to collaborate on? Let’s chat!
Stay up to date on what BizStream is doing and keep in the loop on the latest in marketing & technology.