Patrice Calve

Life's short, have fun
posts - 57 , comments - 90 , trackbacks - 31

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

Print | posted on Wednesday, November 12, 2008 9:48 PM |

Feedback

Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

Interesting article. We have been wondering how can we fill the correct url of the parents node (with appropriate query string info), so we will take an inspiration from your post :)

One question - you use SiteMap.CurrentNode (e.g. when calling SiteMap.CurrentNode.ParentNode) - the instance of the node is shared across all the sessions, isn't it? You then change the url for all sessions so they can overwrite the urls each other. Or am I wrong? Where is the magic? :)
11/27/2008 7:58 AM | Pepa
Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

Hi Pepa,

Inspiration is good :)

In my test above I tried two things: 1) have fun, 2) find a solution (new angle) to the problem of site maps + localization + mvc dynamicness.

The post above was the effort of a long evening :) and shame on me, few tests. I had to put aside this project to work on other stuff. I'll be getting back to this in the next few days and I'll try to put more thorough tests on a dedicated "test server" with multi-sessions.

As for the magic, to be honest, I was actually suprised it worked (even with only 1 session). This is in part the reason I posted this blog; to allow other heads (usually better than mine ;)) to poke at my idea and find what's wrong (and hopefully what's good, of course).

If I can inspire one person, my goal was reached.

My GrandPa used to say: "Two heads are better than one, even when there's nothing in either".

This phrase sounds better in french: "Deux têtes valent mieux qu'une, même s'il n'y a rien dans les deux".

I'll update this post with my findings about the session.

Thanks,

Pat
11/27/2008 8:37 AM | Patrice
Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

Hi,

Pretty cool this article.

Try to implement this in my app but gotta error in setNodeFromMethodInfo.
The property Role in AspNetMvcSiteMapNode is null. Is it not instantiated automatically?
Or a miss something on the way?

Do you have an example? Source code or something like that...

Thank you very much! :D

PS.: Sorry about the grammar...i'm don't speak english very well...
2/17/2009 9:44 AM | Marco Antonio O Teixeira
Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

Hi Marco,

Good question. I'll try to find an answer tonight.

My guess would be one of the following two things to check, (both in the...

private static void setNodeFromMethodInfo(...)

method, )

right after the
...
AuthorizeAttribute authorizeAttribute = (AuthorizeAttribute)authAtt;
...

I think I should check if the authorizeAttribute is not null before trying to use it.

And right after, I should check that the role being added is "not" empty.

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

after:

foreach (string role in roles)
{
if (role != string.empty) // or something in the same line of thoughts
node.Roles.Add(role);
}
2/17/2009 12:42 PM | Patrice
Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

Mike,

I agree, I feel there's a gap in that space. On the other hand, Asp.Net MVC is "open" enough to plugin missing features.

I suggest you check out Maarten Balliauw's MvcSiteMap:

- Here: http://blog.maartenballiauw.be/post/2009/03/20/New-CodePlex-project-MvcSiteMap-ndash3b-ASPNET-MVC-sitemap-provider.aspx

and

- Here: http://mvcsitemap.codeplex.com/

Pat
12/3/2009 6:25 AM | Pat
Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

I also tried to overwrite the node`s Title but it didn`t work. I have then followed the path you suggested in this article and I was far more pleased with the dynamic part of the whole deal. Yes, you are right. You must pay attention to the readonly.
4/8/2011 5:58 AM | Phoenix Web Design
Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

Damn thanks for the walk through on this. The code is sometimes overwhelming to me to be honest.
12/12/2012 1:02 PM | Rainbowseo.com
Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

Please Help how to use this method in a View
Can i have complete solution if possible
thanks
Ashies Paul
12/27/2012 5:45 AM | Ashies
Gravatar

# re: ASP.Net MVC - inpractical web.sitemap in a dynamic context

Hi,

Me again.

The project has moved to the following development site:

https://github.com/maartenba/MvcSiteMapProvider

Pat
12/28/2012 9:03 AM | Patrice Calve
Post A Comment
Title:
Name:
Email:
Comment:
Verification:
 

Powered by: