Sunday, September 8, 2013

Dynamic error handling in EPiServer 7 with static fallback

A lot of EPiServer editors want to be able to edit their 404 and 500 error pages in edit mode, and telling them that they have to poke around in static HTML files is usually not very popular.

But it’s important to remember that dynamic error pages cause some additional challenges that has to be considered. Examples are avoiding infinite loops, globalization and being able to add error pages for any HTTP status code that you’d like. The concept behind this solution is the same as the solution for EPiServer CMS 6, but it has been rewritten to MVC 4 and it now supports globalization.

I prefer to create a new pagetype for the error pages, in that way I can restrict where the pagetype can be used and who has access to creating and editing error pages. So let’s create our new pagetype called ErrorPage:

1: [ContentType(Order = 7000)]

2: [AvailablePageTypes(Availability = Availability.None)]

3: public class ErrorPage : SitePageData

4: {

5:   [Display(Order = 100)]

6:   [CultureSpecific]

7:   public virtual string HttpStatusCode { get; set; }

8:  

9:   [Display(Order = 1000, GroupName = SystemTabNames.Content)]

10:  [CultureSpecific]

11:  public virtual XhtmlString MainBody { get; set; }

12:  

13:   public override void SetDefaultValues(ContentType contentType)

14:   {

15:     base.SetDefaultValues(contentType);

16:  

17:     VisibleInMenu = false;

18:     DisableIndexing = true;

19:   }

20: }

There is nothing special with this page type. It has a string property called HttpStatusCode where the editor specifies which status code this error page should be used for, and a MainBody property where they can specify a friendly message. Now let’s create a controller for this pagetype:

1: [ContentOutputCache]

2: public class ErrorPageController : PageControllerBase<ErrorPage>

3: {

4:   public ActionResult Index(ErrorPage currentPage)

5:   {

6:     return View(PageViewModel.Create(currentPage));

7:   }

8: }

The only thing this controller does is to return a view, and that view can look like this:

1: @model PageViewModel<ErrorPage>

2:  

3: @{ Layout = "~/Views/Shared/Layouts/_Root.cshtml"; }

4:

5: <h1>@Model.CurrentPage.PageName</h1>

6: @Html.DisplayFor(m => Model.CurrentPage.MainBody)

So far, so good! Right? Now comes the challenging part! The view we just created will never actually be seen by a visitor, the only time this view is displayed is in edit mode. Instead of the visitor seeing this view, an ErrorHandler will be in charge of finding the correct ErrorPage and displaying its content. Let’s take a look at our ErrorHandlerController:

1: public class ErrorHandlerController : Controller

