Search
Close this search box.

ASP.Net MVC – inpractical web.sitemap in a dynamic context

The out-of-the-box StaticSiteMapProvider is great for, well, static web sites.  I don’t find the StaticSiteMapProvider (and web.sitemap) model very practical for the dynamic nature of web sites/applications and especially Asp.Net Mvc applications.

In an mvc application it’s difficult to render a static sitemap that allows breadcrumbs like:

  • Home
  • Home > Cars
  • Home > Cars > Porsche 911
  • Home > Cars > Porsche 911 > Edit

For the sake of discussion, and to keep the discussion as small as possible

  • Home: url = /default.aspx?
  • Cars: url = /Cars/Index (Controller=Cars, Action=Index)
  • Porsche 911: /Cars/View(id) (Controller=Cars, Action=View, id = id)
  • Edit: /Cars/Edit(id) (Controller=Cars, Action=Edit, id = id)

I’d like to have breadcrumb generating proper title (localized please) and url.  Maarten Balliauw wrote a nice MvcSitemapProvider where you can write a sitemap with dynamic.  What I don’t like with the approach by Mr Balliauw is that I have to create a separate file that needs to keep be synched with the application, ie if the controller changes, I need to remember to change the sitemap.

So I’m offering you my “version” of a SiteMapProvider.  The angle I’m taking is to decorate classes and methods with an attribute and have a SiteMapProvider that uses builds the sitemap dynamically, using these attributes (with reflection).

I understand that reflection is slower than reading a static file, but from what I’ve found, the SiteMapProvider gets initialized once, on startup.  Ho, and I’m no expert by the way.

First, I created a blank, new AspNet Mvc (beta) application.  Then, I created 3 files:

  • AspNetMvcSiteMapNode.cs
  • AspNetMvcSiteMapProvider.cs
  • AspNetMvcSiteNodeAttribute.cs

We’ll see them in details bellow, but first, let me show you how the “decoration” looks.  In the HomeController.cs, I decorated the “out-of-the-box” Index and About actions, and created another action called View,  Here a sample using the About and Item actions.

[AspNetMvcSiteNode(Key = "HomeIndexAbout", Title = "About",
                   Description = "Description of us", ParentKey = "HomeIndex",
                   Url = "/Home/About")]

public ActionResult About()

{
  ViewData["Title"] = "About Page";

  return View();
}

[AspNetMvcSiteNode(Key = "HomeItem", Description = "An item, simple one",
                   IsDynamic = true, ParentKey = "HomeIndex",
                   Title = "Item {id}", Url = @"/Home/Item/\b(?<id>\d+)")]

public ActionResult Item(int id)

{
  SiteMap.CurrentNode.Title = string.Format("Item - foo[{0}]", id);

  ViewData["id"] = id;

  return View();
}

My first “pass” at the attribute pattern above was to rely on the Provider to magically render the Title at run-time based on the “rawUrl” parameter, and a mix of title and DynamicUrl regex pattern.  It didn’t turn out that well, more details at the end of the post.

So, instead of relying in the Provider, I decided to simply overwrite the node’s Title myself in the actual “action”.

SiteMap.CurrentNode.Title = string.Format("Item - foo[{0}]", id);

 With the “StaticSiteMapProvider”, everything is, well, static… so the above doesn’t work (pitty).  But with the AspNetMvcSiteMapNode provider, I made sure that SiteMapNodes are NOT readonly ;).

In the “Edit” action, I’m actually updating the “parentNode’s” title !

[AspNetMvcSiteNode(Key = "HomeItemEdit",
                   Description = "Edit of the item, simple one",
                   IsDynamic = true, ParentKey = "HomeItem", Title = "Edit",
                   Url = @"/Home/Edit/\d+")]
public ActionResult Edit(int id) {
  SiteMap.CurrentNode.ParentNode.Title = string.Format("Item - foo[{0}]", id);
  SiteMap.CurrentNode.ParentNode.Url = "/Home/Item/" + id;

  ViewData["id"] = id;
  ViewData["name"] = id.ToString();
  return View();
}

The AspNetMvcSiteNodeAttribute.cs class is very basic:

public class AspNetMvcSiteNodeAttribute : Attribute {
  public string Key { get; set; }
  public string Url { get; set; }
  public string Title { get; set; }
  public string Description { get; set; }
  public string ParentKey { get; set; }
  public bool IsDynamic { get; set; }
  public bool IsRoot { get; set; }
}

Nothing fancy.  The Key could actually be generated automatically, via a Guid, but it would be difficult to build the parent/child relationship with randomn data. 

I also created a AspNetMvcSiteMapNode.cs class, that inherits from the SiteMapNode and implements the “dynamic” portion.

public class AspNetMvcSiteMapNode : SiteMapNode {
  /// <summary>
  /// If the url is dynamic (variable on the querystring, for example), set the
  /// value to True
  /// </summary>
  public bool IsDynamic { get; set; }
  public string DynamicUrl { get; set; }
  public string ParentKey { get; set; }

