CONTENTSTART
EXCLUDESTART EXCLUDEEND

Dynamic Routing with Kentico MVC

Read the Article already and just need the code?  Grab it here!

Portal Method and MVC can be pretty different animals.  For the most part, you can make a transition to MVC through items such as MVC Widgets, calling Html.RenderAction to simulate webpart like behavior in your views, and similar things.

However, one thing that is probably the hardest thing to switch thinking on is MVC Routes.

How Portal Finds and Renders a Page

First let's take a quick refresh of how Portal Method displays a page.  When a page request comes in, Kentico matches that request to a Page, through the NodeAliasPath or Url Aliases.  Once it finds the page, it then looks at the Page's Template and proceeds to render the page (using things such as the Template's Master Page/Nesting, widget zones, web parts, etc).

How MVC Finds and Renders a Page

MVC is quite different.  In an MVC Site, you define Routes on startup.  When a page request comes in, it matches it to a predefined Route.  That route then determines what Controller and Action is called.  This Controller/Action gathers the information it needs, and passes that to a View which then renders the page.  So by default, routes are very structured, because of this you lose a lot of the freedom of having content anywhere.


Here's a good example of the issue. Say Fred wants a blog on the site.  He adds a Blog type page under the root in Kentico, with a path of /FredsBlog, and then adds some articles under it.  You create a route of /FredsBlog/{ArticleName} that uses the controller "BlogController." 

However now Sally wants a blog as well, so she adds a blog at /SallysBlog.  Now you need to add another route of /SallysBlog/{ArticleName} with the same BlogController in use.

To further complicate things, within Kentico you need to define a Url Pattern so Kentico knows what path to call on your MVC site to preview the page, and now you have two unique patterns.

This is also why Url Aliases are not available in an MVC instance of Kentico.

Restoring Portal-Like Routing

Fret not, it IS possible to restore portal-like routing and handling.  We are going to accomplish this through two things: a Route Handler and a ControllerFactory.

Route Handlers

When you define a route within MVC, you can also assign a Route Handler.  This provides the means to perform additional logic with a route, such as adding additional routing context (like the sample Dancing Goat's MultiCultureMvcRouteHandler).  This class inherits the MvcRouteHandler where you can override the GetHttpHandler.  We'll use a RouteHandler to inject custom logic to dynamically determine what Controller to render based on the Node found, just like Portal Method.

ControllerFactory

A controller factory (ControllerBuilder.Current.GetControllerFactory()) is a class that allows you to get a controller purely on the name of it (making it very dynamic).  This is done through the CreateController(RequestContext, ControllerName) method.  The RequestContext has things such as the Request's properties (Controller, Action, and other properties the Controller needs to determine the correct method to call), and the controller name is simply that, the name of the controller (ex "Blog" for a BlogController).

Routes to Handle

Routes work in the order that you add them, and as soon as one is found that matches it runs that. Because MVC usually needs a default controller of "{Controller}/{Action}/{Id}" for things like Html.RenderAction, this route needs to stay.  We will need to attach the Custom Route Handler to this route so if our page also has a Url like /FredsBlog/MyArticle, we can adjust the logic before it tries to call FredsBlogController with an action of MyArticle.

Now this route only catches Urls that have at most 3 parts.  So we also need a universal catch all at the very end, and add our Dynamic Route to it.  a Route with a url of "{*url}" will cover any and all remaining urls, and that should be added as the last route on your configuration.

var route = routes.MapRoute(
	 name: "Default",
	 url: "{controller}/{action}/{id}",
	 defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
 );
route.RouteHandler = new KMVCDynamicRouteHandler();

// This will catch any other patterns and check Node Alias / Url Alias, and throw an HttpError/Index if not found.
route = routes.MapRoute(
	name: "CheckByUrl",
	url: "{*url}"
	// No defaults, expecially on controller, as the Dynamic Routing will throw the HttpError if not found.
);
route.RouteHandler = new KMVCDynamicRouteHandler();

Dynamic Route Logic

Now let's discuss the routing logic (Code below this explanation).  The first step is to try to render the controller as normal, because if the request does have a matching Controller or action, it should render.  If it tries to call that Controller, and it doesn't exist (or if there is no Controller in the RequestContext at all), it will throw a HttpException saying the Controller wasn't found.

Catching that exception, now we can see if the request Url matches a page in Kentico instead.  I call a "GetNodeByAliasPath()" method i created that checks NodeAliasPaths, and UrlAlias's and returns the TreeNode if it finds one.   If none is found, we can go ahead and call whatever our 404 handler is.

Assuming we found the page though, now we can put in our own custom logic.  We can adjust the RequestContext and set the Controller and Action to whatever we want based on the page's ClassName, or maybe some values attached to that page type.  Calling the Controller.Execute(RequestContext) then will properly call the controller.

Don't forget in Kentico to set the Page Type's Url Pattern to simply {% NodeAliasPath %}.

public class KMVCDynamicRouteHandler : IRouteHandler
{
	public IHttpHandler GetHttpHandler(RequestContext requestContext)
	{
		return new KMVCDynamicHttpHandler(requestContext);
	}

}

public class KMVCDynamicHttpHandler : IHttpHandler
{
	public RequestContext RequestContext { get; set; }
	public KMVCDynamicHttpHandler(RequestContext requestContext)
	{
		this.RequestContext = requestContext;
	}

	public bool IsReusable
	{
		get
		{
			return false;
		}
	}
	public void ProcessRequest(HttpContext context)
	{
		IController controller = null;
		IControllerFactory factory = ControllerBuilder.Current.GetControllerFactory();
		try
		{
			string controllerName = (RequestContext.RouteData.Values.ContainsKey("controller") ? (string)RequestContext.RouteData.Values["controller"] : "");
			if (string.IsNullOrWhiteSpace(controllerName))
			{
				// No controller found, so throw exception to skip ahead to path resolving.
				throw new HttpException("was not found or does not implement icontroller");
			}
			controller = factory.CreateController(RequestContext, controllerName);
			controller.Execute(RequestContext);
		}
		catch (HttpException ex)
		{
			string NewController = "HttpErrors";
			if (ex.Message.ToLower().Contains("was not found or does not implement icontroller"))
			{
				// Get the classname based on the URL
				TreeNode FoundNode = DocumentQueryHelper.GetNodeByAliasPath(context.Request.Url.AbsolutePath);
				string ClassName = (FoundNode != null ? FoundNode.ClassName : "");
				switch (ClassName.ToLower())
				{
					case "":
						// New Controller will stay HttpErrors as ClassName not found
						break;
					// can add your own cases to do more advanced logic if you wish
					default:
						// Default will look towards the classname (period replaced with _) for the Controller
						NewController = ClassName.Replace(".", "_");
						break;
				}
			}
			// execute Generic Widget Page
			this.RequestContext.RouteData.Values["Controller"] = NewController;

			// If there is an action (2nd value), change it to the CheckNotFound, and remove ID
			if (this.RequestContext.RouteData.Values.ContainsKey("Action"))
			{
				this.RequestContext.RouteData.Values["Action"] = "Index";
			}
			else
			{
				this.RequestContext.RouteData.Values.Add("Action", "Index");
			}
			if (RequestContext.RouteData.Values.ContainsKey("Id"))
			{
				RequestContext.RouteData.Values.Remove("Id");
			}
			try
			{
				controller = factory.CreateController(RequestContext, NewController);
				controller.Execute(RequestContext);
			}
			catch (HttpException ex2)
			{
				// Even that failed, log and use normal HttpErrors controller
				EventLogProvider.LogException("KMVCDynamicHttpHandler", "ClassControllerNotConfigured", ex2, additionalMessage: "Page found, but could not find a Controller for " + NewController + ", create one with an index view to auto handle or modify the KMVCDynamicHttpHandler");
				controller = factory.CreateController(RequestContext, "HttpErrors");
				controller.Execute(RequestContext);
			}
		}
		finally
		{
			factory.ReleaseController(controller);
		}
	}
}

Getting the Url Alias UI Back

Since for MVC sites, all content is "Content Only," and Kentico assumes you're using standard MVC Routing, there is no "Url Alias" UI provided for your pages.  With my dynamic Route Handling, we CAN use Url Aliases once more, but we need to do a little modification to Kentico itself to give us the User Interface to be able to add them again.

The issue lies in the /CMSModules/CMSDesk/Properties/Alias_List.aspx.cs file.  It contains a section that has this:

if (ShowContentOnlyProperties)
{
	pnlUIPath.Visible = false;
	pnlUIExtended.Visible = false;
	pnlUIDocumentAlias.Visible = false;
	headAlias.Visible = false;
}

You have two options here.  You can just comment out the last 2 items that are hidden with this (the pnlUIDocumentAlias.Visible and headAlias.Visible should be commented out or set to true), but you run the risk of Kentico eventually overwriting these changes.  Otherwise, you can copy this and the sibling pages into a separate module folder and create your own UI element that points to it (mimicking the existing Url Alias tab settings).  

If you don't feel comfortable creating your own User Interface, you can go ahead and just comment out those 2 fields, otherwise you can download my modified version here and create your own UI element.
In the end, your code should look like this:

if (ShowContentOnlyProperties)
{
	pnlUIPath.Visible = false;
	pnlUIExtended.Visible = false;
	//pnlUIDocumentAlias.Visible = false;
	//headAlias.Visible = false;
}

Mimic Dynamic Templates with your Pages

One last little trick I thought i would share.  Just like you can have various Page Templates for the same Page Types, you can accomplish this relatively easily.  On your Page Type, just add a "View" Property, and give your user a drop down of the various Views you have prebuilt.  When your Controller handles the page.  Here i have a GenericWidgetPageController for my Generic Widget Page Types, with a "Layout" field that has the various View options I created.

public class KMVCHelper_GenericWidgetPageController : BaseController
{
	// GET: GenericWidgetPage
	public ActionResult Index()
	{
		GenericWidgetPage FoundNode = (GenericWidgetPage) DocumentQueryHelper.GetNodeByAliasPath(HttpContext.Request.Url.AbsolutePath, GenericWidgetPage.OBJECT_TYPE);
		if (FoundNode != null)
		{
			HttpContext.Kentico().PageBuilder().Initialize(FoundNode.DocumentID);
			SetContext(FoundNode.DocumentID);

			return View(FoundNode.Layout);
		}
		else
		{
			return HttpNotFound("Could not find page by that Url");
		}
	}
}

Want the code?

My code uses a couple helper classes to make life easier, and while not fully complete, i've included what i have so far.  Download and use as you wish!

Comments
Trevor Fayas
Hey Mark, the DocumentQueryHelper is a helper method i created, you can get it on the new Kentico 12 Boilerplate on my GitHub, there's an article about it!
5/23/2019 10:54:34 AM

Mark Clark
Awesome solution!
Just a small change as we are on K12.0.9 and I could not resolve 'DocumentQueryHelper' (perhaps an API change from K11->K12?)

// The following commented line is what we are replacing
//TreeNode FoundNode = DocumentQueryHelper.GetNodeByAliasPath(context.Request.Url.AbsolutePath);
var tree = new TreeProvider(MembershipContext.AuthenticatedUser);
TreeNode FoundNode = tree.SelectNodes()
.WhereLike("NodeAliasPath", context.Request.Url.AbsolutePath)
.OnSite(SiteContext.CurrentSiteName).SingleOrDefault();
2/22/2019 6:06:03 PM

Is nine = four ? (true/false)
CONTENTEND