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
I think this could be done by using regular expression constraints for the route parameters, but I wanted routes to be defined in a way that was easier and more maintainable. First I tried to extend the MvcRouteHandler and in the GetHttpHandler method check for different querystring parameters and change the "action" route value accordingly. This worked but felt like the wrong way.

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.

8 comments:

Anonymous said...

is this an open source project that you are working on?

Torkel Ödegaard said...

Not yet, but I hope to create one for it soon.

Rickard said...

nice, I haven't seen this done before. Url routing engine in ASP.NET MVC is pretty good, the only innovative thing in ASP.NET MVC :)

david said...

This looks good; i'll look into it more closely.

Remember that Routing is no longer baked into ASP.NET MVC; it's available also to Web Forms...

Anonymous said...

Please contribute this to MVC contrib

Torkel Ödegaard said...

That is a good idea, I will try.

Anonymous said...

But in this case, why not have urls like /diff/1/2/some/path and /changeset/25/some/path?

Torkel Ödegaard said...

Yes, that is an option I considered. I am not sure which I like best yet.