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