Two real-world examples of how to customize the routing for EPiServer pages to take control of the site's URLs and links.
EPiServer 7 CMS uses the built in routing functionality in ASP.NET for URL handling. The default routing when using EPiServer 7 mimics the friendly URL functionality in older versions of the CMS in which the URL for a page is built up of it's URLSegment property prefixed by it's ancestors URL segments.
There are many nice aspects of the default behavior. For instance, URL's for pages match their places in the page tree and in navigation elements making them seem logical and predictable. However, it also has the drawback of tying the URL for a page to it's location in the page tree making it risky to move content.
Also, in some situations, such as when you have a site with a lot of content, it may not be appropriate to place content in a specific place in the content tree even though it belongs there.
It may also be that a page is only used as a data bearer without a template and links to it should lead to some other page, optionally with some parameters in the URL.
In such cases we need to extend or modify EPiServer's default routing. In this article I'll show two real-world examples of doing just that.
Custom routing using partial routing
The first example is drawn from how articles are routed on this site. The article you're currently reading is tied to the EPiServer CMS category (a page) on the site which is reflected in navigation elements.
It does not however reside under the CMS category's page. Instead it's placed in a hierarchical structure based on the date it was created.
Why articles is stored this way is beyond the scope of this article but, as you can probably imagine, storing them this way brings a few problems.
Making the article belong to the CMS category is straight forward. All that's needed is a property of type ContentReference or PageReference on the page type used for articles. That can then be used when building menus and breadcrumbs.
Listing content based on categories is a bit trickier to achieve performance wise with the CMS' API but in my case I have Find to help me with that.
Finally, the URL for an article will be /<year>/<month>/<URLSegment>/. While that's not terrible it's not what I want. Instead I wanted the URL's for articles to be simply the host name followed by their URL segments.
Partial routing
EPiServer 7 features a concept called partial routing. In the developer guide it's described as
Partial routing makes it possible to extend routing beyond pages. You can use partial routing either to route to data outside EPiServer CMS or to route to other content types than pages.
We can however also use it to route ordinary pages, like articles in this case.
To utilize partial routing we create a class implementing IPartialRouter<TContent, TRoutedData> found in the EPiServer.Web.Routing namespace. The first type parameter specifies the type of content we're interested in routing children for. The second the type of content, or other type of object that we'll be routing.
Did I lose you there? Perhaps a fruity example can clarify things.
A class that implements IPartialRouter<Banana, Apple> will be able to route outgoing URLs whenever the object to route is an apple. Whenever a part of the URL for an incoming request has been routed to a content object of type Banana and there are still parts of the URL left to route it will be handed the banana and information about the remaining URL and asked to return an apple.
Crystal clear, right? Perhaps not. Let's look at how we can use it to route articles.
Implementing IPartialRouter
Given that we want articles to have relative URLs that are simply their URL segments the first type parameter when implementing IPartialRouter is the page type class that is used for the start page. The second type parameter is the page type used for articles.
Let's create a class that implements IPartialRouter with those type parameters and have Visual Studio generate the required methods for us.
using System.Web.Routing;
using EPiServer.Web.Routing;
using EPiServer.Web.Routing.Segments;
using MySite.Models.Pages;
namespace MySite.Routing
{
public class ArticleRouter : IPartialRouter<StartPage, ArticlePage>
{
public object RoutePartial(StartPage content, SegmentContext segmentContext)
{
throw new System.NotImplementedException();
}
public PartialRouteData GetPartialVirtualPath(
ArticlePage content,
string language,
RouteValueDictionary routeValues,
RequestContext requestContext)
{
throw new System.NotImplementedException();
}
}
}
As you can see, there are two methods that we'll need to implement. RoutePartial is for incoming requests while GetPartialVirtualPath is for outgoing.
We'll start by implementing the RoutePartial method.
RoutePartial
The RoutePartial method will be invoked whenever a the first parts of the URL points to a page of the TContent type parameter and there are remaining parts of the URL left to route. In this example, as we're implementing the IPartialRouter interface with the start page's type as the TContent parameter that will pretty much always be the case.
The method is invoked with two arguments. The first is the content of type TContent that the first parts of the URL route to, the start page in our case. The second is an object of type SegmentContext which contains information about the next segment in the URL to route.
Using the two arguments it's our job to figure out what article, if any, the request should be routed to.
public object RoutePartial(StartPage content, SegmentContext segmentContext)
{
if (!content.ContentLink.CompareToIgnoreWorkID(ContentReference.StartPage))
{
return null;
}
var nextSegment = segmentContext.GetNextValue(segmentContext.RemainingPath);
var urlSegment = nextSegment.Next;
if (string.IsNullOrEmpty(urlSegment))
{
return null;
}
ArticlePage article = null;
//TODO: Figure out which article it is we're routing based on the urlSegment variable, if any, and populate the article variable with that.
if (article != null)
{
segmentContext.RemainingPath = nextSegment.Remaining;
segmentContext.RoutedContentLink = article.ContentLink;
}
return article;
}
There's quite a few things going on in the above code.
First of all it verifies that the root content is indeed an object for which we want to route remaning parts of the URL. In other words, if we for some reason are dealing with a page of the StartPage type that is not the start page. Not likely when dealing with start pages perhaps, but stranger things have happened and this check may be vital in other scenarios.
Next we proceed to retrieve the next part of the URL which we assign to the urlSegment variable. For an URL such as /hello-world/ the urlSegment variable's value will be "hello-world". For an URL such as /hello/world/ the urlSegment variable's value will be "hello".
Given that there is indeed at least one more segment in the URL we proceed to locate an article whose URL segment matches that. As that's code specific to the site I've omitted my implementation.
How one would do this can vary greatly from site to site. On a small site we may simply retrieve all articles and use LINQ's Where method to find an article with a matching URLSegment property. On a larger site we may use EPiServer Find or we may make the page's ID a part of the URL when implementing outgoing routing which we'll get to shortly.
However we choose to locate articles, or whatever it it we're routing, we need to make sure to do it in a good way performance wise. This code will be called a lot!
Given that we've found an article that we wish to route to we remove the URL segment that we've taken care of from the SegmentContext by assigning the remaning part of the URL to its RemainingPath property. That is, given an article with a URL such as /hello-world/ where we have extracted "hello-world" from the segment context we update it to set its remaining path to nothing.
Also, given that we've found an article, we modify the segment context so that it knows that we've found content that we want to route to.
Finally we return the article, or null, if we didn't find one. If we return null nothing in particular will happen. The routing will proceed as usual routing to a page of some other type or resulting in a 404.
GetPartialVirtualPath
The GetPartialVirtualPath method will be invoked when an article is being outgoing routed. That is, when we for instance create a link to an article with the PageLink HTML helper method.
It's invoked with four arguments which we can use to determine the context in which the article is being routed. Based on those we need to return an object of type PartialRouteData.
The PartialRouteData class has two properties - BasePathRoot and PartialVirtualPath. The generated relative URL will be a combination of the relative URL of the content referenced by BasePathRoot and whatever we set PartialVirtualPath to.
public PartialRouteData GetPartialVirtualPath(
ArticlePage content,
string language,
RouteValueDictionary routeValues,
RequestContext requestContext)
{
var contentLink = ContentRoute.GetValue("node", requestContext, routeValues)
as ContentReference;
if (!content.ContentLink.CompareToIgnoreWorkID(contentLink))
{
return null;
} if (PageEditing.PageIsInEditMode)
{
return null;
}
return new PartialRouteData
{
BasePathRoot = ContentReference.StartPage,
PartialVirtualPath = content.URLSegment
};
}
In the implementation above we begin by extracting a reference to the content that is being routed using the ContentRoute.GetValue method. Typically this will be a reference to the same article that is being passed to the method as the "content" parameter. However, I've found at least one situation when that wasn't the case.
If the method is invoked in context of routing something else than the article passed in as the "content" variable we're not interested in modifying the routing so we simply return null.
Likewise, if the request is in context of EPiServer's edit mode we return null as we don't have any reason to modify the routing then. That is, we don't care what the URL is when the page is loaded into the CMS's IFrame and if we modify it things may not work as expected.
If however we are dealing with an article that we want to modify the routing for we create a new PartialRouteData object, populate its properties and return it.
Since in this example we want articles to simply have their own URL segments as URLs we set the BasePathRoot to a reference to the start page and the PartialVirtualPath to the article's URLSegment property.
Using the partial router
Our partial router class is done. In order for it to be used we need to add it to the site's route table during start-up though. That's easily done using an initialization module and the RegisterPartialRouter extension method that EPiServer provides (in the EPiServer.Web.Routing namespace).
using System.Web.Routing;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.Web.Routing;
namespace MySite.Routing
{
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class RouteInitialization : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
var articleRouter = new ArticleRouter();
RouteTable.Routes.RegisterPartialRouter(articleRouter);
}
public void Uninitialize(InitializationEngine context)
{
}
public void Preload(string[] parameters)
{
}
}
}
Restoring tree-like URLs
In the above example we modify EPiServer's default routing to make pages of a specific type, articles, have URLs that are simply their URL segments. That's what I wanted for this site as it decouples article's URLs from both their place in the tree and their categories.
However, we're not limited to that specific use case. For instance we may instead want articles in the example scenario we've looked at here to have a URL based on their categories instead. As if they were actually placed under their categories in the page tree.
To accomplish that we would need to make a few modifications to our partial router class.
Since the "URL homes", the base path, for articles won't be the start page but instead a categories the first step is to modify the TContent type parameter when implementing IPartialRouter. Instead of the start page's type we set it to the type under which articles will reside URL-wise. In the case of this site that would be the class CategoryPage.
public class ArticleRouter : IPartialRouter<CategoryPage, ArticlePage>
Changing the type parameter of the implemented interface we need to change the type of the RoutePartial method's first parameter or the compiler will complain.
public object RoutePartial(CategoryPage content, SegmentContext segmentContext)
Now the compiler is happy but we'll need to make some changes to the code as well. Let's start with the GetPartialVirtualPath method as that's easiest.
All we need to do there is change what we set the BasePathRoot property on the object we return to. Instead of setting it to a reference to the start page we set it to a reference to some other page that will provide the first part of the article's URL. For our example scenario that would be the property that points to the article's main category.
return new PartialRouteData
{
BasePathRoot = content.MainCategory,
PartialVirtualPath = content.URLSegment
};
The RoutePartial method will need to undergo larger changes.
We can skip the initial check that the root content which is passed in in as the "content" variable is indeed the root page we care about as we now have multiple possible roots.
Instead we'll need to verify that the root, the category, should be used as the routing parent for the article that we're routing.
ArticlePage article = null;
//TODO: Figure out which article it is we're routing based on the urlSegment variable, if any, and populate the article variable with that.
if (article == null
|| content.ContentLink.CompareToIgnoreWorkID(article.MainCategory))
{
return null;
}
segmentContext.RemainingPath = nextSegment.Remaining;
segmentContext.RoutedContentLink = article.ContentLink;
return article;
Custom routing using a custom segment
We've now seen an example of how we can use the partial routing concept in EPiServer 7 to implement custom routing. In the next example we'll look at another way of customizing routing - by using a custom implementation of the ISegment interface.
On this site I use pages to define tags. That is, each tag is a page somewhere in the page tree and articles have a content area to which I can add one or more tag pages. This allows me to change the name of a tag without having to update all pages tagged with it. It also gives me the possibility to add unique content to a tag beyond its name.
When I tag a page with one or more existing tags I simply drag them to the content area. When I render a list of tags I simply output a link to each tag using the PageLink HTML helper.
As I have a separate template for tag pages this works great. However, when the site first went live I didn't have that template. Instead I wanted a tag-link to point to a search for the tag using the site's search page.
One way to solve that would of course have been to find all places where links to tags were rendered and modify the code there to link to the search page. That wouldn't have been a very flexible solution though. By instead modifying the outgoing URLs for tag pages by modifying routing for them I didn't have to touch any other code.
At first glance it may seem like we could use partial routing for this scenario as well but a case like this, where we want to route to a specific page with one or more query string parameters isn't really what partial routing is for.
Instead we can handle cases like this by creating a custom implementation of the ISegment interface and register a route that uses the segment.
Creating a segment
In order to create a custom segment we create a class that implements ISegment (in the EPiServer.Web.Routing.Segments namespace). Alternatively we can let our class inherit SegmentBase which I'll do in this example.
using System;
using System.Web.Routing;
using EPiServer.Web.Routing.Segments;
namespace MySite.Routing
{
public class TagSearchSegment : SegmentBase
{
public TagSearchSegment(string name) : base(name)
{
}
public override bool RouteDataMatch(SegmentContext context)
{
throw new NotImplementedException();
}
public override string GetVirtualPathSegment(
RequestContext requestContext,
RouteValueDictionary values)
{
throw new NotImplementedException();
}
}
}
As you can see from the code above, where I've had Visual Studio autogenerate required methods for me, SegmentBase has two abstract methods that we must implement - RouteDataMatch and GetVirtualPathSegment.
RouteDataMatch is invoked during in-bound routing. We get passed information about the current context and can use that to determine if we want to modify the routing. If we do we modify the context in some way and return true. However, as we only care about out-bound routing in this example we can simply implement it to return false.
public override bool RouteDataMatch(SegmentContext context)
{
return false;
}
GetVirtualPathSegment
The GetVirtualPathSegment method is invoked during outgoing routing and in order to modify the URL we'll need to return a string with the URL, or part of the URL, that we want to route to. We'll also need to remove values that we feel we've taken care of from the RouteValueDictionary that is passed to the method.
public override string GetVirtualPathSegment(
RequestContext requestContext,
RouteValueDictionary values)
{
if (GetContextMode(requestContext.HttpContext, requestContext.RouteData)
!= ContextMode.Default)
{
return null;
}
var contentLink = ContentRoute.GetValue("node", requestContext, values)
as ContentReference;
if (ContentReference.IsNullOrEmpty(contentLink))
{
return null;
}
var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
var tagToRoute = contentLoader.Get<IContent>(contentLink) as TagPage;
if (tagToRoute == null)
{
return null;
}
string tagSearchUrl = null;
//TODO: Figure out the url we want to route to, if any, and set the tagSearchUrl variable to that
if (tagSearchUrl == null)
{
return null;
}
values.Remove("node");
values.Remove("controller");
values.Remove("action");
values.Remove("routedData");
return tagSearchUrl;
}
That's quite a mouthful! Let's go through it step by step.
First we check that we're not in edit mode using a helper method. This is necessary as we will otherwise get a 404, or worse, when viewing tag pages in edit mode as our code will route to the search page while EPiServer will add the tag's ID to the URL, meaning that we'll end up viewing invoking the search page's template with the tag page as the current page.
The GetContextMode method looks like this:
private static ContextMode GetContextMode(
HttpContextBase httpContext,
RouteData routeData)
{
var contextModeKey = "contextmode";
if (routeData.DataTokens.ContainsKey(contextModeKey))
{
return (ContextMode)routeData.DataTokens[contextModeKey];
}
if ((httpContext == null) || (httpContext.Request == null))
{
return ContextMode.Default;
}
if (!PageEditing.GetPageIsInEditMode(httpContext))
{
return ContextMode.Default;
}
return ContextMode.Edit;
}
Next we proceed to extract a reference to the content that is being routed. If we can't find such a reference we're probably routing a file or something else that we don't care about so we return null.
Given that we have a content reference we proceed to fetch the content that we're routing. If it isn't a tag we don't care about it so we return null.
If however the content that we're routing is a tag we build up the URL we want to route to. I've omitted that code as it may be specific to each site and use case.
Given that we have a URL that we want to route to we return it, but before doing so we remove route values that we don't want anyone else who cares about routing to see as we've already taken care of them.
Using the segment
With the custom segment done we need to register a route that uses it for it to matter. As this should be done during start-up of the site we add an initialization module.
using System.Collections.Generic;
using System.Web.Routing;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.Web.Routing;
using EPiServer.Web.Routing.Segments;
namespace MySite.Routing
{
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class RouteInitialization : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
var segment = new TagSearchSegment("tag");
var routingParameters = new MapContentRouteParameters()
{
SegmentMappings = new Dictionary<string, ISegment>()
};
routingParameters.SegmentMappings.Add("tag", segment);
RouteTable.Routes.MapContentRoute(
name: "tags",
url: "{language}/{tag}",
defaults: new { action = "index" },
parameters: routingParameters);
}
public void Uninitialize(InitializationEngine context)
{
}
public void Preload(string[] parameters)
{
}
}
}
Using the code in the Initialize method above we register a route for "{language}/{tag}". We map the {tag} part to our custom segment meaning that it will be replaced with whatever we return from the segment's GetVirtualPathSegment method.
Basic routing
We've just seen two fairly advanced examples of how the routing for content can be customized when using EPiServer 7. In both cases we're more or less in complete control and can execute custom logic on a per-request basis.
That's nice, but there may be cases where we want to do something simpler. Were we don't need all that power and flexibility. For instance, let's say we for some reason wanted to support outputting only the first n characters of the name of a page given that the request is for a page followed by /name/<n>/.
Not the most realistic scenario perhaps, but it does make for a simple example. Anyhow, To handle such a case we wouldn't have to create a partial router or a custom segment. All we need to do is register a route using the MapContentRoute method during start-up.
RouteTable.Routes.MapContentRoute(
"myRoute", "{language}/{node}/{action}/{charcount}",
new {action = "name"});
Now, given that we're using MVC, we can add an action to controllers for pages named Name which will be used for matching requests.
public ActionResult Name(CategoryPage currentPage, int charCount)
{
if (currentPage.PageName.Length < charCount)
{
charCount = currentPage.PageName.Length;
}
return new ContentResult()
{
Content = currentPage.PageName.Substring(0, charCount)
};
}
For another example of this type of custom routing check the routing section in EPiServer's developers guide.
Conclusion
We've seen a couple of examples of how we can take charge of the routing for specific types of content. With this in our toolbox we can free the URLs for pages from the content tree, change where pages links to as well as other interesting stuff.
A couple of words of warning is in place though.
First of all, when modifying both the outbound and inbound routing as in the first example, the one with articles, we're bypassing EPiServer's functionality for ensuring that no two pages can have the same URL. That is, in the example with the articles we'll need to ensure that no two articles have the same URL segment.
Second, do think about that the methods for routing, such as the ones we've looked at in this article may be invoked a lot, so be sure to test the performance with realistic data before deploying to production.
With that said, I must say that it's nice that we can control the routing in just about any way we'd like. That brings many interesting possibilities.
Happy routing!
PS. For updates about new posts, sites I find useful and the occasional rant you can follow me on Twitter. You are also most welcome to subscribe to the RSS-feed.
My book
Want a structured way to learn EPiServer 7 development? Check out my book on Leanpub!
Comments
comments powered by Disqus