Introduction
I'm a firm believer in avoiding querystring parameters in URLs, especially for public web sites. Security, SEO and esthetics are a few reasons why. Custom URL rewriting can help you avoid those ugly ampersands and question marks, even in cases where you want to use querystring parameters. Internally that is. You'll see what I'm talking about in a little bit.
Friendly URLs in EPiServer are normally composed by traversing the page tree and concatenating the URL segment of each page. So, here is our first challenge: friendly URLs in EPiServer are limited to pages that exist in the EPiServer page tree. By default, that is.
This article will demonstrate how to implement a custom URL rewrite provider for EPiServer to use friendly URLs for "virtual" pages that do not exist in the EPiServer database.
Note that this has nothing to do with the Page Provider functionality (the functionality previously known as Custom Page Store, formerly known as Content Channel) available in EPiServer CMS 5 R2 Enterprise (scheduled for release fall 2008).
The concept of URL rewriting in EPiServer
URL rewriting in EPiServer is carried out by two components working together; a UrlRewriteProvider and a URLRewriteModule. The URLRewriteModule is used to parse HTML in both directions to ensure that all internal URLs are converted to external (friendly) URLs before being sent to the browser. It also parses all incoming friendly URLs to replace them with their internal URL representations.
Note, however, that the URLRewriteModule is only responsible for parsing HTML - the actual rewriting is carried out by the URLRewriteProvider. The URLRewriteModule (an HTTP module) parses HTML to identify URLs that should be rewritten. It then calls the URLRewriteProvider which performs the actual rewriting.
More information about extending URL rewriting can be found in the EPiServer SDK.
Approaches to implementing custom URL rewriting in EPiServer
The EPiServer SDK contains an article which explains URL rewriting in EPiServer, and how to customize it. However, I found it is not quite as straightforward as the article makes it out to be.
There are three main approaches to overriding the default URL rewriting behavior in EPiServer:
- Subscribing to events to customize the process
- Inheriting from URLRewriteModule and/or URLRewriteProvider to override selected parts (this is what I do)
- Fully independent implementation of the URLRewriteModule and/or URLRewriteProvider
The scenario for this demonstration
Here's what I want to accomplish:
I have a page (web form) called UserInformation.aspx. This page can be accessed with a user querystring parameter to display user account details for a specific user. For example, UserInformation.aspx?user=2 would display user details for the user account with ID = 2.
I have created an Account details page type which maps to the UserInformation.aspx web form.
Now, I don't want links on my website with URLs like "http://www.mysite.com/en/Users/Account-Information/?id=2. It's just plain ugly. Instead, I want a neat and clean URL like "http://www.mysite.com/en/Account-Information/Ted-Nyberg/" (or whatever the user's name is).
So, how do we accomplish this without creating an actual EPiServer page for each user? (Note: rhetorical question, but feel free to stop reading if you didn't think "Custom URL Rewriting!" right away.)
Ted's Step-by-Step Guide to Implementing Custom URL Rewriting in EPiServer
Catchy title, huh? Ok, here it goes:
Step 1 - inheriting from FriendlyUrlRewriteProvider
Create a new class and inherit from FriendlyUrlRewriteProvider (the FriendlyUrlRewriteProvider class is used to provide the default friendly URL rewriting in EPiServer):
public class MyUrlRewriter : EPiServer.Web.FriendlyUrlRewriteProvider
Next, modify your web.config file to make use of our new URL rewrite provider, even though it won't add much value - yet. Since we inherit from the original FriendlyUrlRewriteProvider we can simply remove it from the <providers> element.
<urlRewrite defaultProvider="MyUrlRewriter">
<providers>
<add
name="MyUrlRewriter"
enableSimpleAddress="true"
friendlyUrlCacheAbsoluteExpiration="0:0:10"
type="MyNamespace.MyUrlRewriter, MyAssembly"
description="My URL rewriter used to demonstrate custom URL rewriting" />
<-- Additional providers here -->
</providers>
</urlRewrite>
Just leave the EPiServerIdentityUrlRewriteProvider and EPiServerNullUrlRewriteProvider providers in there, but change the defaultProvider attribute of the <urlRewrite> element to your newly added provider's name (or name your provider "EPiServerFriendlyUrlRewriteProvider", whichever turns you on).
Step 2 - figure out how to go from external (friendly) URL to internal and vice versa
URL rewriting requires that there is a 1-to-1 relationship between an internal URL and it's external, or friendly, counterpart. So, you need a way of going from internal URL to external and back to internal. For a scenario such as the one in this demonstration you're probably dependant on some data source for performing this URL translation.
Composing the friendly URL
Here's how I decided to compose my friendly URLs: As mentioned above I only want custom URL rewriting for my account details pages. I've added a page of type Account details to my EPiServer site.
This page has PageID = 28 (this is important, you'll see why in a little bit). I've set the Page name in address for this page to "Account-Information" (this is also important, and yes, you'll see why shortly).
So, the desired friendly URL form is:
www.mysite.com/Language branch/URL segment of account details page/Name of the user/
In other words, I want URLs like: www.mysite.com/en/Account-Information/Ted-Nyberg/. If my user ID was 2 in the underlying data source, the internal URL would be www.mysite.com/UserInformation.aspx?user=2.
Reverting to the internal URL
My data source has user objects in it, and each user object has an ID, a user name and a URL segment (just like EPiServer pages, see?). So, if I have the friendly URL www.mysite.com/en/Account-Information/Ted-Nyberg/ I know that the request is for an account details page (because of the leading /en/Account-Information/ part of the URL. I also know that the next part of the URL is the URL segment of the user account, because that's how I compose my friendly URLs.
Now, by extracting the URL segment of the user I can get the ID of the user account (through the data source) and thus compose the internal URL like: www.mysite.com/UserInformation.aspx?user=id-goes-here.
So, now I have a way (at least in theory) of going from internal URL to friendly URL, and back to internal again. Sweet!
Step 3 - getting what you need to realize the water tight plan crafted in step 2
In this sample I start out the URL rewrite provider class with a static reference to the underlying EPiServer page (the page used to display account details):
private static PageReference _userInformationPageRef = new PageReference(28);
I also keep a few class-wide variables around:
private string
_userInformationPageNameInUrl,
_userInformationPageTypeFileName,
_userInformationUrlPrefix;
I use these to store the following:
- The Page name in address property value for the account details page (specified in edit mode)
- The file name of the Account details page type's underlying web form ("UserInformation.aspx")
- The leading URL prefix of account details pages (such as "/en/Account-Information/")
The first one is to compose the friendly URL. The second one is to compose the internal URL. The third one is used to identify URLs that map to account details pages (thus requiring custom URL rewriting).
The constructor
I populate the previously mentioned variables in my provider's constructor:
public MyUrlRewriter() : base()
{
//Get the EPiServer page used to display user account details
PageData userInfoPage =
DataFactory.Instance.GetPage(_userInformationPageRef);
//Get the URL segment of the account details page
//(the "Page name in address" property)
_userInformationPageNameInUrl = userInfoPage.URLSegment;
//Get the physical file name of the user account page
//(the .aspx file)
_userInformationPageTypeFileName =
PageType.Load(userInfoPage.PageTypeID).FileName;
//Get the URL prefix of user account details pages,
//for example "/en/User-Account-Details-Page/"
_userInformationUrlPrefix = string.Concat(
"/",
ContentLanguage.PreferredCulture.Name,
"/",
_userInformationPageNameInUrl,
"/");
}
Step 4 - going from internal URL to friendly URL
In order to customize how friendly URLs are generated we need to override the ConvertToExternalInternal method of the FriendlyUrlRewriteProvider class. Don't ask me where that method name came from, though. Oh yeah, the name "ConvertToExternal" was taken. Sorry.
In my implementation I use my custom URL rewrite behavior for account details pages and the default rewrite behavior for all other requests. It looks like this:
protected override bool ConvertToExternalInternal (UrlBuilder url, object internalObject, Encoding toEncoding)
{
//Check the internal URL to see if a user
//account page is being requested
//(look for "UserInformation.aspx")
if (url.Path.ToLower().Contains(
_userInformationPageTypeFileName.ToLower()))
{
//Get the user ID from the querystring
string userAccountId = url.QueryCollection["user"];
//Create the custom friendly URL
string friendlyPath = string.Concat(
"/",
LanguageBranch.Load(
ContentLanguage.PreferredCulture).LanguageID, "/",
_userInformationPageNameInUrl, "/",
User.GetUserById(userAccountId).UrlSegment, "/");
//Remove querystring parameters
url.QueryCollection.Clear();
//Set the URL to the new friendly URL
url.Path = friendlyPath;
//Indicate that the original URL has been rewritten
return true;
}
else //Not a user account details page
{
//Use the default rewriting behavior
return base.ConvertToExternalInternal(
url, internalObject, toEncoding);
}
}
Step 5 - going from friendly URL to internal URL
This code sample is a bit longer than the previous one, but it's actually not that complicated. I take the friendly URL, chop it up and use the parts to get what I need to compose the internal URL:
protected override bool ConvertToInternalInternal(
UrlBuilder url, ref object internalObject)
{
if (url == null)
{
return false;
}
//Check if the requested URL should be handled by the custom URL rewriting
if (url.Path.StartsWith(_userInformationUrlPrefix,
StringComparison.OrdinalIgnoreCase))
{
NameValueCollection queryCollection = url.QueryCollection;
//Get the language branch based on the requested URL
string nonLocalizedPath;
ContentLanguage.PreferredCulture = GetLanguageBranchAndPath(url.Path, out nonLocalizedPath).Culture;
//Set the internal object to a PageReference of the user account details page
internalObject = _userInformationPageRef;
//Ensure that the URL has a trailing slash
if (!url.Path.EndsWith("/"))
{
url.Path = url.Path + "/";
HttpContext.Current.Response.Redirect((string)url);
}
string friendlyUrl = url.Path;
//Get the internal URL of the user account details page
string internalUrl = new Url(DataFactory.Instance.GetPage(_userInformationPageRef).StaticLinkURL).Path;
//Set the URL to the internal URL
url.Path = internalUrl;
//Extract the URL segment of the user
string urlSegment = friendlyUrl.Replace(_userInformationUrlPrefix, string.Empty);
//Remove trailing slash
urlSegment = urlSegment.Substring(0, urlSegment.Length - 1);
//Get the User object
User user = User.GetUserByUrlSegment(urlSegment);
//Add the user ID to the querystring of the internal URL
queryCollection.Add("user", user.ID);
//Add additional querystring parameters to the internal URL
queryCollection.Add("epslanguage", ContentLanguage.PreferredCulture.TwoLetterISOLanguageName);
queryCollection.Add("id", _userInformationPageRef.ID.ToString());
//Indicate URL rewriting
return true;
}
else //Not a user account details page
{
//Use the default rewriting behavior
return base.ConvertToInternalInternal(url, ref internalObject);
}
}
Step 6 - circumventing the default URL caching behavior
The FriendlyUrlRewriteProvider class uses URL caching to increase URL rewriting performance (URL rewriting is said to account for about 60% of the CPU load of an "average" EPiServer web site).
URL caching is good. However, in the process the FriendlyUrlRewriteProvider removes all querystring parameters except for the id, epslanguage and epstemplate parameters. This is bad. Especially if you're dependant on a querystring parameter named user (remember?).
Unless we deal with this problem the first request to one of our account details pages will work perfectly well. But, if we would hit F5 within 10 seconds (or whatever the URL cache timeout is set to be) we would get an error. This is because the internal URL would be fetched from the cache and it would have the user querystring parameter stripped from it.
I solved this by overriding the ConvertToInternal method like so:
public override bool ConvertToInternal(
UrlBuilder url, out object internalObject)
{
internalObject = null;
//Check if a user account details page is being requested
if (url.Path.StartsWith(_userInformationUrlPrefix, StringComparison.OrdinalIgnoreCase))
{
ConvertToInternalInternal(url, ref internalObject);
return true;
}
else //Not a user account details page, use default rewriting
{
return base.ConvertToInternal(url, out internalObject);
}
}
Now we have a custom friendly URL rewriter!
That was easy, wasn't it? Now, to top it off, here's the trivial source code of the account details page (UserInformation.aspx):
protected void Page_Load(object sender, EventArgs e)
{
if (!string.IsNullOrEmpty(Request.QueryString["user"]))
{
//Display the internal URL
litInternalUrl.Text = HttpContext.Current.Request.Url.ToString();
//Get the user ID
string userId = Request.QueryString["user"];
//Get user details based on the ID
User user = User.GetUserById(userId);
//Output user details
litUserName.Text = user.Name;
litUrlSegment.Text = user.UrlSegment;
litUserId.Text = user.ID;
litDescription.Text = user.Description;
}
}
And here's the result (note the friendly URL in the address bar and its internal representation at the bottom of the page):
So, what's so special about all this? There is no page in the EPiServer database with a Page name in address property set to "Ted-Nyberg"! On the start page of the web site I've added a number of links to the UserInformation.aspx file with different querystring parameter values like so:
However, look what happens when we browse to the start page (note the URL in the status bar):
Recipe for custom URL rewriting
- Inherit from FriendlyUrlRewriteProvider
- Modify the <urlRewrite> element in web.config to use your new provider
- Override the ConvertToExternalInternal method
- Override the ConvertToInternalInternal method
- Override the ConvertToInternal method (to avoid missing querystring parameters due to default URL caching)
- Enjoy the show!