In the ASP.NET MVC application I am working on I have a lot of catch-all routes, for example:
routes.MapRoute("History", "history/{*path}", new {controller = "History", action = "ViewHistory", path = ""});
The asterisk in the path url segment is a catch-all parameter, which means that everything in the url after "history/" will be captured in the path parameter (not including the querystring). In my scenario I want the same url path mapped to different controller actions depending on querystring parameters . This scenario might not be common for most mvc apps but it led me to write some interesting routing extensions.
This is the sort of route handling I needed:
- /history/some/path/ => mapped to action ViewHistory
- /history/some/path?cs=123 => mapped to action ViewChangeset
- /history/some/path?r1=123&r2=124 => mapped to action ViewDiff
What I ended up doing was subclassing the Route class and creating a sort of fluent interface for defining routes. The end result looked like this:
SagaRoute .MappUrl("history/{*urlPath}") .IfQueryStringExists("cs") .ToAction<HistoryController>(x => x.ViewChangeset("", null)) .AddWithName("ChangesetDetail", routes); SagaRoute .MappUrl("history/{*urlPath}") .IfQueryStringExists("r1", "r2") .ToAction<DiffController>(x => x.ViewDiff("", null, null)) .AddWithName("Diff", routes); SagaRoute .MappUrl("history/{*urlPath}") .ToAction<HistoryController>(x => x.ViewHistory("", null, null)) .AddWithName("History", routes);
SagaRoute is a class that inherits from the Route class defined in System.Web.Routing. The really interesting function here is ToAction which takes a lambda from which it will extract the default controller name, action name and parameter values. Here is the full source code for the SagaRoute class:
public class SagaRoute : Route { public SagaRoute(string url) : base(url, new MvcRouteHandler()) { } public static SagaRoute MappUrl(string url) { return new SagaRoute(url); } public SagaRoute ToAction<T>(Expression<Func<T, ActionResult>> action) where T : IController { var body = action.Body as MethodCallExpression; Check.Require(body != null, "Expression must be a method call"); Check.Require(body.Object == action.Parameters[0], "Method call must target lambda argument"); string methodName = body.Method.Name; string controllerName = typeof(T).Name; if (controllerName.EndsWith("Controller", StringComparison.OrdinalIgnoreCase)) { controllerName = controllerName.Remove(controllerName.Length - 10, 10); } Defaults = LinkBuilder.BuildParameterValuesFromExpression(body) ?? new RouteValueDictionary(); foreach (var pair in Defaults.Where(x => x.Value == null).ToList()) Defaults.Remove(pair.Key); Defaults.Add("controller", controllerName); Defaults.Add("action", methodName); return this; } public SagaRoute AddWithName(string routeName, RouteCollection routes) { routes.Add(routeName, this); return this; } public SagaRoute IfQueryStringExists(params string[] names) { if (Constraints == null) { Constraints = new RouteValueDictionary(); } Constraints.Add("dummy", new QueryStringExists(names)); return this; } }
This is not a fully featured fluent interface for defining routes, I just did the bare minimum for handling the kind of routes that I needed. Another interesting function is the IfQueryStringExists function which adds a QueryStringExists constraints to the Constraints dictionary. This dictionary can either contain a parameter key and a regular expression string or a parameter key and an object that implements IRouteConstraint.
The QueryStringExists is a very simple class that implements IRouteConstraint. The constraint model is designed to only work with one parameter at a time which is probably ok for most scenarios. What I wanted was a constraint like "if ANY of these parameters exists" which is why when I add the constraint to the dictionary I named the key "dummy". Here is the code for the QueryStringExists constraint:
public class QueryStringExists : IRouteConstraint { public string[] names; public QueryStringExists(params string[] names) { this.names = names; } public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection) { if (routeDirection == RouteDirection.UrlGeneration) return true; var queryString = httpContext.Request.QueryString; return names.Where(name => queryString[name] != null).Any(); } }
I guess most of this is very specific to my scenario but I think the API for adding routes that I ended up with is a small improvement to the standard way of doing it and could be used in other scenarios. For example the standard route can be rewritten like this:
routes.MapRoute( "Default", // Route name "{controller}/{action}/{id}", // URL with parameters new { controller = "Home", action = "Index", id = "" } // Parameter defaults ); SagaRoute .MappUrl("{controller}/{action}/{id}") .ToDefaultAction<HomeController>(x => x.Index(), new {id=""}) .AddWithName("Default", routes);
To support this more common scenario I added a ToDefaultAction method, which does the same as ToAction, but in this method you can also pass in defaults for parameters that aren't included in the arguments to the default action.