  public AspNetMvcSiteMapNode(SiteMapProvider provider, string key)
      : base(provider, key) {
    IsDynamic = false;
  }
}

The Provider AspNetMvcSiteMapProvider.cs class, that inherits from the SiteMapProvider uses Reflection to get the AspNetMvcSiteNodeAttribute.  The algorithm includes a synchronization with the roles (via the AuthorizeAttribute). 

This is far from production ready code!!!!

public class AspNetMvcSiteMapProvider : SiteMapProvider {
  private Dictionary<string, AspNetMvcSiteMapNode> _nodes;
  private AspNetMvcSiteMapNode _rootNode;

  public override SiteMapNode FindSiteMapNode(string rawUrl) {
    foreach (KeyValuePair<string, AspNetMvcSiteMapNode> kvp in _nodes) {
      if (kvp.Value.IsDynamic) {
        Regex regex = new Regex(kvp.Value.DynamicUrl);

        if (regex.IsMatch(rawUrl)) {
          kvp.Value.Url = rawUrl;

          int[] groupNumbers = regex.GetGroupNumbers();

          Match match = regex.Matches(rawUrl)[0];

          for (int i = 1; i < groupNumbers.Length; i++) {
            Group group = match.Groups[i];

            kvp.Value.Title = kvp.Value.Title.Replace(
                "{" + regex.GroupNameFromNumber(i) + "}", group.Value);
          }

          return kvp.Value;
        }
      } else {
        if (kvp.Value.Url.ToUpper() == rawUrl.ToUpper()) {
          return kvp.Value;
        }
      }
    }
    return null;
  }

  public override SiteMapNodeCollection GetChildNodes(SiteMapNode node) {
    SiteMapNodeCollection coll = new SiteMapNodeCollection();

    foreach (KeyValuePair<string, AspNetMvcSiteMapNode> kvp in _nodes) {
      if (kvp.Value.ParentKey != null && kvp.Value.ParentKey == node.Key) {
        coll.Add(kvp.Value);
      }
    }

    return coll;
  }

  public override SiteMapNode GetParentNode(SiteMapNode node) {
    if (node != null && node.Key != null && node.Key != string.Empty &&
        _nodes.ContainsKey(node.Key)) {
      AspNetMvcSiteMapNode aNode = _nodes[node.Key];

      if (aNode.ParentKey != null && aNode.ParentKey != null &&
          _nodes.ContainsKey(aNode.ParentKey)) {
        return _nodes[aNode.ParentKey];
      } else
        return null;

    } else
      return null;
  }

  protected override SiteMapNode GetRootNodeCore() {
    return _rootNode;
  }

  public override void Initialize(
      string name,
      System.Collections.Specialized.NameValueCollection attributes) {
    base.Initialize(name, attributes);

    _nodes = new Dictionary<string, AspNetMvcSiteMapNode>();

    Assembly a = Assembly.GetExecutingAssembly();

    foreach (Type t in a.GetTypes()) {
      Attribute[] allAttributes = (Attribute[])t.GetCustomAttributes(
          typeof(AspNetMvcSiteNodeAttribute), true);

      foreach (Attribute att in allAttributes) {
        if (att.GetType() == typeof(AspNetMvcSiteNodeAttribute)) {
          addMvcNodeFromAttribute((AspNetMvcSiteNodeAttribute)att, null);
        }
      }

      foreach (MethodInfo mi in t.GetMethods()) {
        foreach (Attribute att in mi.GetCustomAttributes(true)) {
          if (att.GetType() == typeof(AspNetMvcSiteNodeAttribute)) {
            addMvcNodeFromAttribute((AspNetMvcSiteNodeAttribute)att, mi);
          }
        }
      }
    }
  }

  private void addMvcNodeFromAttribute(
      AspNetMvcSiteNodeAttribute aspNetMvcSiteNodeAttribute,
      MethodInfo methodInfo) {
    AspNetMvcSiteMapNode node =
        new AspNetMvcSiteMapNode(this, aspNetMvcSiteNodeAttribute.Key);
    node.Title = aspNetMvcSiteNodeAttribute.Title;
    node.Description = aspNetMvcSiteNodeAttribute.Description;

    if (aspNetMvcSiteNodeAttribute.IsRoot)
      _rootNode = node;
    else {
      node.ParentKey = aspNetMvcSiteNodeAttribute.ParentKey;
    }

    node.ReadOnly = false;

    node.IsDynamic = aspNetMvcSiteNodeAttribute.IsDynamic;
    if (node.IsDynamic) {
      node.DynamicUrl = aspNetMvcSiteNodeAttribute.Url;
    } else {
      node.Url = aspNetMvcSiteNodeAttribute.Url;
    }
    if (methodInfo != null) {
      setNodeFromMethodInfo(methodInfo, node);
    }

    _nodes.Add(node.Key, node);
  }

  private static void setNodeFromMethodInfo(MethodInfo methodInfo,
                                            AspNetMvcSiteMapNode node) {
    foreach (Attribute authAtt in methodInfo.GetCustomAttributes(
                 typeof(AuthorizeAttribute), true)) {
      if (authAtt.GetType() == typeof(AuthorizeAttribute)) {
        AuthorizeAttribute authorizeAttribute = (AuthorizeAttribute)authAtt;

        string[] roles = authorizeAttribute.Roles.Split(
            new string[] { "," }, StringSplitOptions.RemoveEmptyEntries);

        foreach (string role in roles) {
          node.Roles.Add(role);
        }
      }
    }
  }
}

Note that the AspNetMvcSiteNodeAttribute can be applied to any class.  For example, on the “Default.aspx.cs” class, I decorated the page_load method like this:

public partial class _Default : Page {
  [AspNetMvcSiteNode(IsRoot = true, Key = "Root", Url = "/Default.aspx?",
                     Title = "Home", Description = "The site's home page")]
  public void Page_Load(object sender, System.EventArgs e) {
    HttpContext.Current.RewritePath(Request.ApplicationPath);
    IHttpHandler httpHandler = new MvcHttpHandler();
    httpHandler.ProcessRequest(HttpContext.Current);
  }
}

In the code above (and in the attribute), I have to specify the url.  I don’t like that.  I really would like to forget about that “static” url and rely on the System.Web.Mvc to generate the proper urls in the case of controller/action methods.  But my attempts to make it work failed…

If the first page to load the web site in IIS is “/Default.aspx”, then the HttpContext .Current.Handler is not the MvcHandler.    So I can’t leverage the Routing.  If the first page loaded is handled by the MvcHandler, everything is fine.  Since the Provider’s “initialize” gets fired once, at startup, I can’t rely on the fact that it will always be the MvcHandler.

The HomeController.cs code is like this:

namespace MvcApplication1.Controllers {
  [HandleError]
  [AspNetMvcSiteNode(Key = "HomeController", Title = "Home",
                     Description = "Home Page", Url = "/Home",
                     ParentKey = "Root")]
  public class HomeController : Controller {
    [AspNetMvcSiteNode(Key = "HomeIndex", Title = "Index",
                       Description = "Description of Index",
                       Url = "/Home/Index", ParentKey = "Root")]
    public ActionResult Index() {
      for (int i = 0; i < 10; i++) {
        AspNetMvcSiteMapNode node = new AspNetMvcSiteMapNode(
            SiteMap.Provider, "HomeItem_" + i.ToString());
        node.Url = "/Home/Item/" + i.ToString();
        node.Title = string.Format("Item [id={0}]", i);
        node.IsDynamic = false;

        SiteMap.CurrentNode.ChildNodes.Add(node);
      }
      ViewData["Title"] = "Home Page";
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }

    [AspNetMvcSiteNode(Key = "HomeIndexAbout", Title = "About",
                       Description = "Description of us",
                       ParentKey = "HomeIndex", Url = "/Home/About")]
    public ActionResult About() {
      ViewData["Title"] = "About Page";

      return View();
    }

    [AspNetMvcSiteNode(Key = "HomeItem", Description = "An item, simple one",
                       IsDynamic = true, ParentKey = "HomeIndex",
                       Title = "Item {id}", Url = @"/Home/Item/\b(?<id>\d+)")]
    public ActionResult Item(int id) {
      SiteMap.CurrentNode.Title = string.Format("Item - foo[{0}]", id);
      ViewData["id"] = id;
      return View();
    }

    [AspNetMvcSiteNode(Key = "HomeItemEdit",
                       Description = "Edit of the item, simple one",
                       IsDynamic = true, ParentKey = "HomeItem", Title = "Edit",
                       Url = @"/Home/Edit/\d+")]
    public ActionResult Edit(int id) {
      SiteMap.CurrentNode.ParentNode.Title =
          string.Format("Item - foo[{0}]", id);
      SiteMap.CurrentNode.ParentNode.Url = "/Home/Item/" + id;

      ViewData["id"] = id;
      ViewData["name"] = id.ToString();
      return View();
    }
  }
}

You have my code, so go ahead and play with it.  If you find improvements, let me/us know.

Regex in the DynamicUrl 

As mentionned above, my first “pass” at the attribute pattern above was to rely on the Provider to magically render the Title at run-time based on the “rawUrl” parameter, and a mix of title and DynamicUrl regex pattern.  But this idea only works if the value you want to show in the Title is the “id” ! 

  • Home > Cars [25]  // ok because id=25 is the value to show.
  • Home > Cars [Porsche]  // impossible because the provider can’t render “Porsche” from the id 25…  so, problem 1

Problem 2, the “rawUrl” sent to the method FindSiteMapNode(string rawUrl) only works for the “current node”, so the: Home > Cars [25] > Edit wouldn’t be possible, because the “Cars [25]” portion would actually be rendered by the “parent” url being the “view”, not the “edit”.

So I kept the regex algorithm just in case it would be useful for someone someday.  Check the: public override SiteMapNode FindSiteMapNode(string rawUrl) Method from the Provider to see how I’m using it.

Have fun……  life’s short.

Pat

This article is part of the GWB Archives. Original Author: Patrice Calve

Related Posts