Episerver CMS and Commerce custom routing
Description
We needed to implement and Episerver Website that could be customized for multiple stakeholders. Each stakeholder has his own Personal Website URL, containing a Site Name segment. Because the number of the stakeholders was more than 10000, in order to reuse the Episerver content and do not create personalized content for each stakeholder, we decided to customize Episerver routing for both CMS and Commerce content.
Example
Consider that you have the next page tree in Episerver CMS for your stakeholders’ pages:
Please see the next table for exemplifying how Episerver does the default routing and how our custom routing needs to work. The same page should be retrieved for each stakeholder, but with that stakeholder segment in the URL.
Page Name |
Episerver Default URL |
Custom Routing URLs |
Stakeholder Home |
http://quicksilver.localtest.me/en/stakeholder-home/ |
http://quicksilver.localtest.me/en/stakeholder1/
http://quicksilver.localtest.me/en/stakeholder2/ |
Become A Stakeholder |
http://quicksilver.localtest.me/en/stakeholder-home/become-a-stakeholder/ |
http://quicksilver.localtest.me/en/stakeholder1/become-a-stakeholder/
http://quicksilver.localtest.me/en/stakeholder2/become-a-stakeholder/ |
About Me |
http://quicksilver.localtest.me/en/stakeholder-home/about-me/ |
http://quicksilver.localtest.me/en/ stakeholder1/about-me/
http://quicksilver.localtest.me/en/ stakeholder2/about-me/ |
Shop Now |
http://quicksilver.localtest.me/en/stakeholder-home/shop-now/ |
http://quicksilver.localtest.me/en/ stakeholder1/shop-now/
http://quicksilver.localtest.me/en/ stakeholder2/shop-now/ |
Shopping Cart |
http://quicksilver.localtest.me/en/stakeholder-home/shopping-cart/ |
http://quicksilver.localtest.me/en/ stakeholder1/shopping-cart/
http://quicksilver.localtest.me/en/ stakeholder2/shopping-cart/ |
Checkout |
http://quicksilver.localtest.me/en/stakeholder-home/checkout/ |
http://quicksilver.localtest.me/en/ stakeholder1/checkout/
http://quicksilver.localtest.me/en/ stakeholder2/checkout/ |
For Commerce, the routing is different, because we need to insert the stakeholder segment instead of replacing an existing one, as it is for CMS. Please see below some example of routing for several Catalog items URLs:
Catalog Content Type |
Episerver Default URL |
Custom Routing URLs |
Category |
http://quicksilver.localtest.me/en/fashion/mens/ |
http://quicksilver.localtest.me/en/stakeholder1/fashion/mens/
http://quicksilver.localtest.me/en/stakeholder2/fashion/mens/ |
Subcategory |
http://quicksilver.localtest.me/en/fashion/mens/mens-shoes/ |
http://quicksilver.localtest.me/en/stakeholder1/fashion/mens/mens-shoes/ |
Product |
http://quicksilver.localtest.me/en/fashion/mens/mens-shoes/p-36127195/ |
http://quicksilver.localtest.me/en/stakeholder1/fashion/mens/mens-shoes/p-36127195/ |
Implementation
In this section, I will describe the approach that we choose and implementation details for both CMS and Commerce content custom routing.
- CMS Content custom routing
In order to customize default Episerver content routing we need to implement a custom partial router, which implements the public interface IPartialRouter<TContent, TRoutedData>. Please see the next references for details about the IPartialRouter interface and its methods: IPartialRouter Interface, RoutePartial Method and GetPartialVirtualPath Method.
1. /* This custom routing code will be called just for the pages 2. which are under StartPage into the CMS page tree 3. and have page type StakeholderBasePage*/ 4. public class CustomCmsContentPartialRouter : IPartialRouter<StartPage, StakeholderBasePage> 5. { 6. private readonly IContentLoader _contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>(); 7. 8. public object RoutePartial(StartPage content, SegmentContext segmentContext) 9. { 10. string stakeholderSiteName; 11. // All the stakeholder pages will inherite from the same base page 12. StakeholderBasePage stakeholderPage = null; 13. var contentUrl = CustomRoutingHelper.GetStakeholderContentUrl(segmentContext, out stakeholderSiteName); 14. var contentReference = CustomRoutingHelper.TryGetContentRefrence(contentUrl); 15. 16. if (contentReference != null) 17. { 18. if (CustomRoutingHelper.CheckIfCustomSiteName(segmentContext, stakeholderSiteName)) 19. { 20. stakeholderPage = _contentLoader.Get<StakeholderBasePage>(contentReference.ContentLink); 21. 22. if (stakeholderPage != null) 23. { 24. segmentContext.RemainingPath = string.Empty; 25. segmentContext.RoutedContentLink = stakeholderPage.ContentLink; 26. } 27. } 28. } 29. 30. return stakeholderPage; 31. } 32. 33. public PartialRouteData GetPartialVirtualPath(JewelerBasePage content, string language, RouteValueDictionary routeValues, 34. RequestContext requestContext) 35. { 36. string urlSegment; 37. string stakeholderSiteName; 38. 39. if (CustomRoutingHelper.CheckIfCustomSiteName(requestContext, out stakeholderSiteName)) 40. { 41. urlSegment = GetRoutedContentLink(content, stakeholderSiteName); 42. } 43. else 44. { 45. return null; 46. } 47. 48. return new PartialRouteData 49. { 50. BasePathRoot = ContentReference.StartPage, 51. PartialVirtualPath = urlSegment 52. }; 53. } 54. 55. private string GetRoutedContentLink(StakeholderBasePage content, string stakeholderSiteName) 56. { 57. /* Stop iteration if content type is StakeholderStartPage. 58. All pages with type StakeholderBasePage should be under 59. StakeholderStartPage into CMS page tree */ 60. if (content is StakeholderStartPage) 61. return stakeholderSiteName; 62. 63. var parentContent = _contentLoader.Get<StakeholderBasePage>(content.ParentLink); 64. 65. return GetRoutedContentLink(parentContent, stakeholderSiteName) + "/" + content.URLSegment; 66. } 67. }
- Commerce/Catalog Content custom routing
In order to customize default Episerver content routing we need to implement a custom partial router, which extends the public class HierarchicalCatalogPartialRouter. Please see the next references for details about the HierarchicalCatalogPartialRouter class and the overrode methods: HierarchicalCatalogPartialRouter Class, FindNextContentInSegmentPair Method and GetPartialVirtualPath Method.
1. public class CustomCommerceCatalogRouter : HierarchicalCatalogPartialRouter 2. { 3. public CustomCommerceCatalogRouter(CatalogContentBase commerceRoot, Func<ContentReference> func) 4. : base(func, commerceRoot, false) { } 5. 6. protected override CatalogContentBase FindNextContentInSegmentPair(CatalogContentBase catalogContent, SegmentPair segmentPair, SegmentContext segmentContext, CultureInfo cultureInfo) 7. { 8. 9. if (!CustomRoutingHelper.CheckIfCatalogContentUrl(segmentContext.RequestUrl.ToString())) 10. return null; 11. 12. CustomRoutingHelper.SkipCustomSiteNameSegment(segmentPair, segmentContext.RequestUrl.ToString()); 13. var currentCatalog = base.FindNextContentInSegmentPair(catalogContent, segmentPair, segmentContext, cultureInfo); 14. 15. return currentCatalog; 16. } 17. 18. public override PartialRouteData GetPartialVirtualPath(CatalogContentBase content, string language, RouteValueDictionary routeValues, 19. RequestContext requestContext) 20. { 21. string stakeholderSiteName; 22. 23. var partialRouteData = base.GetPartialVirtualPath(content, language, routeValues, requestContext); 24. 25. if (CustomRoutingHelper.CheckIfCustomSiteName(requestContext, out stakeholderSiteName)) 26. { 27. partialRouteData.PartialVirtualPath = stakeholderSiteName + "/" + partialRouteData.PartialVirtualPath; 28. } 29. 30. return partialRouteData; 31. } 32. }
- Common methods from Helpers
CustomRoutingHelper.GetStakeholderContentUrl method will return the Episerver Content URL. This method will replace stakeholder site name with stakeholder Episerver home page URL, in return in the stakeholderSiteName output parameter the stakeholder custom site name.
E.g.: For http://website/stakeholder-sitename the method will return http://website/stakeholder-home and stakeholderSiteName = "stakeholder-sitename".
Please see below the CustomRoutingHelper class:
1. public class CustomRoutingHelper 2. { 3. public static string GetStakeholderContentUrl(SegmentContext segmentContext, out string stakeholderSiteName) 4. { 5. var nextSegment = segmentContext.GetNextValue(segmentContext.RemainingPath); 6. 7. if (CultureHelper.IsAvailableCulture(nextSegment.Next)) 8. { 9. nextSegment = segmentContext.GetNextValue(nextSegment.Remaining); 10. } 11. 12. stakeholderSiteName = nextSegment.Next; 13. var regex = new Regex(Regex.Escape(nextSegment.Next)); 14. // replace custom site name with CMS stakeholder HomePage/StartPage URL 15. return regex.Replace(segmentContext.RequestUrl.AbsolutePath, "stakeholder-home", 1); 16. } 17. 18. // check if request path cotains a valid stakeholder custom site name 19. public static bool CheckIfCustomSiteName(RequestContext requestContext, out string stakeholderSiteName) 20. { 21. stakeholderSiteName = null; 22. 23. if (requestContext.HttpContext == null) 24. return false; 25. 26. // ajax requests got stakeholder ID in request headers 27. if (requestContext.HttpContext.Request.IsAjaxRequest()) 28. return false; 29. 30. var requestPath = requestContext.HttpContext.Request.Path; 31. var segments = requestPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); 32. 33. if (segments.Length > 0) 34. { 35. if (CultureHelper.IsAvailableCulture(segments[0]) && segments.Length > 1) 36. { 37. stakeholderSiteName = segments[1]; 38. } 39. else 40. { 41. stakeholderSiteName = segments[0]; 42. } 43. 44. return CheckIfStakeholderSiteName(stakeholderSiteName, requestPath); 45. } 46. 47. return false; 48. } 49. 50. public static bool CheckIfCustomSiteName(SegmentContext segmentContext, string stakeholderSiteName) 51. { 52. return CheckIfStakeholderSiteName(stakeholderSiteName, segmentContext.RequestUrl.ToString()); 53. } 54. 55. public static void SkipCustomSiteNameSegment(SegmentPair segmentPair, string requestUrl) 56. { 57. var remainingSegments = segmentPair.Remaining.Split('/'); 58. 59. if (remainingSegments.Length <= 0 60. ||!remainingSegments[0].Equals("DefaultCatalogName", StringComparison.OrdinalIgnoreCase) 61. || !CheckIfStakeholderSiteName(segmentPair.Next, requestUrl)) 62. return; 63. 64. segmentPair.Next = remainingSegments[0]; 65. segmentPair.Remaining = segmentPair.Remaining.Remove(0, remainingSegments[0].Length + 1); 66. } 67. 68. // check if the segment is a valid stakeholder site name 69. public static bool CheckIfStakeholderSiteName(string segment, string requestUrl) 70. { 71. if (segment.IsNullOrEmpty()) return false; 72. 73. var key = new Tuple<string, string>(segment.ToLower(), requestUrl); 74. 75. // avoid extra checks for already checked segments 76. if (StakeholderSiteNames.ContainsKey(key)) 77. return StakeholderSiteNames[key]; 78. 79. // check for some specific routes to avoid extra call to the database 80. if (segment.Equals("episerver", StringComparison.OrdinalIgnoreCase) 81. || segment.Equals("stakeholder-home", StringComparison.OrdinalIgnoreCase) 82. || segment.Equals("DefaultCatalogName", StringComparison.OrdinalIgnoreCase)) 83. return false; 84. 85. bool exists = //check if there is a custom sitename in your system equal with the segment 86. 87. StakeholderSiteNames.Add(key, exists); 88. 89. return exists; 90. } 91. 92. // This dictionary help us to avoid extra checks in the same request 93. private static Dictionary<Tuple<string, string>, bool> StakeholderSiteNames 94. { 95. get 96. { 97. if (HttpContext.Current.Items["StakeholderSiteNames"] == null) 98. HttpContext.Current.Items["StakeholderSiteNames"] = new Dictionary<Tuple<string, string>, bool>(); 99. return HttpContext.Current.Items["StakeholderSiteNames"] as Dictionary<Tuple<string, string>, bool>; 100. } 101. } 102. 103. public static bool CheckIfCatalogContentUrl(string requestUrl) 104. { 105. return requestUrl.Contains("DefaultCatalogName"); 106. } 107. 108. public static IContent TryGetContentRefrence(string url) 109. { 110. try 111. { 112. return UrlResolver.Current.Route(new UrlBuilder(url)); 113. } 114. catch 115. { 116. return null; 117. } 118. } 119. }
Please see below the CultureHelper class:1. public class CultureHelper 2. { 3. public static bool IsAvailableCulture(string cultureCode) 4. { 5. var availableLanguages = GetAvailableLanguages(); 6. 7. return availableLanguages.Contains(cultureCode); 8. } 9. 10. private static IList<string> GetAvailableLanguages() 11. { 12. var languageBranchRepository = ServiceLocator.Current.GetInstance<ILanguageBranchRepository>(); 13. var availableLanguages = languageBranchRepository.ListEnabled(); 14. 15. if (availableLanguages != null && availableLanguages.Any()) 16. return availableLanguages.Select(x => x.URLSegment).ToList(); 17. 18. return new List<string>(); 19. } 20. }
Nice - I haven't jumped into detail, but I appreciate the time and effort you put into write such a long, well formatted post. Keep it up :)