2: {

3:   private List<ErrorPage> _errorPages;

4:   private string _currentLanguage;

5:  

6:   private List<ErrorPage> ErrorPages

7:   {

8:     get

9:     {

10:       if (_errorPages == null || _errorPages.Count == 0)

11:       {

12:         if (string.IsNullOrWhiteSpace(_currentLanguage))

13:           _currentLanguage = ContentLanguage.PreferredCulture.Name;

14:  

15:         IContentTypeRepository contentTypeRepository = ServiceLocator.Current.GetInstance<IContentTypeRepository>();

16:         ContentType errorPage = contentTypeRepository.Load(typeof(ErrorPage));

17:  

18:         _errorPages = DataFactory.Instance.FindPagesWithCriteria(ContentReference.RootPage, new PropertyCriteriaCollection

19:         {

20:           new PropertyCriteria

21:           {

22:           Condition = CompareCondition.Equal,

23:           Name = "PageTypeID",

24:           Type = PropertyDataType.PageType,

25:           Value = errorPage.ID.ToString()

26:           }

27:         }, _currentLanguage).FilterForDisplay().Cast<ErrorPage>().ToList();

28:       }

29:       return _errorPages;

30:     }

31: }

32:  

33: public ActionResult Index()

34: {

35: // Get status code

36:     int statusCode = GetStatusCode();

37:  

38:     if (statusCode > 0)

39:     {

40:       Response.StatusCode = statusCode;

41:  

42:       // Check if page with this status code is defined in EPiServer

43:       ErrorPage errorPage = ErrorPages.Find(page => page.HttpStatusCode.Equals(statusCode.ToString()));

44:       if (errorPage == null)

45:       {

46:         string staticHtmlPage = GetStaticHtmlPage(statusCode);

47:         if (String.IsNullOrEmpty(staticHtmlPage))

48:         {

49:           throw new HttpException(

50:           String.Format("Could not get static html page for statuscode {0} in errorhandler", statusCode));

51:         }

52:         return new ExtendedContentResult

53:         {

54:           StatusCode = Response.StatusCode,

55:           Content = MvcHtmlString.Create(staticHtmlPage).ToString(),

56:           ContentEncoding = Encoding.UTF8,

57:           ContentType = "text/html",

58:           Headers = Response.Headers

59:         };

60:       }

61:        

62:       // Set RoutingConstants.NodeKey so EPi's extension method RequestContext.GetContentLink() will work

63:       ControllerContext.RouteData.DataTokens[RoutingConstants.NodeKey] = errorPage.ContentLink;

64:       return View("~/Views/ErrorHandling/ErrorHandler.cshtml", PageViewModel.Create(errorPage));

65:     }

66:  

67:     throw new HttpException("No status code sent to errorhandler");

68:   }

69:  

70:   /// <summary>

71:   /// Returns the static html file for the given status code as a string

72:   /// </summary>

73:   /// <param name="statusCode">HTTP Error code</param>

74:   /// <returns>The content of the static html file as a string</returns>

75:   private string GetStaticHtmlPage(int statusCode)

76:   {

77:     string filePath = String.Format("/StatusCodes/{0}.html", statusCode);

78:     string mapPath = Server.MapPath(filePath);

79:     if (System.IO.File.Exists(mapPath))

80:     {

81:       // Read the file as one string.

82:       StreamReader myFile = new StreamReader(mapPath);

83:       string myString = myFile.ReadToEnd();

84:       myFile.Close();

85:       return myString;

86:     }

87:     return String.Empty;

88:   }

89:  

90:   /// <summary>

91:   /// Get the HTTP error status code from the querystring and sets current language

92:   /// </summary>

93:   private int GetStatusCode()

94:   {

95:     int code = 0;

96:     string request = Server.UrlDecode(ControllerContext.HttpContext.Request.QueryString.ToString());

97:     if (!String.IsNullOrEmpty(request))

98:     {

99:        Regex regex = new Regex(@"(?:[0-9]{3}\;)");

100:       Match match = regex.Match(request);

101:  

102:       if (match.Success)

103:       {

104:         string[] requestQueryStrings = request.Split(';');

105:         if (requestQueryStrings.Length > 0)

106:         {

107:           int.TryParse(requestQueryStrings[0], out code);

108:  

109:           if (requestQueryStrings.Length > 1)

110:             SetCurrentLanguage(requestQueryStrings[1]);

111:           }

112:         }

113:       }

114:  

115:       return code;

116:     }

117:  

118:   /// <summary>

119:   /// Gets the current page language based on the url

120:   /// </summary>

121:   /// <param name="url">Url of current page</param>

122:   private void SetCurrentLanguage(string url)

123:   {

124:     string absoluteUrl = new Uri(url).AbsolutePath;

125:     if (string.IsNullOrWhiteSpace(absoluteUrl))

126:       return;

127:  

128:     string[] urlSegments = absoluteUrl.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);

129:     if (urlSegments.Length == 0)

130:       return;

131:  

132:     var regex2 = new Regex("([^\\s]+(?=\\.([a-zA-Z]+))\\.\\2)");

133:     Match match2 = regex2.Match(urlSegments[urlSegments.Length - 1]);

134:     if (match2.Success && !urlSegments[urlSegments.Length - 1].EndsWith(Settings.Instance.UrlRewriteExtension))

135:       return;

136:  

137:     _currentLanguage = GetLanguageFromDomainSegment(urlSegments[0]);

138:   }

