Part II: Implementing Kentico Xperience StatusCodePages Middleware

Error Pages in Kentico Xperience

In Part I: An Introduction to ASP.NET Core StatusCodePages, we explored ASP.NET Core StatusCodePages and how we can use this middleware provided by the framework to produce user-friendly error pages from the Kentico Xperience Content Tree using a custom Controller. In this predecessor to Part I, we'll dip our toes into the waters of the ASP.NET Core StatusCodePages source to implement a custom Request Delegate Middleware, allowing support for Page Routing.

Implementing Kentico Xperience StatusCodePages Middleware

As recommended by Microsoft, we'll start implementing our Kentico Xperience StatusCodePages Middleware by defining an extension method that targets IApplicationBuilder, to expose the usage of UseStatusCodePages(IApplicationBuilder, Func<StatusCodeContext,Task>) to configure the StatusCodePages middleware.

We'll start our implementation with the WithRedirects extension:

public static class XperienceStatusCodePagesExtensions
{

  private static async Task GetErrorNodePathAsync( HttpContext context )
  {
    var errorPageRetriever = context.RequestServices.GetRequiredService();
    var page = await errorPageRetriever.RetrieveAsync( context.Response.StatusCode, context.RequestAborted );
    if( page is null )
    {
      return string.Empty;
    }

    var pageUrlRetriever = context.RequestServices.GetRequiredService();
    return pageUrlRetriever.Retrieve( page )
      ?.RelativePath
      ?.TrimStart( '~' );
  }

  public static IApplicationBuilder UseXperienceStatusCodePageWithRedirects( this IApplicationBuilder app )
  {
    if( app is null ) throw new ArgumentNullException( nameof( app ) );

    return app.UseStatusCodePages(
      async context =>
      {
        var path = await GetErrorNodePathAsync( context.HttpContext );
        if( !string.IsNullOrEmpty( path ) )
        {
          context.HttpContext.Response.Redirect( context.HttpContext.Request.PathBase + path );
        }
      }
    );
  }

}

If the path of an HttpErrorNode can be retrieved via GetErrorNodePathAsync, we instruct the response to redirect to the retrieved path. In GetErrorNodePathAsync, we use the IErrorPageRetriever from Part I to retrieve an HttpErrorNode for the current response, and Kentico's IPageUrlRetriever to retrieve the path for the respective HttpErrorNode. By using Kentico's IPageUrlRetriever, our Kentico Xperience StatusCodePages Middleware will automatically support the HttpErrorNode's configured Page Routing behavior.

To learn more about the details of Kentico's IPageUrlRetriever service, check out Sean G. Wright's excellent piece "Bits of Xperience: The Hidden Cost of IPageUrlRetriever.Retrieve".

That static GetErrorNodePathAsync method though, kind of gross. Just as was done with the ErrorController example from Part I, a "Retriever" service can be defined to make the implementation more concise and extensible.

public interface IErrorPageUrlRetriever
{

  Task RetrieveAsync( int code, CancellationToken cancellation = default );

}
Refactoring the code from the static GetErrorNodePathAsync method, the ErrorPageUrlRetriever is as follows:
public class ErrorPageUrlRetriever : IErrorPageUrlRetriever
{
  #region Fields
  private readonly IErrorPageRetriever errorPageRetriever;
  private readonly IPageUrlRetriever pageUrlRetiever;
  #endregion

  public ErrorPageUrlRetriever(
      IErrorPageRetriever errorPageRetriever,
      IPageUrlRetriever pageUrlRetiever
  )
  {
    this.errorPageRetriever = errorPageRetriever;
    this.pageUrlRetriever = pageUrlRetriever;
  }

  public async Task RetrieveAsync( int code, CancellationToken cancellation = default )
  {
    var page = await errorPageRetriever.RetrieveAsync( code, cancellation );
    if( page is null )
    {
      return null;
    }

    return pageUrlRetriever.Retrieve( page );
  }

}

Add our service to the IServiceCollection in the Startup, and we've once again made our code a bit more concise.

public class Startup
{

  public void ConfigureServices( IServiceCollection services )
  {
    // ...

    services.AddTransient();
    services.AddTransient();

    // ...
  }

}
public static class XperienceStatusCodePagesExtensions
{

  private static async Task GetErrorNodePathAsync( HttpContext context )
  {
    var url = await context.RequestServices.GetRequiredService()
      .RetrieveAsync( context.Response.StatusCode, context.RequestAborted );

    return url?.RelativePath?.TrimStart( '~' );
  }

  // ...
}

The ASP.NET Core source code for the UseStatusCodePagesWithReExecute extension method on GitHub shall serve as a solid reference for how to support ReExecute functionality. Reviewing the source code, there are 4 lines of particular interest:

var newPath = new PathString(
  string.Format(CultureInfo.InvariantCulture, pathFormat, context.HttpContext.Response.StatusCode));
var formatedQueryString = queryFormat == null
  ? null : string.Format(CultureInfo.InvariantCulture, queryFormat, context.HttpContext.Response.StatusCode);
