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:
- A set of route values (or a
VirtualPath
object in the case ofGetVirtualPath
). This indicates the route matched the request. 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:
CustomRoute
will match any URL that is either 1, 2, or 3 segments in length (note thatsegment1
is required because it has no default value).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:
segment1 = "Home"
controller = "MyController"
action = "About"
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:
- Pretty much every other route should have at least one literal segment (or a constraint if you are into that sort of thing).
- 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
Convert Generic List/Enumerable to Datatable
How to Make the Script Wait/Sleep in a Simple Way in Unity
Combining Two Expressions (Expression≪Func≪T, Bool≫≫)
What Is a Good Pattern For Using a Global Mutex in C#
How to Display a Decimal Value to 2 Decimal Places
Encrypting & Decrypting a String in C#
How to Execute a Stored Procedure Within C# Program
Output Log Using Ftpwebrequest
How to Match an Entire String With a Regex
How to Escape a Double Quote in a Verbatim String Literal
Why Would You Use Expression≪Func≪T≫≫ Rather Than Func≪T≫
How to Convert Json Object to Custom C# Object
Asp.Net MVC 5 Culture in Route and Url
Capture Screenshot of Active Window
Await' Works, But Calling Task.Result Hangs/Deadlocks
Graph Nodes Coordinates Evaluation
How to Specify a [Dllimport] Path At Runtime
Deserializing Polymorphic Json Classes Without Type Information Using Json.Net