Strongly typed routing for ASP.NET MVC 6 with IApplicationModelConvention

Β· 1364 words Β· 7 minutes to read

This is something I hacked together last night, but it was a very interesting exercise into customizing the new (or rather, future) ASP.NET MVC 6 to suit your needs.

If you visit this blog from time to time, some time ago I blogged about building strongly typed routing provider for ASP.NET Web API (code is here). That was built around extensibility points provided by the direct routing mechanism (better known as direct routing’s default implementation - attribute routing).

So I thought, it would be fun to port this solution to MVC 6. However, while MVC 6 supports attribute routing, it does not provide the same abstractions over the routing mechanism. Instead it exposes something new for both MVC and Web API developers - IApplicationModelConvention, which is what we’ll use here.

Background πŸ”—

If you skim through the old Web API post you’ll get the idea, but nevertheless, briefly - imagine we want to have something like this:

services.AddMvc();

services.Configure<MvcOptions>(opt =>  
{  
opt.EnableTypedRouting();  
opt.GetRoute("homepage", c => c.Action<HomeController>(x => x.Index()));  
opt.GetRoute("aboutpage/{name}", c => c.Action<HomeController>(x => x.About(Param<string>.Any)));  
opt.PostRoute("sendcontact", c => c.Action<HomeController>(x => x.Contact()));  
});  

This is effectvely attribute routing - since we have one route definition pointing to a specific action (no catch all or catch many definitions that you might have in regular routing) - except its centralized.

In fact I noticed the MVC team is actually considering supporting something like this themselves.

IApplicationModelConvention πŸ”—

IApplicationModelConvention is the recommended way to customize the framework to your needs. While in Web API or MVC, in order to perform any more advanced customization, you’d have to replace various internal services - potentially breaking other things in the process (example: custom Web API action selector typically would break the API explorer), in MVC 6 IApplicationModelConvention is a one stop shop for convenient customizations.

The interface is very simple:

namespace Microsoft.AspNet.Mvc.ApplicationModels  
{  
public interface IApplicationModelConvention  
{  
void Apply([NotNullAttribute]ApplicationModel application);  
}  
}  

With the ApplicationModel exposing all controllers and filters that have been discovered by MVC. Then you can iterate through them, and access all discovered actions, action parameters and all useful framework components like that.

You can then plug it in against your MVC options object in the Startup class of your MVC application, and the convention will applied by the MVC runtime as your application is started.


services.Configure<MvcOptions>(opt =>  
{  
opts.ApplicationModelConventions.Add(new MyApplicationModelConvention());  
});  

Just to give you example - we used a custom IApplicationModelConvention in the Omnisharp project to apply HttpPost attribute to all actions automagically.

Building up typed routing on top of IApplicationModelConvention πŸ”—

So, kind of similarly to what happened in the above mentioned Omnisharp example, we’ll use a custom convention to apply attribute routing to all of the actions a user specifies in the typed routing setup.

So let’s start by defining our TypedRoute class - which will represent the typed route model. It should inherit from AttributeRouteModel, the standard way of MVC 6 for storing attribute route information.

public class TypedRoute : AttributeRouteModel  
{  
public TypedRoute(string template)  
{  
Template = template;  
HttpMethods = new string[0];  
}

public TypeInfo ControllerType { get; private set; }

public MethodInfo ActionMember { get; private set; }

public IEnumerable<string> HttpMethods { get; private set; }

public TypedRoute Controller<TController>()  
{  
ControllerType = typeof(TController).GetTypeInfo();  
return this;  
}

public TypedRoute Action<T, U>(Expression<Func<T, U>> expression)  
{  
ActionMember = GetMethodInfoInternal(expression);  
ControllerType = ActionMember.DeclaringType.GetTypeInfo();  
return this;  
}

public TypedRoute Action<T>(Expression<Action<T>> expression)  
{  
ActionMember = GetMethodInfoInternal(expression);  
ControllerType = ActionMember.DeclaringType.GetTypeInfo();  
return this;  
}

private static MethodInfo GetMethodInfoInternal(dynamic expression)  
{  
var method = expression.Body as MethodCallExpression;  
if (method != null)  
return method.Method;

throw new ArgumentException("Expression is incorrect!");  
}

public TypedRoute WithName(string name)  
{  
Name = name;  
return this;  
}

public TypedRoute ForHttpMethods(params string[] methods)  
{  
HttpMethods = methods;  
return this;  
}  
}  

The class is virtually identical to the original Web API one, with a couple of small differences. Since pretty much all MVC 6 internals deal with TypeInfo rather than Type, we use that too. The crucial properties - Template (for the route template) and Name (for the route name) are actually defined in the base AttributeRouteModel for us.

We also have a tiny fluent API that allows us to specify HTTP Methods relevant for a given route and the name of the route. The rest is rather self explanatory.

Next step is to create our IApplicationModelConvention. We’ll store the route dictionary there, and use it when iterating through different controllers to annotate its actions with appropriate attribute routing.

