Patrice Calve

Life's short, have fun
posts - 51, comments - 49, trackbacks - 31

My Links

News

Archives

Post Categories

Image Galleries

Wednesday, November 12, 2008

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

posted @ Wednesday, November 12, 2008 9:48 PM | Feedback (4) |

Migrating VSS 2005 to TFS 2008

I finally and succesfully migrated a VSS 2005 Database to TFS 2008.

I got soo many problems/errors.  Things like:

  • Migration tool worked, but only the folders have been created in TFS. No file has been created,
  • Another migration warned that TF60085:  No file or folder to migrate
  • DCOM errors on the server.
  • When re-creating a TFS Project, TF30162: Task "WITs" from Group "WorkItemTracking" failed

So, after migrating "empty folders" the first time, I tried to delete the projects in TFS and re-importing them...  It turned to be a mess.........  I had to run the TFSDeleteProject.exe...

Here are my findings that may or may not be evident for mortals.

  • You must run the VSSConverter.exe from a "client" computer (not the TFS server)
  • The account you're using must have "project creation" priviliges on the TFS server
  • Check for DCOM errors on the server

If I had time and gutts, I'd start all over again (new os and all) and re-run everything with the following steps just to QA my steps!

To fix the DCOM errors,

Open Component Services

  • Start-->Administrative Tools-->Component Services
  • Expand Component Services, Computers, My Computer, DCOM Config.
  • Find the application (IIS WAMREG Admin Service / CLSID {61738644-F196-11D0-9953-00C04FD919C1}). 
  • Right-Click-->Properties and select the Security tab. 
  • For the "Launch and Activation Permissions, ensure that the Customize radio button is selected, and click Edit.
  • Add your service account (check the DCOM error message in the event viewer to find the right one), in my case, it was "NT AUTHORITY\NETWORK SERVICE"
    • Local Launch 
    • Remote Launch (not sure for this)
    • Local Activation
    • Remote Activation (not sure for this)
  • Restart IIS and continue on.

This "should" fix the DCOM errors in the future..  hehe.. 

As for the TF30162: Task "WITs" from Group "WorkItemTracking" failed.  I don't understand, but after "browsing" IIS console, re-starting IIS, I was able to re-create a new TFS Project without the TF30162 error above.  mmm.  maybe we just need to wait a few minutes (for sub-processes to finish/garbage collect) and a restart iis to clear caching. anywhoooo

ok, now back to migration

Perform the following steps from the client computer unless noted.

 

Step 1. Test Project creation on TFS

Open Visual Studio, connect to the TFS Server, try to create a project and upload a file to the Source Control.  If all works, continue, if not, fix !

 

Step 2: Analyze the VSS

Run the Analyze.exe.  Something like

"C:\Program Files\Microsoft Visual Studio\VSS\win32\ANALYZE.EXE" -f -c -d -v1 "d:\vss\data"

 

Step 3: get rid of checked outs files

There will likely be files that are checked out (older/defunct projects or un-monitored projects). 

  • Open VSS (client)
  • Search/Status Search
  • Choose "Display all checked out files"
  • Search Area "Search in in all projects"

If there are files found, send an email to your team or kill/"Undo Checkout" all files.

  • Open VSS client as admin
  • Right click the root "$/"
  • Choose Undo Checkout
  • Recursive = True, Local Copy = Leave, ok
  • Confirm all mesages... 

 

Step 4: install proper sps and hot fixes

if you run the VSSConverter and run into something like this:

Initializing...
VSSConverter has detected that Visual SourceSafe does not have the recommended u
pdates installed.  To ensure optimal results, install the updates referred to in
 Knowledge Base Article 950185.  Proceeding without these updates may lead to pr
oblems during migration.  Continue the migration without the updates (Y/N)?n

That's because the VSSConverter.exe needs to be updated (hotfix).

Well, kb950185, although the information seems to be correct, it wasn't clear that the hotfix can be found at  http://code.msdn.microsoft.com/ (the url is misleading, I think). 

Also, on that code.msdn page, to download the actual "exe", you have to click on "Current release"... Here's the direct link to the english (international) download page: http://code.msdn.microsoft.com/KB950185/Release/ProjectReleases.aspx?ReleaseId=1123

At the time of this writting, I have the following (all in English language, OSes and software):

Client:

  • Visual SourceSafe 2005 + Visual SourceSafe 2005 SP1
  • Visual Studio 2008 + Visual Studio 2008 SP1
  • TFS 2008 Explorer

Server:

- Team Foundation Server 2008 + TFS 2008 Explorer + TFS SP1

Note that all three (VSS client, TeamFoundationClient/Explorer and VSSConverter) must have the same language in order for VSSConverter to work.

Step 5: Run!

Follow the "standard" steps to migrate.  Here's my settings.xml file:

 

<?xml version="1.0" encoding="utf-8"?>
<SourceControlConverter>
  <ConverterSpecificSetting>
    <Source name="VSS">
      <VSSDatabase name="E:\VSS\Patware1.0" />
      <UserMap name="E:\tfs\usermapPatware1.0.xml"  />
    </Source>
    <ProjectMap>
      <Project Source="$/" Destination="$/Patware/"/>
    </ProjectMap>
  </ConverterSpecificSetting>
  <Settings>
    <!--<Output file="E:\tfs\analysisPatware1.0.xml"  />-->
     <TeamFoundationServer name="turner" port="8080" protocol="http"></TeamFoundationServer>
  </Settings>
</SourceControlConverter>

I "basically" used the same file for the analyze and the migrate portion.  I simply commented out the proper settings portion.

Glad it can help if it does!

Pat

posted @ Wednesday, November 12, 2008 2:46 PM | Feedback (1) |

Powered by: