Virtual pages in EPiServer using partial routing

As mentioned in a previous article about storing data in EPiServer’s blob storage, we had the need to create around 18k worth of virtual pages. Using the ID-slug mappings talked about in the article, we implemented a partial router.

The virtual pages are retrieved from a third party system, and their titles occasionally changes. When this happens, a new URL is created to match the new title, and all the old ones are permanently redirected (301) to the primary one.

Implementing an IPartialRouter

Below follows a slightly altered version of what we ended up with. Note that this partial router will also be used by the UrlResolver implementation when getting URLs to your pages.

Our implementation of the ISlugMappingService below isn’t important here. In short, it uses the EPiServer blob storage via a slug repository class.

So the first method, RoutePartial, is triggered when the routing mechanism detects a page of type MyPage in the URL path. It is important to know that once it does, it won’t process anymore of the URL after you’re done.

For instance, /epi-page/epi-page/my-page/virtual-slug/ will call your partial router once it hits my-page. The path /epi-page/my-page/epi-page/virtual-slug/ will halt at the my-page and expect you to manage the rest.

Our implementation is rather simple. The nextValue.Next is our generated slug. We pass it into a service to find the primary slug for what we got from the URL. This is either not found, an old legacy slug, or the proper primary one.

If we need redirecting from a legacy URL, the new target is added to the route values to be handled later. If the primary slug was used, we extract the ID value that the system can actually use to retrieve the page.

public class MyPartialRouter : IPartialRouter<MyPage, MyPage>
{
  public object RoutePartial(MyPage content, SegmentContext segmentContext)
  {
    var nextValue = segmentContext.GetNextValue(segmentContext.RemainingPath);
    var mySlug = nextValue.Next;

    var mapService = ServiceLocator.Current.GetInstance<ISlugMappingService>();
    var primaryMap = mapService.PrimaryMapFor(slug: mySlug);

    if (primaryMap == null)
    {
      // No mapping found for slug
      return content;
    }
    segmentContext.RemainingPath = nextValue.Remaining;

    if (!mySlug.Equals(primaryMap.Slug, StringComparison.InvariantCultureIgnoreCase))
    {
      // Legacy slug was used, redirect to primary
      segmentContext.RouteData.Values[Keys.RedirectSlugKey] = primaryMap.Slug;
      return content;
    }

    segmentContext.RouteData.Values[Keys.IdKey] = primaryMap.Id;
    return content;
  }

The other method that needs implementing is GetPartialVirtualPath. It works the opposite way. Here we find the ID value from the route values, and tries to find a matching slug for it.

If a mapping for the ID is not found, we assume that it is a new page. In this case we retrieve it from the third party system and generates a slug candidate (since it needs to be unique).

This method will also be used internally by the UrlResolver.

  public PartialRouteData GetPartialVirtualPath(MyPage content, string language, RouteValueDictionary routeValues, RequestContext requestContext)
  {
    if (!routeValues.TryGetValue(Keys.IdKey, out object id))
    {
      return new PartialRouteData{BasePathRoot = content.ContentLink};
    }

    var mapService = ServiceLocator.Current.GetInstance<ISlugMappingService>();

    IdSlugMap primaryMap = mapService.PrimaryMapFor(id);

    if (primaryMap == null)
    {
      // No mapping found, create new one
      var someService = ServiceLocator.Current.GetInstance<ISomeService>();
      MyObject myObject = someService.GetInfoSomewhere(id);
      string slug = myObject.GenerateSlugCandidate();
      primaryMap = slugMappingService.CreateOrUpdateSlug(id, slug);
    }

    // We no longer need the id parameter on the URL.
    routeValues.Remove(Keys.HsaIdKey);

    var data = new PartialRouteData
    {
      BasePathRoot = content.ContentLink,
      PartialVirtualPath = primaryMap.Slug
    };
    return data;
  }
}

We create a PartialRouteData with the EPiServer page instance (MyPage instance) as base path, and our virtual slug as PartialVirtualPath. This will allow EPiServer’s code to know what to do.

Redirecting to your virtual pages

Getting a RedirectAction for your virtual pages may be done in a fashion as below. Then use it early in your request management.

public class MyPageRedirection
{
  public virtual ActionResult GetRedirectAction(ControllerContext context)
  {
    var hasQueryId = context.RequestContext.HttpContext
         .Request.QueryString.TryGetValue(Keys.IdKey, out string queryId);

    var values = context.RouteData.Values;
    var hasRoutedId = values.ContainsKey(Keys.IdKey);
    var hasRedirectSlug = values.TryGetValue(Keys.RedirectSlugKey, out object redirectSlug);

    if (!hasRedirectSlug && !hasQueryId && !hasRoutedId)
    {
      throw new HttpException(404, "MyPage not found");
    }

    if (hasRedirectSlug)
    {
      return RedirectFromLegacyToPrimaryUrl(redirectSlug as string);
    }

    if (hasQueryId)
    {
      return RedirectFromIdToFriendlyUrl(queryId);
    }
    return null;
  }

  private static ActionResult RedirectFromLegacyToPrimaryUrl(string redirectSlug)
  {
    // ...
  }

  private static ActionResult RedirectFromIdToFriendlyUrl(string id)
  {
    var pageRouteHelper = ServiceLocator.Current.GetInstance<IPageRouteHelper>();
    var myPage = (MyPage) pageRouteHelper.Page;
    string url = myPage.FriendlyUrlTo(id);
    return new RedirectResult(url, permanent: true);
    }
  }

I won’t go in to great detail about the above code, it’s mainy to give an idea of what may need implementing. The hardest part is to keep track of the various ways of passing data as not to create infinite redirect loops. It gets more interesting once you start involving attribute routing.

Registering an EPiServer partial router

For the custom partial routing to take effect, you will need to register it in the RouteCollection. It may be done as below.

public void RegisterPartialRouters(RouteCollection routes)
{
  routes.RegisterPartialRouter<MyPage, MyPage>(new MyPartialRouter());
}

Automatically redirecting to your virtual pages

Overriding the method OnActionExecuting allows you to handle your redirects relatively easy.

protected override void OnActionExecuting(ActionExecutingContext filterContext)
{
  if (HandleRedirect(filterContext))
  {
    return;
  }
  base.OnActionExecuting(filterContext);
}

Just change the ActionExecutingContext’s Result property to your redirection result if the request is supposed to be redirected.

protected virtual bool HandleRedirect(ActionExecutingContext filterContext)
{
  ActionResult result = new MyPageRedirection().GetRedirectAction(filterContext);
  if (result == null)
  {
    return false;
  }

  filterContext.Result = result;
  return true;
}