public class TypedRoutingApplicationModelConvention : IApplicationModelConvention  
{  
internal static readonly Dictionary<TypeInfo, List<TypedRoute>> Routes = new Dictionary<TypeInfo, List<TypedRoute>>();

public void Apply(ApplicationModel application)  
{  
foreach (var controller in application.Controllers)  
{  
if (Routes.ContainsKey(controller.ControllerType))  
{  
var typedRoutes = Routes[controller.ControllerType];  
foreach (var route in typedRoutes)  
{  
var action = controller.Actions.FirstOrDefault(x => x.ActionMethod == route.ActionMember);  
if (action != null)  
{  
action.AttributeRouteModel = route;  
foreach (var method in route.HttpMethods)  
{  
action.HttpMethods.Add(method);  
}  
}  
}  
}  
}  
}  
}  

The routes will be stored in a dictionary where the key is TypeInfo (a controller) and a value is a list of our TypedRoute instances - each pointing to a different method.

Inside our application model convention we iterate through the controllers, look up the corresponding entry in our Routes dictionary. Then we try to match the action on the controller with the action inside the Routes dictionary entry for that controller.

Finally, we assign our route (remember it derives from AttributeRouteModel) to the property AttributeRouteModel on the action and copy the supported HTTP methods. Should our route have no default HTTP methods, MVC 6 will treat is as if it supported all of them for us.

Adding extension methods to allow registration πŸ”—

So we have our infrastructure set up - we have a custom IApplicationModelConvention and a TypedRoute model which extends the default AttributeRouteModel. All that’s left at this point is to add some plumbing that will allow the user to get his routes into our Routes dictionary.

Since we wanted to to do this off the MvcOptions object (see our introduction in the post), let’s add the extension methods there.

public static class MvcOptionsExtensions  
{  
public static TypedRoute GetRoute(this MvcOptions opts, string template, Action<TypedRoute> configSetup)  
{  
return AddRoute(template, configSetup).ForHttpMethods("GET");  
}

public static TypedRoute PostRoute(this MvcOptions opts, string template, Action<TypedRoute> configSetup)  
{  
return AddRoute(template, configSetup).ForHttpMethods("POST");  
}

public static TypedRoute PutRoute(this MvcOptions opts, string template, Action<TypedRoute> configSetup)  
{  
return AddRoute(template, configSetup).ForHttpMethods("PUT");  
}

public static TypedRoute DeleteRoute(this MvcOptions opts, string template, Action<TypedRoute> configSetup)  
{  
return AddRoute(template, configSetup).ForHttpMethods("DELETE");  
}

public static TypedRoute TypedRoute(this MvcOptions opts, string template, Action<TypedRoute> configSetup)  
{  
return AddRoute(template, configSetup);  
}

private static TypedRoute AddRoute(string template, Action<TypedRoute> configSetup)  
{  
var route = new TypedRoute(template);  
configSetup(route);

if (TypedRoutingApplicationModelConvention.Routes.ContainsKey(route.ControllerType))  
{  
var controllerActions = TypedRoutingApplicationModelConvention.Routes[route.ControllerType];  
controllerActions.Add(route);  
}  
else  
{  
var controllerActions = new List<TypedRoute> { route };  
TypedRoutingApplicationModelConvention.Routes.Add(route.ControllerType, controllerActions);  
}

return route;  
}

public static void EnableTypedRouting(this MvcOptions opts)  
{  
opts.ApplicationModelConventions.Add(new TypedRoutingApplicationModelConvention());  
}  
}  

There isn’t really much exciting stuff going on here:

  • we have EnableTypedRouting method which will register our TypedRoutingApplicationModelConvention into opts.ApplicationModelConventions.Add
  • we have TypedRoute method which is the generic version (for all HTTP verbs)
  • we have 4 versions of methods allowing to register GET, POST, PUT, DELETE methods with a shorthand syntax. Same is available through the main TypedRoute too, so this is just for convenience

And that’s everything. As a final touch we can throw in a simple Params class to ignore the action arguments (those are defined by the template anyway):

public static class Param<TValue>  
{  
public static TValue Any  
{  
get { return default(TValue); }  
}  
}  

And that’s everything, we can now use our typed routing like we wished all along:

services.AddMvc();

services.Configure<MvcOptions>(opt =>  
{  
opt.EnableTypedRouting();  
opt.GetRoute("homepage", c => c.Action<HomeController>(x => x.Index()));  
opt.GetRoute("aboutpage/{name}", c => c.Action<HomeController>(x => x.About(Param<string>.Any)));  
opt.PostRoute("sendcontact", c => c.Action<HomeController>(x => x.Contact()));  
});  

This creates:

    • a GET route to /homepage
    • a GET route to /aboutpage/{name}
    • a POST route to /sendcontact

All of which go against the relevant methods on our HomeController. Since the API is fluent, you can also give the routes names so that you can use them with i.e. link generation.

opt.GetRoute("homepage", c => c.Action<HomeController>(x => x.Index())).WithName("foo");  

And that’s about it - a 20 min hack to get typed routing into MVC6. As such - disclaimer here - it’s not perfect, but it demonstrates the usage of IApplicationModelConvention for doing interesting MVC customizations.

About


Hi! I'm Filip W., a cloud architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP