ASP.NET MVC 4 RC: Getting WebApi and Areas to play nicely
ASP.NET MVC 4 is a framework for building scalable, standards-based web applications using well-established design patterns and the power of ASP.NET and the .NET Framework. Two powerful features of ASP.NET MVC 4 are Areas and WebApi. Areas let you partition Web applications into smaller functional groupings, while WebApi is a platform for building RESTful applications. Unfortunately, combining both features is not supported by ASP.NET MVC 4. In this blog, I discuss these limitations and present a possible solution.
Background
The WebApi and Areas features play an important role in the project I am currently working on. In this project, a web application is developed for multiple types of end-users. Areas are used to create separate frontends for each type of end-user. WebApi is used as part of an interaction framework (knockoutjs) that enriches the user experience. Below is a list of relevant design decisions that were made:
- The main MVC application resides in the root of the solution.
- All administrator functionality resides in a separate area.
- Each external party has its own area.
- Each area, including the root, constitutes a well separated functional block. Functionality from one area may not be exposed to another area. This is to prevent unauthorized access of data.
- Each area, including the root, has its own RESTfull API (WebApi).
During the development of this web application, I encountered an important limitation of WebApi when used in conjunction with Areas.
Routing and WebApi
Both regular and WebApi calls use ASP.NET MVC’s routing mechanism to translate HTTP requests to the appropriate controller action. However, only regular calls support areas, while WebApi calls are “arealess”. As a result, WebApi controllers in different areas are actually accessible from all areas. Additionally, having multiple WebApi controllers with identical names in different areas will produce an exception:
Multiple types were found that match the controller named ‘clients’. This can happen if the route that services this request (‘api/{controller}/{id}’) found multiple controllers defined with the same name but differing namespaces, which is not supported.
The request for ‘clients’ has found the following matching controllers: MvcApplication.Areas.Administration.Controllers.Api.ClientsController MvcApplication.Controllers.Api.ClientsController
The error message pretty much sums up the problem: ASP.NET MVC 4 RC does not support the partitioning of WebApi controllers across areas.
IHttpControllerSelector
The culprit is the DefaultHttpControllerSelector
which is ASP.NET WebAPI’s default implementation of the IHttpControllerSelector
interface. This class is responsible for selecting the appropriate IHttpController
(the interface implemented by ApiController
), when provided with a HTTP request message. At the heart of the DefaultHttpControllerSelector
lies the HttpControllerTypeCache
. This class runs through all assemblies that are used by the application and caches all types that implement the IHttpController
. The SelectController
method of the DefaultHttpControllerSelector
uses this cache to lookup a matching type for the given controller name. This operation can end in three different manners:
- No matching types were found, which results in an
HttpStatus.NotFound
(404). - One matching type was found, which is returned by the method and ASP.NET MVC continues to process the request.
- Multiple matches were found, which results in an exception similar to one displayed earlier.
In search for a solution
Fortunately, through the power of Inversion of Control, developers can inject their own implementation of IHttpControllerSelector
. In a related blog by Andrew Malkov, he attempts to tackle the problem by creating a custom implementation called AreaHttpControllerSelector
.
This class allows area specific WebApi controllers to co-exist, provided one makes a minor modification to the WebApi routes. In order to function, a default route parameter called “area” must be added to the HttpRoute
definition in the AreaRegistration
file.
AreaRegistration.cs
1context.Routes.MapHttpRoute(2 name: "Administration_DefaultApi",3 routeTemplate: "Administration/api/{controller}/{id}",4 defaults: new { area = "Administration", id = RouteParameter.Optional }5);
Unfortunately, adding this extra parameter introduces a new limitation: Querystring parameters on WebApi calls no longer function. E.g. GET /Administration/api/clients
will work, but GET /Administration/api/clients?firstname=john
will result in a 404.
Part of the problem lies in the manner in which AreaRegistration
is used to define routes. Consider the AdministrationAreaRegistration
below:
AdministrationAreaRegistration.cs
1public class AdministrationAreaRegistration : AreaRegistration2{3 public override string AreaName4 {5 get6 {7 return "Administration";8 }9 }1011 public override void RegisterArea(AreaRegistrationContext context)12 {13 context.Routes.MapHttpRoute(14 name: "Administration_DefaultApi",15 routeTemplate: "Administration/api/{controller}/{id}",16 defaults: new { id = RouteParameter.Optional }17 );1819 context.MapRoute(20 "Administration_default",21 "Administration/{controller}/{action}/{id}",22 new { action = "Index", id = UrlParameter.Optional }23 );24 }25}
The first route defines how ApiContollers can be reached, while the second route defines how regular controllers can be reached. Both registrations use a different method for registering the route in order to differentiate between normal calls and WebApi calls. Routes registered through MapHttpRoute are meant for WebApi controllers while routes registered through MapRoute
are meant for regular controllers.
Note that MapHttpRoute
is called on the Routes
collection, whereas MapRoute is called on the AreaRegistrationContext
itself. This implies that there is a difference between the default MapRoute and the one provided by the AreaRegistrationContext
.
After digging through the sourcecode of ASP.NET MVC, I found that the most notable difference is that the MapRoute of AreaRegistrationContext
incorporates the AreaName
into the route’s metadata. Specifically, the value of the AreaName
property is added to the route’s DataTokens
.
Solution – Part 1
I created a MapHttpRoute extension method for the AreaRegistrationContext that performed a similar operation as the AreaRegistrationContext.MapRoute method.
AreaRegistrationContextExtensions.cs
1public static class AreaRegistrationContextExtensions2{3 public static Route MapHttpRoute(this AreaRegistrationContext context, string name, string routeTemplate)4 {5 return context.MapHttpRoute(name, routeTemplate, null, null);6 }78 public static Route MapHttpRoute(this AreaRegistrationContext context, string name, string routeTemplate, object defaults)9 {10 return context.MapHttpRoute(name, routeTemplate, defaults, null);11 }1213 public static Route MapHttpRoute(this AreaRegistrationContext context, string name, string routeTemplate, object defaults, object constraints)14 {15 var route = context.Routes.MapHttpRoute(name, routeTemplate, defaults, constraints);16 if (route.DataTokens == null)17 {18 route.DataTokens = new RouteValueDictionary();19 }20 route.DataTokens.Add("area", context.AreaName);21 return route;22 }23}
To use the new extension method, remove the Routes property from the call chain:
AreaRegistrationContextExtensions.cs
1context.MapHttpRoute(2 name: "Administration_DefaultApi",3 routeTemplate: "Administration/api/{controller}/{id}",4 defaults: new { id = RouteParameter.Optional }5);
Now both the regular routes and the WebApi routes have knowledge of their corresponding area.
Solution – Part 2
The second part of the solution is to create an implementation of IHttpControllerSelector
that actually uses the area name. I took the AreaHttpControllerSelector
class from Andrew Malkov’s blog post and used it as a base for my own solution.
AreaHttpControllerSelector.cs
1namespace MvcApplication.Infrastructure.Dispatcher2{3 using System;4 using System.Collections.Concurrent;5 using System.Collections.Generic;6 using System.Globalization;7 using System.Linq;8 using System.Net.Http;9 using System.Web.Http;10 using System.Web.Http.Controllers;11 using System.Web.Http.Dispatcher;1213 public class AreaHttpControllerSelector : DefaultHttpControllerSelector14 {15 private const string AreaRouteVariableName = "area";1617 private readonly HttpConfiguration _configuration;18 private readonly Lazy<ConcurrentDictionary<string, Type>> _apiControllerTypes;1920 public AreaHttpControllerSelector(HttpConfiguration configuration)21 : base(configuration)22 {23 _configuration = configuration;24 _apiControllerTypes = new Lazy<ConcurrentDictionary<string, Type>>(GetControllerTypes);25 }2627 public override HttpControllerDescriptor SelectController(HttpRequestMessage request)28 {29 return this.GetApiController(request);30 }3132 private static string GetAreaName(HttpRequestMessage request)33 {34 var data = request.GetRouteData();35 if (data.Route.DataTokens == null)36 {37 return null;38 }39 else40 {41 object areaName;42 return data.Route.DataTokens.TryGetValue(AreaRouteVariableName, out areaName) ? areaName.ToString() : null;43 }44 }4546 private static ConcurrentDictionary<string, Type> GetControllerTypes()47 {48 var assemblies = AppDomain.CurrentDomain.GetAssemblies();4950 var types = assemblies51 .SelectMany(a => a52 .GetTypes().Where(t =>53 !t.IsAbstract &&54 t.Name.EndsWith(ControllerSuffix, StringComparison.OrdinalIgnoreCase) &&55 typeof(IHttpController).IsAssignableFrom(t)))56 .ToDictionary(t => t.FullName, t => t);5758 return new ConcurrentDictionary<string, Type>(types);59 }6061 private HttpControllerDescriptor GetApiController(HttpRequestMessage request)62 {63 var areaName = GetAreaName(request);64 var controllerName = GetControllerName(request);65 var type = GetControllerType(areaName, controllerName);6667 return new HttpControllerDescriptor(_configuration, controllerName, type);68 }6970 private Type GetControllerType(string areaName, string controllerName)71 {72 var query = _apiControllerTypes.Value.AsEnumerable();7374 if (string.IsNullOrEmpty(areaName))75 {76 query = query.WithoutAreaName();77 }78 else79 {80 query = query.ByAreaName(areaName);81 }8283 return query84 .ByControllerName(controllerName)85 .Select(x => x.Value)86 .Single();87 }88 }8990 public static class ControllerTypeSpecifications91 {92 public static IEnumerable<KeyValuePair<string, Type>> ByAreaName(this IEnumerable<KeyValuePair<string, Type>> query, string areaName)93 {94 var areaNameToFind = string.Format(CultureInfo.InvariantCulture, ".{0}.", areaName);9596 return query.Where(x => x.Key.IndexOf(areaNameToFind, StringComparison.OrdinalIgnoreCase) != -1);97 }9899 public static IEnumerable<KeyValuePair<string, Type>> WithoutAreaName(this IEnumerable<KeyValuePair<string, Type>> query)100 {101 return query.Where(x => x.Key.IndexOf(".areas.", StringComparison.OrdinalIgnoreCase) == -1);102 }103104 public static IEnumerable<KeyValuePair<string, Type>> ByControllerName(this IEnumerable<KeyValuePair<string, Type>> query, string controllerName)105 {106 var controllerNameToFind = string.Format(CultureInfo.InvariantCulture, ".{0}{1}", controllerName, AreaHttpControllerSelector.ControllerSuffix);107108 return query.Where(x => x.Key.EndsWith(controllerNameToFind, StringComparison.OrdinalIgnoreCase));109 }110 }111}
If you want to learn more about the technical details of the solution, I suggest you read Andrew’s excellent blog post first. The most significant modifications are:
- Changed the GetAreaName method in order to retrieve the area name from the DataTokens property rather than the RouteData.
- Added support for “arealess” WebApi controllers (e.g. those that reside in the root) to the GetControllerType method.
- Removed the fallback mechanism from the SelectController method. The original implementation would call the SelectController method of the base-class in case GetControllerType failed to produce a result. I preferred an approach where the responsibility of successful controller selection resided in AreaHttpControllerSelector.
Finally, to inject the new AreaHttpControllerSelector class, the following line must be added to the Application_Start method in the Global.asax.cs
Global.asax.cs
1GlobalConfiguration.Configuration.Services.Replace(typeof(IHttpControllerSelector), new AreaHttpControllerSelector(GlobalConfiguration.Configuration));
After these modifications everything worked as expected!
Closing notes
The AreaHttpControllerSelector
class presented in this blog post was designed to tackle a specific limitation of the DefaultHttpControllerSelector
. However, the new implementation lacks several features which the default implementation has:
- The AreaHttpControllerSelector searches the current loaded assemblies for the appropriate controllers, while the DefaultHttpControllerSelector also searches all referenced assemblies.
- The DefaultHttpControllerSelector uses a much more elaborate mechanism for caching the WebApi controllers. The performance impact can be notable when the web application is loaded for the first time (cold-boot time). There is no difference in performance in subsequent requests. For more info, see this blog post by Alexander Beletsky.
- The DefaultHttpControllerSelector throws more meaningful exceptions when no or multiple WebApi controllers are found.
Hope you enjoyed reading this blog post! In my next installment, I will show how you can get ASP.NET MVC to automagically generate knockoutjs bindings for your forms.
UPDATE: 6 september 2012
Several developers have contacted me about a scenario they encountered where the DataTokens property of the route variable is null. My implementation assumes that the DataTokens
property is always initialized and will not function properly if this property is null. This behavior is most likely caused by recent changes in the ASP.NET MVC framework and may be actually be a bug in the framework. I’ve updated my code to handle this scenario.