var newQueryString = queryFormat == null ? QueryString.Empty : new QueryString(formatedQueryString);

// ...

These 4 lines format the string arguments passed to UseStatusCodePagesWithReExecute that create the path to ReExecute the request pipeline on. We can base our implementation off of the frameworks source for UseStatusCodePagesWithReExecute, replacing these four lines with a call to our GetErrorNodePathAsync method. This will ensure that our custom Kentico Xperience StatusCodePages Middleware's behavior is compatible with the behavior of the StatusCodePages Middleware when using UseStatusCodePagesWithReExecute out-of-the-box, ensuring additional middleware in the request pipeline that may depend on the IStatusCodeReExecuteFeature will continue to behave correctly, just as expected.

public static class XperienceStatusCodePagesExtensions
{
  // ...

  public static IApplicationBuilder UseXperienceStatusCodePageWithReExecute( this IApplicationBuilder app )
  {
    if( app is null ) throw new ArgumentNullException( nameof( app ) );

    return app.UseStatusCodePages(
      async context =>
      {
        // Get the path to the HttpErrorNode
        var path = await GetErrorNodePathAsync( context.HttpContext );
        var newPath = new PathString( path );

        var originalPath = context.HttpContext.Request.Path;
        var originalQueryString = context.HttpContext.Request.QueryString;

        // Store the original paths so the app can check it.
        context.HttpContext.Features.Set(
          new StatusCodeReExecuteFeature
          {
            OriginalPathBase = context.HttpContext.Request.PathBase.Value,
            OriginalPath = originalPath.Value,
            OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null,
          }
        );

        // An endpoint may have already been set. Since we're going to re-invoke the middleware pipeline, we need to reset the endpoint and route values to ensure things are re-calculated.
        context.HttpContext.SetEndpoint(endpoint: null);

        context.HttpContext.Features.Get()
          ?.RouteValues
          ?.Clear();

        context.HttpContext.Request.Path = newPath;
        context.HttpContext.Request.QueryString = QueryString.Empty;

        try
        {
          await context.Next( context.HttpContext );
        }
        finally
        {
          context.HttpContext.Request.Path = originalPath;
          context.HttpContext.Request.QueryString = originalQueryString;

          context.HttpContext.Features.Set(null);
        }
      }
    );
  }

}

The next step is to register the HttpErrorNode for Kentico Xperience Page Routing, refactoring the ErrorController to use Kentico's IPageDataContextRetriever.

[assembly: RegisterPageRoute( HttpErrorNode.CLASS_NAME, typeof( ErrorController ) )]
public class ErrorController
{

  public IActionResult Index( [FromServices] IPageDataContextRetriever contextRetriever )
  {
    contextRetriever.TryRetrieve( out IPageDataContext data );
    var viewModel = new ErrorViewModel
    {
      Content = new HtmlContentBuilder()
        .SetHtmlContent(data?.Page?.Content ?? "There was an error processing the request."),
      Heading = data?.Page?.Heading ?? "Internal Server Error"
    }

    return View( viewModel );
  }

}

@model ErrorViewModel 

<h1>@Model.Heading</h1>
<p>@Model.Content</p> 

Finally, update the Startup.cs to use our desired Kentico Xperience StatusCodePages middleware behavior:

public class Startup
{
  // ...

  public void Configure( IApplicationBuilder app )
  {
    //app.UseXperienceStatusCodePagesWithRedirects();
    app.UseXperienceStatusCodePagesWithReExecute();

    // ...
  }
}

Requests to the MVCMvc site at paths that produce a 404: Not Found status code will now return the Content and Heading from an HttpErrorNode where HttpStatusCode = 404:

GET https://localhost:5001/not-a-real-url


<!DOCTYPE html>
  <html>
    <head>
      <!-- ... -->
    </head> 
    <body>
      <h1>Page Not Found</h1>
      <p>...</p>
    </body> 
  </html> 

Learn More on GitHub

Learn more about ASP.NET Core StatusCodePages + Kentico Xperience Middleware on GitHub at BizStream/xperience-status-code-pages. Stay up to date on implementation details, open a PR, or just ask a question!

In Part III: Using the BizStream Kentico Xperience StatusCodePages Packages, we'll dig into the BizStream/xperience-status-code-pages repository, and how the NuGet packages published from it can be used to quickly get started using StatusCodePages powered by Kentico Xperience.

If you are looking for more help with your Kentico Xperience projects, feel free to reach out to BizStream directly.

 

Click here to read more Kentico posts
Start a Project with Us
Photo of the author, Sam Steele

About the author

Sam loves learning. While teaching himself to program, Sam found a place of friends in the Open Source Community where he was able to grow his skills with other young developers. His primary goal at BizStream is to learn and gain as much experience from the rest of the team. When he's not programming, you can find Sam playing his favorite video game, Rainbow Six Siege, watching movies old movies, or tinkering with his car.

View other posts by Sam

Subscribe to Updates

Stay up to date on what BizStream is doing and keep in the loop on the latest with Kentico.