Create ASP.NET Core custom rendering engine middleware to display a content-managed not found page for Experience Edge

It is well-known that the not found page is displayed whenever a user types a non-existent domain address in the browser url. Over the years, 404 pages have become creative as specified in the best 404 pages.

Sometimes the most simple things could get left-out in the process of covering some of the other complicated things. In this blog post, I cover one such scenario of displaying the content-managed not found page on the asp.net rendering host (Sitecore MVP site as example) but has to check and fetch (using GraphQL) the not found page only when a route/path doesn't exist. Thanks to Rob Earlam for explaining this issue in a concise manner and then reviewing the PR to suggest approach-2 that was finally implemented! 

Initially when I ventured to build such a simple use-case, all I could find were suggestions to use Response. Redirect or to write a Sitecore Pipeline Processor. In short, I actually couldn't find any examples to display a not found page that is compatible with the new XM Cloud/Experience Edge-related decoupled architecture. Also, since this was an issue in the MVP site, I thought it would be good exercise to get my hands dirty. As ever, this blog post covers my approach for implementing a not found page in the decoupled architecture. 

General rule of thumb for headless architecture:

First of all, here is the premise: I use the XM Cloud repo' MVP Site  for this use-case since the existing MVP Site, displays a static not found page as of now. 

Also, there is a lot of documentation/blog posts to discuss the concepts of pipeline/engine/middleware. I'm not getting into those. Some of them here for reference:


Off to our job of customising the logic for not found page implementation -

Default Rendering Pipeline/Engine/Middleware flow:


Implementation of 404 custom logic:

Although I have detailed about two implementation approaches, approach-2 is what is implemented in current site but I've juxtaposed both approaches in this blog post. 

Approach-1:

- Make a GraphQL query call to check if layout exists, if not, request for 404 item


Pros and Cons:

- Cleaner , step-by-step approach
- To and fro GraphQL calls esp., one extra GraphQL query call to check if layout exists

***********
#these params must be passed
query LayoutQuery($path: String!, $language: String!, $site: String!) {
layout(routePath: $path, language: $language, site: $site) {
item {
rendered
}
}
}
***********

Code commit for this approach

Approach-2:

- Make original request for the item as-is and just in case of error response, request 404 item



Pros and Cons:

- Seems cumbersome but reduces code lines
- Optimistic approach, makes 404 item call only when in need
- No unnecessary GraphQL call to check if layout exists

Since approach-2 reduces multiple calls, it was implemented based on review comments.

The most important change falls here -

Custom Rendering Engine Middleware vs Default Rendering Engine Middleware:

Approach-1:


In the above code snippet, two changes to the custom middleware compared with the default:

1. Invokes the handler that checks if layout exists for the concerned path, if not, returns false, if false, displays the 404 item else the item http requested
2. Sets the proper 404 response code for the not found item

This is how the invoked handler code (for checking valid item) looks like:
using Microsoft.Extensions.Configuration;
using Mvp.Foundation.Configuration.Rendering.AppSettings;
using Mvp.Foundation.DataFetching.GraphQL;
using Sitecore.LayoutService.Client.Request;
using Sitecore.LayoutService.Client.RequestHandlers.GraphQL;
using System;
using System.Threading.Tasks;
namespace Mvp.Project.MvpSite.Middleware
{
public class CustomGraphQlLayoutServiceHandler
{
private readonly IGraphQLRequestBuilder _graphQLRequestBuilder;
private readonly IGraphQLClientFactory _graphQLClientFactory;
private readonly MvpSiteSettings _configuration;
public CustomGraphQlLayoutServiceHandler(IConfiguration configuration, IGraphQLRequestBuilder graphQLRequestBuilder, IGraphQLClientFactory graphQLClientFactory)
{
_graphQLRequestBuilder = graphQLRequestBuilder;
_graphQLClientFactory = graphQLClientFactory;
_configuration = configuration.GetSection(MvpSiteSettings.Key).Get<MvpSiteSettings>();
}
public async Task<bool> CheckLayoutExists(
SitecoreLayoutRequest layoutrequest)
{
try
{
var client = _graphQLClientFactory.CreateGraphQlClient();
var query = "\r\n query LayoutQuery($path: String!, $language: String!, $site: String!) {\r\n layout(routePath: $path, language: $language, site: $site) {\r\n item {\r\n rendered\r\n }\r\n }\r\n }";
var variables = (object)new
{
path = layoutrequest.Path(),
language = layoutrequest.Language(),
site = _configuration.DefaultSiteName
};
var request = _graphQLRequestBuilder.BuildRequest(query, variables);
var graphQlResponse = await client.SendQueryAsync<LayoutQueryResponse>(request);
string str = graphQlResponse?.Data?.Layout?.Item?.Rendered.ToString();
if (str == null) return false;
}
catch(Exception ex)
{
var x = ex.Message;//log this?
return false;
}
return true;
}
}
}

Approach-2:

Just one change in the middleware since CheckLayoutExists method call within CustomGraphQlLayoutServiceHandler is totally unnecessary:

Actual Custom middleware code:

Approach-1:

using Microsoft.AspNetCore.Http;
using Sitecore.AspNet.RenderingEngine.Configuration;
using Sitecore.AspNet.RenderingEngine;
using Sitecore.LayoutService.Client;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc;
using Sitecore.LayoutService.Client.Response;
using System;
using Sitecore.LayoutService.Client.Request;
using System.Collections.Generic;
using Sitecore.AspNet.RenderingEngine.Middleware;
using Mvp.Foundation.DataFetching.GraphQL;
using Microsoft.Extensions.Configuration;
using Mvp.Foundation.Configuration.Rendering.AppSettings;
using System.Net;
namespace Mvp.Project.MvpSite.Middleware
{
public class CustomRenderingEngineMiddleware
{
private readonly RequestDelegate _next;
private readonly ISitecoreLayoutRequestMapper _requestMapper;
private readonly ISitecoreLayoutClient _layoutService;
private readonly RenderingEngineOptions _options;
private readonly IGraphQLClientFactory _graphQLClientFactory;
private readonly IGraphQLRequestBuilder _graphQLRequestBuilder;
private readonly IConfiguration _configuration;
public CustomRenderingEngineMiddleware(RequestDelegate next, ISitecoreLayoutClient layoutService, ISitecoreLayoutRequestMapper requestMapper, IOptions<RenderingEngineOptions> options, IGraphQLClientFactory graphQLClientFactory, IGraphQLRequestBuilder graphQLRequestBuilder, IConfiguration configuration)
{
_next = next;
_requestMapper = requestMapper;
_options = options.Value;
_graphQLClientFactory = graphQLClientFactory;
_graphQLRequestBuilder = graphQLRequestBuilder;
_layoutService = layoutService;
_configuration= configuration;
}
public async Task Invoke(HttpContext httpContext, IViewComponentHelper viewComponentHelper, IHtmlHelper htmlHelper)
{
SitecoreLayoutRequest sitecoreLayoutRequest = _requestMapper.Map(httpContext.Request);
if (httpContext.GetSitecoreRenderingContext() == null)
{
SitecoreLayoutResponse response = await GetSitecoreLayoutResponse(httpContext).ConfigureAwait(continueOnCapturedContext: false);
//Check not found page and set status code - start
if (response.Request.Path() == _configuration.GetSection(MvpSiteSettings.Key).Get<MvpSiteSettings>().NotFoundPage) { httpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; }
//Check not found page and set status code - end
SitecoreRenderingContext renderingContext = new()
{
Response = response,
RenderingHelpers = new RenderingHelpers(viewComponentHelper, htmlHelper)
};
httpContext.SetSitecoreRenderingContext(renderingContext);
}
else
{
httpContext.GetSitecoreRenderingContext().RenderingHelpers = new RenderingHelpers(viewComponentHelper, htmlHelper);
}
foreach (Action<HttpContext> postRenderingAction in (IEnumerable<Action<HttpContext>>)_options.PostRenderingActions)
postRenderingAction(httpContext);
httpContext.Items.Add((object)nameof(RenderingEngineMiddleware), (object)null);
await _next(httpContext).ConfigureAwait(continueOnCapturedContext: false);
}
private async Task<SitecoreLayoutResponse> GetSitecoreLayoutResponse(HttpContext httpContext)
{
//intercept for not found logic - start
CustomGraphQlLayoutServiceHandler customGraphQlLayoutServiceHandler = new(_configuration,_graphQLRequestBuilder, _graphQLClientFactory);
SitecoreLayoutRequest sitecoreLayoutRequest = _requestMapper.Map(httpContext.Request);
bool retVal = await customGraphQlLayoutServiceHandler.CheckLayoutExists(sitecoreLayoutRequest);
if (!retVal) httpContext.Request.Path = _configuration.GetSection(MvpSiteSettings.Key).Get<MvpSiteSettings>().NotFoundPage;
//intercept for not found logic - end
sitecoreLayoutRequest = _requestMapper.Map(httpContext.Request);
return await _layoutService.Request(sitecoreLayoutRequest).ConfigureAwait(continueOnCapturedContext: false);
}
}
}

Approach-2:

///////////////

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Mvp.Foundation.Configuration.Rendering.AppSettings;
using Mvp.Foundation.DataFetching.GraphQL;
using Sitecore.AspNet.RenderingEngine;
using Sitecore.AspNet.RenderingEngine.Configuration;
using Sitecore.AspNet.RenderingEngine.Middleware;
using Sitecore.LayoutService.Client;
using Sitecore.LayoutService.Client.Exceptions;
using Sitecore.LayoutService.Client.Request;
using Sitecore.LayoutService.Client.Response;
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
namespace Mvp.Project.MvpSite.Middleware
{
public class CustomRenderingEngineMiddleware
{
private readonly RequestDelegate _next;
private readonly ISitecoreLayoutRequestMapper _requestMapper;
private readonly ISitecoreLayoutClient _layoutService;
private readonly RenderingEngineOptions _options;
private readonly IGraphQLClientFactory _graphQLClientFactory;
private readonly IGraphQLRequestBuilder _graphQLRequestBuilder;
private readonly IConfiguration _configuration;
public CustomRenderingEngineMiddleware(RequestDelegate next, ISitecoreLayoutClient layoutService, ISitecoreLayoutRequestMapper requestMapper, IOptions<RenderingEngineOptions> options, IGraphQLClientFactory graphQLClientFactory, IGraphQLRequestBuilder graphQLRequestBuilder, IConfiguration configuration)
{
_next = next;
_requestMapper = requestMapper;
_options = options.Value;
_graphQLClientFactory = graphQLClientFactory;
_graphQLRequestBuilder = graphQLRequestBuilder;
_layoutService = layoutService;
_configuration= configuration;
}
public async Task Invoke(HttpContext httpContext, IViewComponentHelper viewComponentHelper, IHtmlHelper htmlHelper)
{
SitecoreLayoutRequest sitecoreLayoutRequest = _requestMapper.Map(httpContext.Request);
if (httpContext.GetSitecoreRenderingContext() == null)
{
SitecoreLayoutResponse response = await GetSitecoreLayoutResponse(httpContext).ConfigureAwait(continueOnCapturedContext: false);
//Check not found page and set status code - start
if (response.HasErrors)
{
foreach (SitecoreLayoutServiceClientException error in response.Errors)
{
switch (error)
{
case ItemNotFoundSitecoreLayoutServiceClientException:
httpContext.Request.Path = _configuration.GetSection(MvpSiteSettings.Key).Get<MvpSiteSettings>().NotFoundPage;
sitecoreLayoutRequest = _requestMapper.Map(httpContext.Request);
response = await GetSitecoreLayoutResponse(httpContext).ConfigureAwait(continueOnCapturedContext: false);
if (response.Request.Path() == _configuration.GetSection(MvpSiteSettings.Key).Get<MvpSiteSettings>().NotFoundPage) { httpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; }
break;
default:
throw error;
}
}
}
//Check not found page and set status code - end
SitecoreRenderingContext renderingContext = new()
{
Response = response,
RenderingHelpers = new RenderingHelpers(viewComponentHelper, htmlHelper)
};
httpContext.SetSitecoreRenderingContext(renderingContext);
}
else
{
httpContext.GetSitecoreRenderingContext().RenderingHelpers = new RenderingHelpers(viewComponentHelper, htmlHelper);
}
foreach (Action<HttpContext> postRenderingAction in (IEnumerable<Action<HttpContext>>)_options.PostRenderingActions)
postRenderingAction(httpContext);
httpContext.Items.Add(nameof(RenderingEngineMiddleware), null);
await _next(httpContext).ConfigureAwait(continueOnCapturedContext: false);
}
private async Task<SitecoreLayoutResponse> GetSitecoreLayoutResponse(HttpContext httpContext)
{
SitecoreLayoutRequest sitecoreLayoutRequest = _requestMapper.Map(httpContext.Request);
return await _layoutService.Request(sitecoreLayoutRequest).ConfigureAwait(continueOnCapturedContext: false);
}
}
}

//////////////

Other titbits (generic to both approaches):

So, in order to invoke the custom middleware, make this change on top of the action method - pass the typeof custom pipeline as a param:


Note that Custom Rendering Engine Pipeline must inherit Rendering Engine Pipeline:


Understandably, the custom pipeline calls the "Use" method that in turn invokes the middleware:

using Microsoft.AspNetCore.Builder;
using Mvp.Project.MvpSite.Extensions;
using Sitecore.AspNet.RenderingEngine.Middleware;
namespace Mvp.Project.MvpSite.Middleware
{
public class CustomRenderingEnginePipeline:RenderingEnginePipeline
{
//
// Summary:
// Adds the Sitecore Rendering Engine features to the given Microsoft.AspNetCore.Builder.IApplicationBuilder.
//
//
// Parameters:
// app:
// The Microsoft.AspNetCore.Builder.IApplicationBuilder to add features to.
public override void Configure(IApplicationBuilder app)
{
app.UseCustomSitecoreRenderingEngine();
}
}
}
Actual Custom middleware plumbing within ApplicationBuilderExtensions.cs:

public static IApplicationBuilder UseCustomSitecoreRenderingEngine(this IApplicationBuilder app)
{
app.UseMiddleware<CustomRenderingEngineMiddleware>();
return app;
}
view raw gistfile1.txt hosted with ❤ by GitHub


Store the 404 item path in appSettings.json:

Understandably, a C# property with the same name required to deserialize the above json value.

In the DefaultController.cs (MVP site), the following logic related to not found layout can be removed completely:


NotFound.cshtml that stores the static content is not needed anymore either.

Old Not found static page:


New 404 page that displays Sitecore content with 404 response code:


Note: The most important part of the development process is debugging the Visual Studio code with Docker, refer steps in Debugging with Docker section in this blog post

Comments