Dynamic routing

And resolving CurrentDocument automatically

The problem to solve

After the initial release of Kentico 12 MVC it had been quite difficult to explain content editors that setting up any URL for any page regardless the page position in the tree was not going to work anymore. With the new release of Service Pack Kentico introduced Alternative URLs which was a short relief but still didn't solve all the problems. In this article I will show how it can be done. But first of all, list all the requirements for the solution:

  1. It should be possible to assign any URL to any page regardless the page position in the CMS tree.
  2. The system should prevent usage of already existing URLs (like it is implemented within Alternative URLs).
  3. As long as there will be no hard URL structure MVC routing to correct controller should be dynamic. And easy to use, of course!
  4. It would also be good to have CurrentDocument resolved (as it is in Portal Engine). This could also save us some time writing repositories for every single Page Type.

How is it going to work?

The idea is pretty simple:

  1. Any page has SeoUrl field. It can be any unique URL entered manually (or copied automatically from NodeAliasPath if not provided by default). So by this URL it is possible to find a TreeNode in CMS.
  2. TreeNode represents one Page Type. And usually one Page Type is routed to only one Controller and Action.
  3. In this case routing can be defined with attribute applied to Controller's Action with the reference to the Page Type (via ClassName).
  4. On application start routing table can be combined by simply scanning for all these attributes.
  5. And as long as TreeNode is already retrieved in step 1 it can be saved in request context and we will get a little bonus of something similar to CurrentDocument in Portal Engine.

Interesting? When it will be all set up and running, here is how Home controller of the sample DancingGoat MVC website would look like:

public class HomeController : BaseController
{
    private readonly IHomeRepository mHomeRepository;
    private readonly IOutputCacheDependencies mOutputCacheDependencies;

    public HomeController(IHomeRepository homeRepository,
                          IOutputCacheDependencies outputCacheDependencies,
                          IRequestContext requestContext) : base(requestContext)
    {
        mHomeRepository = homeRepository;
        mOutputCacheDependencies = outputCacheDependencies;
    }

    [PageTypeRouting(Home.CLASS_NAME)]
    public ActionResult Index()
    {
        var home = GetContextItem<Home>();
        if (home == null)
        {
            return HttpNotFound();
        }

        HttpContext.Kentico().PageBuilder().Initialize(home.DocumentID);

        var viewModel = new IndexViewModel
        {
            HomeSections = mHomeRepository.GetHomeSections().Select(HomeSectionViewModel.GetViewModel)
        };
            
        mOutputCacheDependencies.AddDependencyOnPage<Home>(home.DocumentID);
        mOutputCacheDependencies.AddDependencyOnPages<HomeSection>();

        return View(viewModel);
    }
}

Note a few changes made to the sample code here:

  1. BaseController and RequestContext are introduced. They will encapsulate the logic of resolving the CurrentDocument.
  2. By the way, CurrentDocument will be called ContextItem.
  3. No need for writing separate HomeRepository as GetContextItem<Home>() will replace it.
  4. MVC routing will be defined via PageTypeRouting attribute. In the example above it means that all the instances of Home (MVC) Page Type will be routed here, to Index Action of Home Controller. There will be no need to add extra MVC routing rule. Looks easy and self-descriptive.

Example setup

Follow these steps to setup the code example for DancingGoat MVC sample website:

  1. Download the code from GitHub.
  2. Copy the content of CMS and Shared folders into your Dancing Goat CMS folder. Include these files in the solution in Visual Studio, buid it and open Kentico CMS Admin in the browser.
  3. CMS folder contains the export of Form Control (formcontrol_SeoUrlSelector_export.zip) and Page Type (pagetype_BasePage_export.zip). Import these using the Sites application. Note, that these objects were exported from Kentico version 12.0.29 (Service Pack).
  4. Copy the content of MVC_App and Shared folders into your Dancing Goat MVC folder. Include these files in the solution in Visual Studio.
  5. Add the following line of code in RouteConfig.cs to register dynamic routes before other MVC routes (before "Article", in this example):
    DynamicRouteConfig.RegisterDynamicRoutes(routes);
    
    var route = routes.MapRoute(
        name: "Article",
        url: "{culture}/Articles/{guid}/{pageAlias}",
        defaults: new { culture = defaultCulture.Name, controller = "Articles", action = "Show" },
        constraints: new { culture = new SiteCultureConstraint(), guid = new GuidRouteConstraint() }
    );
  6. Register RequestContext within IoC container:
    builder.RegisterType<RequestContext>()
        .AsImplementedInterfaces()
        .InstancePerRequest();
  7. Make the following amends to HomeController:
    // Inherit from BaseController
    public class HomeController : BaseController
    {
        private readonly IHomeRepository mHomeRepository;
        private readonly IOutputCacheDependencies mOutputCacheDependencies;
    
        // Add IRequestContext parameter into constructor
        public HomeController(IHomeRepository homeRepository,
                                IOutputCacheDependencies outputCacheDependencies,
                                IRequestContext requestContext) : base(requestContext)
        {
            mHomeRepository = homeRepository;
            mOutputCacheDependencies = outputCacheDependencies;
        }
    
        // Add attribute to identify the correct routing
        [PageTypeRouting(Home.CLASS_NAME)]
        public ActionResult Index()
        {
            // Switch HomeRepository with ContextItem
            var home = GetContextItem<Home>();
            if (home == null)
            {
                return HttpNotFound();
            }
    
            // ...
    
            return View(viewModel);
        }
    }
  8. Save the MVC solution and rebuild it.
  9. Everything is ready in terms of code. A couple of changes in CMS Admin are required to finish. Edit Home (MVC) Page Type so that:
    1. Inherits fields from page type: Base Page (select just imported Page Type from the drop-down)
    2. URL pattern: /{% LocalizationContext.CurrentCulture.CultureCode %}{% SeoUrl %}
    3. *do not skip this step* Click on Search fields tab from the left menu and just click Save button. This is required to trigger the search fields definition updated after changing the inheritance of the Page Type.
    4. Go to Pages application and select Home node. Enter "/" (yes, just a single slash) into the Seo Url field and save changes.
  10. That's it! It's time to test!
    Navigate you favourite browser to http://localhost/Kentico12_DancingGoatMvc/en-US/ (or whatever is your main URL of the MVC app) and it should be working! Now you can repeat the steps 7-9 for any other controller. And specify absolutely any URL (which is not used so far) for any Article, for example.

Let's dig into the details, shall we?

Routing basics

The dynamic routing starts with the registration of the following rule in DynamicRouteConfig.cs:

var route = routes.MapRoute(
    name: "CheckByUrl",
    url: $"{{culture}}/{{*{Constants.DynamicRouting.RoutingUrlParameter}}}",
    defaults: new {defaultcontroller = "HttpErrors", defaultaction = "Index"},
    constraints: new {culture = new SiteCultureConstraint(), PageFound = new PageFoundConstraint()}
);
route.RouteHandler = new DynamicRouteHandler();

The logic here is that every URL has mandatory language prefix which is consistent across the site culture (this can be easily modified by just removing {culture} part of the URL). And the rest is dynamic URL.

Next, PageFoundConstraint which tries to find a TreeNode by SeoUrl provided and saves DocumentID and ClassName for the future use in HttpContext.

public class PageFoundConstraint : IRouteConstraint
{
    public bool Match(HttpContextBase context, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
    {
        if (!context.Items.Contains(Constants.DynamicRouting.ContextItemDocumentId))
        {
            var url = $"/{ValidationHelper.GetString(values[Constants.DynamicRouting.RoutingUrlParameter], String.Empty)}";

            // Get the classname based on the URL
            var foundNode = RoutingQueryHelper
                .GetNodeBySeoUrlQuery(url)
                .TopN(1)
                .AddVersionsParameters(context.Kentico().Preview().Enabled)
                .FirstOrDefault();

            context.Items.Add(Constants.DynamicRouting.ContextItemDocumentId, foundNode?.DocumentID);
            context.Items.Add(Constants.DynamicRouting.ContextItemClassName, foundNode?.ClassName);

            return foundNode != default(TreeNode);
        }

        return context.Items[Constants.DynamicRouting.ContextItemDocumentId] != null;
    }
}

In the GetNodeBySeoUrlQuery method you can find some logic encapsulated which generates the query to select only those documents from the database which have SeoUrl column in the corresponding Page Type.

The main logic of finding the route to the correct controller and action is written in DynamicHttpHandler and PageTypeRoutingHelper classes. On application start the routing table is combined by scanning all the assemblies for a custom PageTypeRoutingAttribute:

public static class PageTypeRoutingHelper
{
    private static readonly Dictionary<string, MethodInfo> RoutingDictionary = new Dictionary<string, MethodInfo>(StringComparer.OrdinalIgnoreCase);

    public static void Initialize()
    {
        var assemblies = AppDomain.CurrentDomain.GetAssemblies();

        var controllerTypes = assemblies
            .SelectMany(assembly => GetAvailableTypes(assembly)
                .Where(type => type
                    .IsSubclassOf(typeof(Controller))));

        foreach (var controllerType in controllerTypes)
        {
            var methods = controllerType
                .GetMethods()
                .Where(method => method.CustomAttributes
                    .Any(attribute => attribute.AttributeType == typeof(PageTypeRoutingAttribute)));

            foreach (var method in methods)
            {
                var pageTypeClassNames = method.GetCustomAttribute<PageTypeRoutingAttribute>().PageTypeClassName;
                foreach (var pageTypeClassName in pageTypeClassNames)
                {
                    RoutingDictionary.Add(pageTypeClassName, method);
                }
            }
        }
    }

    private static Type[] GetAvailableTypes(Assembly assembly)
    {
        try
        {
            return assembly.GetTypes();
        }
        catch (Exception)
        {
            return new Type[0];
        }
    }

    public static MethodInfo GetTargetControllerMethod(string className)
    {
        RoutingDictionary.TryGetValue(className, out var result);

        return result;
    }
}

From PageFoundConstraint we have ClassName already saved in the context and it can be used now for retrieving the correct route:

var controllerName = controllerMethod?.ReflectedType?.Name;
var controllerAction = controllerMethod?.Name;

controllerName = controllerName.Replace("Controller", string.Empty);

this.HttpRequestContext.RouteData.Values["Controller"] = controllerName;
this.HttpRequestContext.RouteData.Values["Action"] = controllerAction;

BaseController and ContextItem

And finally ContextItem (aka CurrentDocument) can be resolved. BaseController is introduced to add a custom RequestContext into all controllers and it can be registered within IoC container with per-request lifetime meaning that ContextItem can be used in other Actions. So before executing the Controller's Action the context can be resolved by getting DocumentID saved previously in PageFoundConstraint:

public class BaseController : Controller
{
    protected IRequestContext RequestContext { get; set; }

    public BaseController(IRequestContext requestContext)
    {
        RequestContext = requestContext;
    }

    protected override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        if (!this.RequestContext.ContextResolved)
            this.ResolveContext();

        base.OnActionExecuting(filterContext);
    }

    protected virtual T GetContextItem<T>() where T: TreeNode, new()
    {
        return this.RequestContext.GetContextItem<T>();
    }

    private void ResolveContext()
    {
        this.RequestContext.ContextItemId = this.HttpContext.Items[Constants.DynamicRouting.ContextItemDocumentId] as int?;
        this.RequestContext.IsPreview = this.HttpContext.Kentico().Preview().Enabled;
        this.RequestContext.ContextResolved = true;
    }
}

And when the ContextItem is requested via GetContextItem<T>() it can be retrieved from the database and cached:

public class RequestContext : IRequestContext
{
    public int? ContextItemId { get; set; }
    public bool ContextResolved { get; set; }
    public bool IsPreview { get; set; }

    private TreeNode ContextItem { get; set; }
    private bool ContextItemResolved { get; set; }

    public T GetContextItem<T>() where T : TreeNode, new()
    {
        if (!this.ContextItemResolved)
            this.ResolveContextItem<T>();

        if (this.ContextItem is T typedContextItem)
            return typedContextItem;

        return null;
    }

    private void ResolveContextItem<T>() where T : TreeNode, new()
    {
        if (this.ContextItemId.HasValue)
        {
            var query = DocumentHelper.GetDocuments<T>()
                .WithID(this.ContextItemId.Value)
                .TopN(1)
                .AddVersionsParameters(this.IsPreview);

            this.ContextItem = query.FirstOrDefault();
            this.ContextItemResolved = true;
        }
    }
}

URL default value and validation

Have nearly forgotten. The URL needs to be unique. In the CMS application code mentioned above there is a custom control called SeoUrlSelector which is a clone of Text Input but with extra validation applied:

public override bool IsValid()
{
    var seoUrl = ValidationHelper.GetString(Value, String.Empty);

    // If URL is not entered and will be copied from NodeAliasPath automatically in custom module
    if (!(Data is TreeNode currentDocument) || string.IsNullOrWhiteSpace(seoUrl)) return base.IsValid();

    var foundNodes = SeoUrlService.GetAllDocumentsBySeoUrl(seoUrl);

    if (foundNodes.Count == 0 || foundNodes.All(x => x.DocumentID == currentDocument.DocumentID))
        return base.IsValid();

    ValidationError =
        $"URL '{seoUrl}' is in conflict with another URL used for the page '{foundNodes.FirstOrDefault()?.DocumentNamePath ?? String.Empty}' ({foundNodes.FirstOrDefault()?.DocumentCulture ?? String.Empty}) page.";
    return false;
}

The method SeoUrlService.GetAllDocumentsBySeoUrl(seoUrl) returns all TreeNodes with the same SeoUrl used. Both published and unpublished. If there are any it means that this URL is already used and there should be error message displayed. For more info refer to SeoUrlService code.

There is also DynamicRoutingModule in the Shared code folder. It is responsible for the following:

  1. If SeoUrl field is empty (not populated by content editor during the new item creation) it's value is copied from NodeAliasPath. So, NodeAliasPath is a default URL value in this case (check EnsureSeoUrlPopulated method).
  2. Checks again before saving the document that SeoUrl is unique (refer to EnsureSeoUrlUnique method).

Wrapping it up

After using this dynamic routing for a while I would say it is easy and convenient. Applying it to the existing project with plenty of Page Types may be a big task to do but starting a new one with it absolutely worth it in my opinion! The benefits, again, are:

  1. Self-explanatory route assignment via attributes on Controller's Action
  2. ContextItem is always resolved and ready to be used
  3. No need to register extra routes in RouteConfig
  4. No need to implement repositories for resolving ContextItem
  5. Automatic validation of URL to be unique

Special credits

I'd like to mention the following people and say big thanks: