Multiple Levels in MVC Custom Routing

Multiple levels in MVC custom routing

You can make CMS-style routes seamlessly with a custom RouteBase subclass.

public class PageInfo
{
// VirtualPath should not have a leading slash
// example: events/conventions/mycon
public string VirtualPath { get; set; }
public Guid Id { get; set; }
}

public class CustomPageRoute
: RouteBase
{
private object synclock = new object();

public override RouteData GetRouteData(HttpContextBase httpContext)
{
RouteData result = null;

// Trim the leading slash
var path = httpContext.Request.Path.Substring(1);

// Get the page that matches.
var page = GetPageList(httpContext)
.Where(x => x.VirtualPath.Equals(path))
.FirstOrDefault();

if (page != null)
{
result = new RouteData(this, new MvcRouteHandler());

// Optional - make query string values into route values.
this.AddQueryStringParametersToRouteData(result, httpContext);

// TODO: You might want to use the page object (from the database) to
// get both the controller and action, and possibly even an area.
// Alternatively, you could create a route for each table and hard-code
// this information.
result.Values["controller"] = "CustomPage";
result.Values["action"] = "Details";

// This will be the primary key of the database row.
// It might be an integer or a GUID.
result.Values["id"] = page.Id;
}

// IMPORTANT: Always return null if there is no match.
// This tells .NET routing to check the next route that is registered.
return result;
}

public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
{
VirtualPathData result = null;

PageInfo page = null;

// Get all of the pages from the cache.
var pages = GetPageList(requestContext.HttpContext);

if (TryFindMatch(pages, values, out page))
{
if (!string.IsNullOrEmpty(page.VirtualPath))
{
result = new VirtualPathData(this, page.VirtualPath);
}
}

// IMPORTANT: Always return null if there is no match.
// This tells .NET routing to check the next route that is registered.
return result;
}

private bool TryFindMatch(IEnumerable<PageInfo> pages, RouteValueDictionary values, out PageInfo page)
{
page = null;
Guid id = Guid.Empty;

// This example uses a GUID for an id. If it cannot be parsed,
// we just skip it.
if (!Guid.TryParse(Convert.ToString(values["id"]), out id))
{
return false;
}

var controller = Convert.ToString(values["controller"]);
var action = Convert.ToString(values["action"]);

// The logic here should be the inverse of the logic in
// GetRouteData(). So, we match the same controller, action, and id.
// If we had additional route values there, we would take them all
// into consideration during this step.
if (action == "Details" && controller == "CustomPage")
{
page = pages
.Where(x => x.Id.Equals(id))
.FirstOrDefault();
if (page != null)
{
return true;
}
}
return false;
}

private void AddQueryStringParametersToRouteData(RouteData routeData, HttpContextBase httpContext)
{
var queryString = httpContext.Request.QueryString;
if (queryString.Keys.Count > 0)
{
foreach (var key in queryString.AllKeys)
{
routeData.Values[key] = queryString[key];
}
}
}

private IEnumerable<PageInfo> GetPageList(HttpContextBase httpContext)
{
string key = "__CustomPageList";
var pages = httpContext.Cache[key];
if (pages == null)
{
lock(synclock)
{
pages = httpContext.Cache[key];
if (pages == null)
{
// TODO: Retrieve the list of PageInfo objects from the database here.
pages = new List<PageInfo>()
{
new PageInfo()
{
Id = new Guid("cfea37e8-657a-43ff-b73c-5df191bad7c9"),
VirtualPath = "somecategory/somesubcategory/content1"
},
new PageInfo()
{
Id = new Guid("9a19078b-2d7e-4fc6-ae1d-3e76f8be46e5"),
VirtualPath = "somecategory/somesubcategory/content2"
},
new PageInfo()
{
Id = new Guid("31d4ea88-aff3-452d-b1c0-fa5e139dcce5"),
VirtualPath = "somecategory/somesubcategory/content3"
}
};

httpContext.Cache.Insert(
key: key,
value: pages,
dependencies: null,
absoluteExpiration: System.Web.Caching.Cache.NoAbsoluteExpiration,
slidingExpiration: TimeSpan.FromMinutes(15),
priority: System.Web.Caching.CacheItemPriority.NotRemovable,
onRemoveCallback: null);
}
}
}

return (IEnumerable<PageInfo>)pages;
}
}

You can register the route with MVC like this.

routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

// Case sensitive lowercase URLs are faster.
// If you want to use case insensitive URLs, you need to
// adjust the matching code in the `Equals` method of the CustomPageRoute.
routes.LowercaseUrls = true;

routes.Add(
name: "CustomPage",
item: new CustomPageRoute());

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

The above assumes you have a CustomPageController with a Details action method.

public class CustomPageController : Controller
{
public ActionResult Details(Guid id)
{
// Do something with id

return View();
}
}

You can change the route if you want it to go to a different controller action (or even make them constructor parameters).

ASP.NET MVC multi-level areas

No, it is not possible.

However, as an alternative to areas, you could go with MvcCodeRouting, which allows multiple levels of controllers and automatically structures the routes accordingly.

Full Disclosure: I am not involved with the MvcCodeRouting project, nor have I used it personally.

How can I configure sub routes in MVC

Try using a catch all route

routes.MapRoute(
name: "Category",
url: "shop/hire/{*categories}",
defaults: new { controller = "Home", action = "Index" }
);

and in your action you can parse the value to get your categories and product

public ActionResult Index(string catagories) { ... }

Why map special routes first before common routes in asp.net mvc?

The routing engine will take the first route that matches the supplied URL and attempt to use the route values in that route.

The reason why this happens is because the RouteTable is used like a switch-case statement. Picture the following:

int caseSwitch = 1;
switch (caseSwitch)
{
case 1:
Console.WriteLine("Case 1");
break;
case 1:
Console.WriteLine("Second Case 1");
break;
default:
Console.WriteLine("Default case");
break;
}

If caseSwitch is 1, the second block is never reached because the first block catches it.

Route classes follow a similar pattern (in both the GetRouteData and GetVirtualPath methods). They can return 2 states:

  1. A set of route values (or a VirtualPath object in the case of GetVirtualPath). This indicates the route matched the request.
  2. null. This indicates the route did not match the request.

In the first case, MVC uses the route values that are produced by the route to lookup the Action method. In this case, the RouteTable is not analyzed any further.

In the second case, MVC will check the next Route in the RouteTable to see if it matches with the request (the built in behavior matches the URL and constraints, but technically you can match anything in the HTTP request). And once again, that route can return a set of RouteValues or null depending on the result.

If you try to use a switch-case statement as above, the program won't compile. However, if you configure a route that never returns null or returns a RouteValues object in more cases than it should, the program will compile, but will misbehave.

Misconfiguration Example

Here is the classic example that I frequently see posted on StackOverflow (or some variant of it):

public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.MapRoute(
name: "CustomRoute",
url: "{segment1}/{action}/{id}",
defaults: new { controller = "MyController", action = "Index", id = UrlParameter.Optional }
);

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

In this example:

  1. CustomRoute will match any URL that is either 1, 2, or 3 segments in length (note that segment1 is required because it has no default value).
  2. Default will match any URL that is 0, 1, 2, or 3 segments in length.

Therefore, if the application is passed the URL \Home\About, the CustomRoute will match, and supply the following RouteValues to MVC:

  1. segment1 = "Home"
  2. controller = "MyController"
  3. action = "About"
  4. id = {}

This will make MVC look for an action named About on a controller named MyControllerController, which will fail if it doesn't exist. The Default route is an unreachable execution path in this case because even though it will match a 2-segment URL, the framework will not give it the opportunity to because the first match wins.

Fixing the Configuration

