3
votes

I have installed v4 of MvcSiteMapProvider and now I want to load a sitemap dynamically. My needs are simple - load an XML sitemap on each page request based on the currently logged in user role eg. AdminSiteMap.xml and UserSiteMap.xml

It appears that this can be done:

So basically you need to use a DI to achieve this (overkill IMHO). Any chance this can be done without a DI?

So as I am using ASP Boilerplate (http://www.aspnetboilerplate.com/) I have Castle Windsor as my DI.

So I installed "MvcSiteMapProvider MVC5 Windsor Dependency Injection Configuration" via NuGet. However now when I run the app I get the following error:

The SiteMapLoader has not been initialized.

Check the 'MvcSiteMapProvider_UseExternalDIContainer' setting in the AppSettings section of web.config.

If the setting is set to 'false', you will need to call the MvcSiteMapProvider.DI.Composer.Compose() method at the end of Application_Start in the Global.asax file. Alternatively, if you are using .NET 4.0 or higher you can install the MvcSiteMapProvider.MVCx NuGet package corresponding to your MVC version.

If the setting is set to 'true', you must set the SiteMaps.Loader property during Application_Start in Global.asax to an instance of the built-in SiteMapLoader type or a custom ISiteMapLoader instance. This can be achieved most easily by using your external DI container.

I have not changed the default configuration and have confirmed that the Install() method is being called in public class MvcSiteMapProviderInstaller : IWindsorInstaller as it hits a breakpoint in there.

So what am I missing here to make this work. Remember all I am trying to do is load a SiteMap based on the logged in user on each request.

**** UPDATE ****

While it may not be elegant it did not require a huge amount of code as proposed by implementing a DI container. See viggity's answer (about the 4th one down) at @Using Multiple MvcSiteMaps

1

1 Answers

5
votes

First of all, 1 SiteMap per user is possible, but will not scale very well - in fact it pretty much defeats the purpose of making a site map. I wouldn't recommend this approach unless you are certain your site won't have more than a few dozen simultaneous users, you have less than a few hundred pages on the site, and you have gobs of extra memory available on the server.

There are more scalable options to make nodes visible/invisible according to which user is logged in.

  • Use Security Trimming. When enabled, this works automatically just by properly configuring MVC security with the AuthorizeAttribute. AuthorizeAttribute has full support for roles. You can also inherit AuthorizeAttribute to add custom security logic if you need to.
  • Use custom visibility providers to control whether each node is visible or invisible according to custom criteria.
  • Customize the built in HTML helpers (by changing the templates in the /Views/Shared/DisplayTemplates/ folder) or build custom HTML helpers to dynamically load links per request for each user in addition to the links from the SiteMap instance.

None of these approaches requires external DI.

The recommended approach is to load all of the nodes into the SiteMap that every user could potentially access, then use security trimming to make the nodes for the current user invisible on the UI. You can force a reload of the cache when data changes by using the SiteMapCacheReleaseAttribute to make dynamic nodes visible on the UI immediately after adding them to your data source.


With that knowledge, if you still would like to proceed down the path you are currently on, you have installed the wrong NuGet package. The way dependency injection works is that you need exactly 1 composition root in your project (that is, one instance of WindsorContainer). Since you already have a composition root in your project, you must install the MvcSiteMapProvider modules only package for Windsor and then manualy add the module to your Windsor configuration by adding a few lines of code. You can downgrade to the modules only package by running this command in Package Manager Console:

PM> Uninstall-Package MvcSiteMapProvider.MVC5.DI.Windsor

Then, search for where new WindsorContainer() is declared in your project and add the MvcSiteMapProvider module to your DI configuration.

// Create the DI container (typically part of your DI setup already)
var container = new WindsorContainer();


// Your existing DI configuration should typically be here...

// Setup configuration of DI
container.Install(new MvcSiteMapProviderInstaller()); // Required
container.Install(new MvcInstaller()); // Required by MVC. Typically already part of your setup (double check the contents of the module).

// Setup global sitemap loader (required)
MvcSiteMapProvider.SiteMaps.Loader = container.Resolve<ISiteMapLoader>();

// Check all configured .sitemap files to ensure they follow the XSD for MvcSiteMapProvider (optional)
var validator = container.Resolve<ISiteMapXmlValidator>();
validator.ValidateXml(HostingEnvironment.MapPath("~/Mvc.sitemap"));

// Register the Sitemaps routes for search engines (optional)
XmlSiteMapController.RegisterRoutes(RouteTable.Routes);

If you ensure there is only 1 instance of WindsorConntainer project-wide and add the code above as appropriate, you should have a working DI configuration.

To load 1 SiteMap per user, you will need to make a custom ISiteMapCacheKeyGenerator that returns a different string for each user.

public class UserSiteMapCacheKeyGenerator
    : ISiteMapCacheKeyGenerator
{
    public virtual string GenerateKey()
    {
        var context = HttpContext.Current;
        if (context.User.Identity.IsAuthenticated)
        {
            // Note: the way you retrieve the user name depends on whether you are using 
            // Windows or Forms authentication
            return context.User.Identity.Name;
        }
        else
        {
            return "default";
        }
    }
}

And inject it by editing the Windsor module at /DI/Windsor/Installers/MvcSiteMapProviderInstaller.cs.

var excludeTypes = new Type[] { 
    // Use this array to add types you wish to explicitly exclude from convention-based  
    // auto-registration. By default all types that either match I[TypeName] = [TypeName] or 
    // I[TypeName] = [TypeName]Adapter will be automatically wired up as long as they don't 
    // have the [ExcludeFromAutoRegistrationAttribute].
    //
    // If you want to override a type that follows the convention, you should add the name 
    // of either the implementation name or the interface that it inherits to this list and 
    // add your manual registration code below. This will prevent duplicate registrations 
    // of the types from occurring. 

    // Example:
    // typeof(SiteMap),
    // typeof(SiteMapNodeVisibilityProviderStrategy)
    typeof(SiteMapNodeUrlResolver),
    typeof(ISiteMapCacheKeyGenerator) // <-- add this line
};

// Code omitted here...


// Add this to the bottom of the module
container.Register(Component.For<ISiteMapCacheKeyGenerator>().ImplementedBy<UserSiteMapCacheKeyGenerator>();

The only thing remaining is to use dynamic node providers or implementations of ISiteMapNodeProvider to dynamically supply the nodes per user. If you set it up as above, you can get the user name through the SiteMap.CacheKey property.

public class SomeDynamicNodeProvider : DynamicNodeProviderBase
{
    public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
    {
        // Get the user name
        var user = node.SiteMap.CacheKey;

        // Entities would be your entity framework context class
        // or repository.
        using (var entities = new Entities())
        {
            // Add the nodes for the current user only
            foreach (var story in entities.Stories.Where(x => x.User == user)
            {
                DynamicNode dynamicNode = new DynamicNode();
                dynamicNode.Title = story.Title;

                // The key of the node that this node will be the child of.
                // This works best if you explicitly set the key property/attribute 
                // of the parent node.
                dynamicNode.ParentKey = "Home"; 
                dynamicNode.Key = "Story_" + story.Id;
                dynamicNode.Controller = "Story";
                dynamicNode.Action = "Details";

                // Add the "id" (or any other custom route values)
                dynamicNode.RouteValues.Add("id", story.Id);

                yield return dynamicNode;

                // If you have child nodes to the current node, you can
                // nest them here by setting their ParentKey property to 
                // the same value as the dynamicNode.Key and returning them
                // using yield return.
            }
        }
    }
}

And finally, add your "template" nodes to your configuration to load the dynamic nodes.

// Set a key explicitly to attach the dynamic nodes to. 
// The key property here corresponds to the ParentKey property of the dynamic node.
<mvcSiteMapNode title="Home" controller="Home" action="Index" key="Home">
    // Use a "dummy" node for each dynamic node provider. This node won't be in the SiteMap.
    <mvcSiteMapNode dynamicNodeProvider="NamespaceName.SomeDynamicNodeProivder, AssemblyName"/>
</mvcSiteMapNode>