Tuesday, February 01, 2011

Sitecore Product URLs Amazon Style


If you are a Sitecore CMS developer and you love shopping on Amazon, you probably had the same thought: what would it take to re-create Amazon.com in Sitecore? Completely from scratch. Ok, maybe it’s just me.

So you will need to think about how to architect your content tree properly. Luckily, Derek has a blog dedicated solely to content tree architecture so I don’t need to go into much detail here.
Let’s say you have your content tree already designed. We are going to take Nicam demo site as an example:image
So all the camera products are structured in categories, that’s nice.
Since we talk about URLs in this post, everything is set there too. Each product has a content path which more or less serves as product URL on the public facing side.
In other words, I would be able to access my D3X camera using the following URL:
http://localhost/en/Products/Digital_SLR/Full_featured/D3X.aspx
One of the things you need to know is that Sitecore is constructing those URLs on the fly based on LinkManager configuration in web.config. And it is actually doing a pretty great job out of the box by giving you a number of options when it comes to URL construction. You can prepend language ISO code in URL, use display name instead of item name, etc. You can learn more about it here.
But what if we want to have fluid URLs that are constructed based on product attributes, meta data or some other criteria? Just as on Amazon.com, where the product URL is clearly driven by product attributes:
SNAGHTMLaa324b6For example, for all of our SLR cameras, use the following URL pattern: http://localhost/{SLR}/{Name}/{SKU}, where SKU is an attribute on the product itself:
imageWell, as it turns out, it is quite straightforward to do.
When it comes to handling any custom URL handling requirements, there are mainly two components you have to deal with.
1. Custom Item Resolver.
The custom logic here will attempt to resolve a valid item in the content tree by the custom URL.
2. Custom Link Provider.
This is the flip side of the solution. We need to teach Sitecore to generate product URLs based on our custom rules.
In addition, you would generally need a component to process such custom URL rules. In my example I would simply use IDTable, which allows to store any mapping to an item in a flat table. For the sake of simplicity I will be updating my IDTable based mapping table every time an item is saved via a handler.
The result will look something like this:
image
So here are all the pieces:
1. Custom Item Resolver:
public class ProductUrlResolver : HttpRequestProcessor
{
   public override void Process(HttpRequestArgs args)
   {
      Assert.ArgumentNotNull(args, "args");
      if (Context.Item != null || Context.Database == null || args.Url.ItemPath.Length == 0) return;
      Context.Item = ProductUrlManager.GetProductItemByFilePath(args.Url.FilePath);
   }
}

<processor type="Sitecore.Pipelines.HttpRequest.ItemResolver, Sitecore.Kernel"/>
<!-- the proper order is important -->
<processor type="Custom.ProductUrlResolver, ProductUrlResolver"/>
<processor type="Sitecore.Pipelines.HttpRequest.LayoutResolver, Sitecore.Kernel"/>

2. Custom Link Provider:
public class ProductLinkProvider : LinkProvider
{
   public override string GetItemUrl(Item item, UrlOptions options)
   {
     Assert.ArgumentNotNull(item, "item");
     Assert.ArgumentNotNull(options, "options");
     return item.IsProduct() ? item.ProductUrl() : base.GetItemUrl(item, options);
   }
}

<linkManager defaultProvider="sitecore">
      <providers>
        <clear/>
         <add name="sitecore"
              type="Custom.ProductLinkProvider, ProductUrlResolver"
              .../>
      </providers>
</linkManager>

3. ItemSaved event handler:
public class ProductHandler
{
      protected void OnItemSaved(object sender, EventArgs args)
      {
         if (args == null) return;

         var item = Event.ExtractParameter(args, 0) as Item;

         if (item == null) return;

         if (item.IsProduct())
         {
            item.RegisterMapping();
         }
      }
}

<event name="item:saved">
    ...
   <handler type="Custom.ProductHandler,ProductUrlResolver" method="OnItemSaved"/>
</event>

4. Utility Manager where all the logic is handled:
public static class ProductUrlManager
   {
      public static string IdTableKey
      {
         get { return "ProductResolver"; }
      }

      public static bool IsProduct(this Item item)
      {
         var template = TemplateManager.GetTemplate(item);
         return template != null &&
template.DescendsFromOrEquals(ID.Parse("{B87EFAE7-D3D5-4E07-A6FC-012AAA13A6CF}"));
      }

      public static string ProductUrl(this Item item)
      {
         return "/{0}/{1}/{2}".FormatWith(item.TemplateName.ToLowerInvariant(), 
item.Name.ToLowerInvariant(),
item["SKU"]);
      }

      public static Item GetProductItemByFilePath(string filePath)
      {
         var id = IDTable.GetID(ProductUrlManager.IdTableKey, filePath);

         if (id != null && !ID.IsNullOrEmpty(id.ID))
         {
            return Context.Database.GetItem(id.ID);
         }

         return null;
      }

      public static void RegisterMapping(this Item item)
      {
         IDTable.RemoveID(ProductUrlManager.IdTableKey, item.ID);
         IDTable.Add(ProductUrlManager.IdTableKey, item.ProductUrl(), item.ID);
      }
   }

Conceptually, what do you think about this?
Note that this is a pretty hardcoded way of implementing such requirement. Please consider this as a prototype rather than a solution ready for production.

8 comments:

Dan Solovay said...

Interesting stuff! Would the CustomItemResolver be a good way of handling URL aliasing?

Mark Cassidy said...

It's one approach, but it does seem like a lot of work. Part of your URL will always be some sort of unique product identifier, and I would probably just go looking for this just before the Item Resolver attempts it in the request pipeline.

Something like

1) Split the URL up in its component parts, to find an ASIN. If found, continue.
2) Ask Lucene if the ASIN exists
3) If so, resolve the item in the pipeline to what Lucene found

Saves you the extra trouble of creating save handlers etc, assuming of course that you have a current lucene index on your solution. And for a webshop, you always would have.

Unknown said...

Hi Mark,

Thanks for the feedback guys.

Mark:

I would not agree that it is a lot of work, the solution is actually very simple. The save handler - I used it only for the sake of simplicity. In reality, I'd leverage something else. Maybe a workflow action that makes sure that the mapping table is updated every time a version is published.

I would not use Lucene simply because in two server setups the front-end index is not updated immediately, and URL is a very critical component.

Mapping table seems like the only bulletproof and quite lightweight way to go. How you populate it is another question (item:saved, wf action or publish:item processor).

I would also not put a lot of additional string processing on this side as both CustomItemResolver and LinkManager are used quite frequently on the delivery side. Reading from a mapping table is easy, plus you can cache it in memory. Messing with strings and querying Lucene every time an custom item is being resolved or a link is being generated could be more expensive.

Kyle Heon said...

Hey Alex, sorry to revisit such an old post but I'm dealing with an issue that I think this might solve for me. I'm not all that familiar with the IDTable piece, is it possible to do this w/o touching IDTable? Could this be handled just by a modified LinkProvider? Just contemplating options. Thanks!

Unknown said...

Hi Kyle,

The IDTable is used here for persistence of the short url to item id. You can either resolve the URL dynamically, which might be more expensive, or persist the mapping somewhere else :-)
The point I was trying to make here is to showcase the flexibility of link generation mechanism and moving pieces involved to make such custom URL happen (LinkProvider&ItemResolver).

Hope this answers your question.

-alex

Kyle Heon said...

Thanks Alex, that helps clear things up, that and I did some research in the meantime regarding IDTable.

Another question, how will this behave in a multilingual implementation? Any gotchas you can think of that I should be looking for?

Thanks again!

arjunan said...

Hi

This solution is fantastic.I have 2 questions on it.I tried the same scenario as you mentioned above.

1.Custom Url & its associate id is getting stored in the IDTable of master database.my question is like when you go for live,where does it refer when we call the custom url.Does it refer master database?

2.If we have more than 2 CD environments,How does it work?.Do i have to point 2 databases in the IDTable config?

Unknown said...

Hello,

I'd try to re-route IDTable to web database (simple config change).
This way, assuming your are sharing the web database between CD environments, it should work there too.

-alex