Quantcast
Viewing all articles
Browse latest Browse all 10

Mapping ASP.Net MVC Routes with Expressions

Update: I’ve added automatic constraints to this.

When you’re working with ASP.Net MVC, routing is a very important thing to get just right. Unfortunately, the default routing options are pretty fragile. By default you have to resort to ‘magic strings’, anonymous types and/or string-based dictionaries etc. Depending on your requirements, this kind of routing can become quite the PITA.

Looking at my personal requirements: I have about 120 routes to map (including localization and a fall-back to managed content in the database). One day I got so fed up with the default routing options, that I decided to go for my own MapRoute() extension method.

Here’s just a fraction of my current RegisterRoutes() method (I’ve removed localization, for simplicity):

routes.MapRoute<ProductsController>("search/{query}/{pageIndex}", c => c.Search("", 0));
routes.MapRoute<ProductsController>("new/{pageIndex}",            c => c.NewProducts(0));
routes.MapRoute<ProductsController>("soon/{pageIndex}",           c => c.ComingProducts(0));

Before my changes it looked like this (again, without localization; with localization, there would be 3 more per route):

routes.MapRoute("Search", "search/{pageIndex}", new { controller = "Products",	action = "Search", pageIndex = 0 });
routes.MapRoute("newproducts", "new/{pageIndex}", new { controller = "Products", action = "NewProducts", pageIndex = 0 });
routes.MapRoute("soonproducts", "soon/{pageIndex}", new { controller = "Products", action = "ComingProducts", pageIndex = 0 });

Notice the difference?

Getting rid of magic strings was not my only motivation, though. I have chopped-up my application in different modules. The two main modules are the actual customer front-end and the administrative product/category/brand/news/foo/bar management pages. As it happens, both have a ProductsController class… If you go for default routing, you’ll run in to issues: ASP.Net MVC won’t be able to distinguish the one class from the other and throw you a nice exception telling you to specify the namespaces to disambiguate the controllers. The default solution would be specifying the namespace using magic strings, yet again…

Here’s the extension method (the most extended overload):

public static void MapRoute<T>(this RouteCollection routes, string url, Expression<Func<T, ActionResult>> action, RouteValueDictionary defaults, RouteValueDictionary constraints)
	where T : Controller
{
	if (routes == null) throw new ArgumentNullException("routes");
	if (url == null) throw new ArgumentNullException("url");
	if (action == null) throw new ArgumentNullException("action");

	var methodCall = action.Body as MethodCallExpression;
	if (methodCall == null) throw new ArgumentException(string.Format("The action '{0}' does not represent a method call.", action), "action");

	// merge action defaults with explicit defaults
	defaults = GetActionDefaults(methodCall, defaults);

	// verify constraints
	if(constraints != null && constraints.Any(c => !(c.Value is IRouteConstraint || c.Value is string)))
		throw new ArgumentException("The constraints dictionary contains illegal elements. Constraint values must be strings (regex) or IRouteConstraint implementations.", "constraints");

	var route = new Route(url, new MvcRouteHandler())
	{
		Defaults = new RouteValueDictionary(defaults),
		Constraints = constraints,
		DataTokens = new RouteValueDictionary { { NamespacesKey, new[] { typeof(T).Namespace } } }
	};

	routes.Add(url, route);
}

Here’s the GetActionDefaults method that extracts the default values from the method signature:

private static RouteValueDictionary GetActionDefaults(MethodCallExpression call, RouteValueDictionary defaults)
{
	var controllerName = call.Method.DeclaringType.Name;
	if (controllerName.EndsWith(ControllerSuffix, StringComparison.OrdinalIgnoreCase))
		controllerName = controllerName.Remove(controllerName.Length - ControllerSuffix.Length, ControllerSuffix.Length);

	defaults.Add(ControllerKey, controllerName);

	var attributes = call.Method.GetCustomAttributes(typeof(ActionNameAttribute), true);
	defaults.Add(ActionKey, attributes.Length == 1 ? ((ActionNameAttribute) attributes[0]).Name : call.Method.Name);

	var parameters = call.Method.GetParameters();
	for (var i = 0; i < parameters.Length; i++)
	{
		var expression = call.Arguments[i] as ConstantExpression;
		if (expression != null) defaults.Add(parameters[i].Name, expression.Value);
	}

	return defaults;
}

Result:

  • Who needed that route name anyway?
  • No more magic string (except fot the url)
  • Namespaces are registered automatically, modules/areas no longer require special treatment
  • Defaults are resolved from the action expression (additional defaults are still suppported)
  • Constraints are checked as soon as the route is mapped (as good as it gets, I suppose)
  • If you’re happy with ‘http://domain.com/{cultureName}/…’ url’s, you’re good to go as well, just pass along an array of supported culture names:
public static void MapRoute<T>(this RouteCollection routes, string url, Expression<Func<T, ActionResult>> action, string[] cultureNames, RouteValueDictionary defaults, RouteValueDictionary constraints)
	where T : Controller
{
	routes.MapRoute(url, action, new RouteValueDictionary(defaults), constraints);

	foreach (var cultureName in cultureNames)
	{
		var d = new RouteValueDictionary(defaults) {{"lang", cultureName}};
		routes.MapRoute(string.Format("{0}/{1}", cultureName, url), action, d, constraints);
	}
}

Image may be NSFW.
Clik here to view.
Image may be NSFW.
Clik here to view.

Viewing all articles
Browse latest Browse all 10

Trending Articles