There are several options on how to proceed to fix the configuration. But all of them depend on the behavior that the first match wins and then routing won't look any further.

Option 1: Add one or more Literal Segments

public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.MapRoute(
name: "CustomRoute",
url: "Custom/{action}/{id}",

// Note, leaving `action` and `id` out of the defaults
// makes them required, so the URL will only match if 3
// segments are supplied begining with Custom or custom.
// Example: Custom/Details/343
defaults: new { controller = "MyController" }
);

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

Option 2: Add 1 or more RegEx Constraints

public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.MapRoute(
name: "CustomRoute",
url: "{segment1}/{action}/{id}",
defaults: new { controller = "MyController", action = "Index", id = UrlParameter.Optional },
constraints: new { segment1 = @"house|car|bus" }
);

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

Option 3: Add 1 or more Custom Constraints

public class CorrectDateConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
var year = values["year"] as string;
var month = values["month"] as string;
var day = values["day"] as string;

DateTime theDate;
return DateTime.TryParse(year + "-" + month + "-" + day, System.Globalization.CultureInfo.InvariantCulture, DateTimeStyles.None, out theDate);
}
}

public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.MapRoute(
name: "CustomRoute",
url: "{year}/{month}/{day}/{article}",
defaults: new { controller = "News", action = "ArticleDetails" },
constraints: new { year = new CorrectDateConstraint() }
);

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

Option 4: Make Required Segments + Make the Number of Segments not Match Existing Routes

public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.MapRoute(
name: "CustomRoute",
url: "{segment1}/{segment2}/{action}/{id}",
defaults: new { controller = "MyController" }
);

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

In the above case, the CustomRoute will only match a URL with 4 segments (note these can be any values). The Default route as before only matches URLs with 0, 1, 2, or 3 segments. Therefore there is no unreachable execution path.

Option 5: Implement RouteBase (or Route) for Custom Behavior

Anything that routing doesn't support out of the box (such as matching on a specific domain or subdomain) can be done by implementing your own RouteBase subclass or Route subclass. It is also the best way to understand how/why routing works the way it does.

public class SubdomainRoute : Route
{
public SubdomainRoute(string url) : base(url, new MvcRouteHandler()) {}

public override RouteData GetRouteData(HttpContextBase httpContext)
{
var routeData = base.GetRouteData(httpContext);
if (routeData == null) return null; // Only look at the subdomain if this route matches in the first place.
string subdomain = httpContext.Request.Params["subdomain"]; // A subdomain specified as a query parameter takes precedence over the hostname.
if (subdomain == null) {
string host = httpContext.Request.Headers["Host"];
int index = host.IndexOf('.');
if (index >= 0)
subdomain = host.Substring(0, index);
}
if (subdomain != null)
routeData.Values["subdomain"] = subdomain;
return routeData;
}

public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
{
object subdomainParam = requestContext.HttpContext.Request.Params["subdomain"];
if (subdomainParam != null)
values["subdomain"] = subdomainParam;
return base.GetVirtualPath(requestContext, values);
}
}

This class was borrowed from: Is it possible to make an ASP.NET MVC route based on a subdomain?

public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.Add(new SubdomainRoute(url: "somewhere/unique"));

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

NOTE: The real gotcha here is that most people assume that their routes should all look like the Default route. Copy, paste, done, right? Wrong.

There are 2 problems that commonly arise with this approach:

  1. Pretty much every other route should have at least one literal segment (or a constraint if you are into that sort of thing).
  2. The most logical behavior is usually to make the rest of the routes have required segments.

Another common misconception is that optional segments mean you can leave out any segment, but in reality you can only leave off the right-most segment or segments.

Microsoft succeeded in making routing convention-based, extensible, and powerful. They failed in making it intuitive to understand. Virtually everyone fails the first time they try it (I know I did!). Fortunately, once you understand how it works it is not very difficult.



Related Topics



Leave a reply



Submit