Introduction
In MVC, your're given a lot of choice over your routing. You can add filters, constraints and catch all rules too. But in a project I was recently working on, I had to nest resources which required building on the existing routing system.
Previously, URLs for products were routed to the correct controller with a simple rule that used the numerical product id. Using the rule /{Controller}/{id}
meant that the public URLs ended up looking something like this: /Product/2444
. Of course, filters were used to provide an error 404 when products were not found.
However, the URL router could have been designed in a much better way where the product name and category are shown in the URL such as /Electronics/Software/Operating-Systems/PC/Windows-8-Installer
- the maximum category depth in this scheme is unlimited. One would think of creating a rule such as /{CategoryPath *}/{ProductPath}
which defaults to the Product controller. However MVC suffers a limitation where the catchall rule (indicated by the star) MUST be the last element in the rule.
Step 1: Catch-All route
We're going to need a catchall route which has a single parameter for path. We'll have a look later at how we can find a resource based on the path - there's a couple of options at our disposal.
routes.MapRoute("catchall",
"{*path}",
new { },
new { path = new Navigation.DatabasePageFilter() }).RouteHandler = new Navigation.RouteHandler();
I've added two additional items. The DatabasePageFilter - which extends IRouteConstraint check that the page exists in the database. And RouteHandler which returns which controller and action to use based off the route context.
Step 2: Filtering and finding pages.
We have two options when it comes to nesting pages. Either each resource has an absolute path. Or each resource has a relative path from its previous item and the hierarchy is maintained in the database. As this system allows for unlimited depth - this might be a problem, but for smaller depths, it might not be a problem. Another issue to consider is how often we want to change URLs. Ideally, once page is put online, its address should never change. It would be best to ensure that pages' URLs should be agnostic to changes in the pages ancestry.
The DatabasePageFilter itself is relatively simple. Just check to see if we have a valid request context before checking whether a Page exists in the database with a given path.
public class DatabasePageFilter : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (!values.ContainsKey("path"))
return false;
var path = (string)values["path"];
return Page.Exists(path);
}
}
Page.Exits is also really simple through the magic of LINQ. My database instance contains a registry of all pages, their controller and action. The page model is passed directly into the action by the RouteHandler() - we'll come onto this later.
public static bool Exists(string path)
{
return Database.GetInstance().Pages.Any(page => page.Path == path);
}
Step 3: Database Structure
There is a minor drawback in our design in the fact that we need another table to store the paths. Any page that can have a URL mapped to it must inherit the "Page" type as shown below.
Two static methods in Page allow us to return Page instances from anywhere within the application.
public partial class Page
{
public static Page GetByURL(string path)
{
return Database.GetInstance().Pages.Where(page => page.Path == path).SingleOrDefault();
}
public static bool Exists(string path)
{
return Database.GetInstance().Pages.Any(page => page.Path == path);
}
public abstract string ControllerName();
public abstract string ActionName();
}
Step 4: Invoking the correct controller for a model
Now that we've got our model. We need to invoke the correct controller. This is easier than it seems thanks to the two abstract methods that were added to the Page class. RouteHandler does all the hard work for this. For now we are keeping it simple, but this can be extended if multiple action types or security is required.
public class RouteHandler : IRouteHandler
{
public IHttpHandler GetHttpHandler(RequestContext requestContext)
{
string path = requestContext.RouteData.Values["path"] as string;
Page page = Page.GetByURL(requestContext.RouteData.Values["path"] as string);
requestContext.RouteData.Values["controller"] = page.ControllerName();
requestContext.RouteData.Values["action"] = page.AcionName();
requestContext.RouteData.Values["model"] = page;
return new MvcHandler(requestContext);
}
}
Obviously, each custom page type must override the ControllerName and ActionName methods to return the name of the controller (eg "ProductController") and the name of the relevant action (eg "ShowProduct").
Adding the controller is again relatively trivial. We just use a single parameter "model" in the list of parameters.
public ActionResult ShowProduct(Product model) ...
That's pretty much it. There's a lot which can be added for extensibility. I've assumed that the reader has familiarity with C# and is able to generate the database and access the models shown in the example. You can find more info on my site https://jthorne.co.uk/