139:  

140:   /// <summary>

141:   /// Returns the language to be used for the given domainSegment from EPiServerFramework.config

142:   /// </summary>

143:   /// <param name="domainSegment">Domain of the requested page</param>

144:   /// <returns></returns>

145:   private string GetLanguageFromDomainSegment(string domainSegment)

146:   {

147:     foreach (HostNameCollection siteHosts in EPiServerFrameworkSection.Instance.SiteHostMapping)

148:     {

149:       foreach (HostNameElement hostNameElement in siteHosts)

150:       {

151:         if (hostNameElement.Name.EndsWith(domainSegment, StringComparison.InvariantCultureIgnoreCase) &&

152:           hostNameElement.Name != "*")

153:           return hostNameElement.Language;

154:         }

155:       }

156:       return null;

157:     }

158: }

This code requires some explanation. When the Index() method of the ErrorHandlerController is called, the HTTP Status Code is retrieved from the request and if there exists an ErrorPage containing this status code, a Index view is returned for the ErrorHandler creating the model based on the ErrorPage. If there is no ErrorPage with the HTTP Status Code set, a static HTML file is used as fallback. This static HTML file should be located in the StatusCodes folder of your solution. So what does the ErrorPage Index view look like? It looks exactly like the View that was used for the ErrorPage that we saw previously:

1: @model PageViewModel<ErrorPage>

2:

3: @{ Layout = "~/Views/Shared/Layouts/_Root.cshtml"; }

4:  

5: <h1>@Model.CurrentPage.PageName</h1>

6: @Html.DisplayFor(m => Model.CurrentPage.MainBody)

So what’s the difference between the ErrorPage Index view and the ErrorHandler Index view? As I’ve mentioned before, the ErrorPage Index view is only seen by the editor. The ErrorHandler Index view however, is the one that will be seen by the visitor. So how does this work? How is the ErrorHandler Index method invoked? It all comes down to the <httpErrors> settings under <system.webserver> in the web.config file:

1: <httpErrors errorMode="Custom" existingResponse="Replace">

2: <remove statusCode="404" />

3: <error statusCode="404" path="/Views/ErrorHandler" responseMode="ExecuteURL" />

4: </httpErrors>

Here we register a 404 status code, setting its path to the ErrorHandler View folder. In this way, the Index method of the ErrorHandlerController will be invoked for every 404 status code. So for every status code you want to catch, you have to add it to the web.config file as we’ve done above. Last, but not least, we need to add a new Route in Global.asax.cs:

1: protected override void RegisterRoutes(RouteCollection routes)

2: {

3:    // Route to handle error pages

4:    routes.MapRoute(

5:      "ErrorHandler",

6:      "Views/ErrorHandling/ErrorHandler",

7:      new { controller = "ErrorHandler", action = "Index" });

8:  

9:    base.RegisterRoutes(routes);

10: }

And that’s it! You now have dynamic error pages with a static fallback. AND, they support globalization, so you can have separate error pages for all your languages. As a last note, in the ErrorHandlerController, when retrieving a static error page a ExtendedContentResult is returned based on a solution by Andrew Rea. ExtendedContentResult looks like this:

1: public class ExtendedContentResult : ContentResult

2: {

3:   public int StatusCode { get; set; }

4:   public NameValueCollection Headers { get; set; }

5:  

6:   public override void ExecuteResult(ControllerContext context)

7:   {

8:     context.HttpContext.Response.StatusCode = StatusCode;

9:     foreach (string s in Headers.Keys)

10:     {

11:       context.HttpContext.Response.AddHeader(s, Headers[s]);

12:     }

13:     base.ExecuteResult(context);

14:   }

15: }

Enjoy!

2 comments:

  1. Great post.
    However, in web.config the path should read "/Views/ErrorHandling/ErrorHandler"

    ReplyDelete
  2. Great work! Copy and paste :)
    Note: EPiServerFrameworkSection.Instance.SiteHostMapping is obsolete. Moved into database now

    ReplyDelete