Chitika

June 27, 2012

ASP.NET MVC 4 WebAPI. Support Areas in HttpControllerSelector

This article was written for ASP.NET MVC 4 RC (Release Candidate). If you are still using Beta version of ASP.NET MVC 4 then you have to read the previous article.

HttpControllerFactory was deleted in ASP.NET MVC 4 RC. Actually, it was replaced by two interfaces: IHtttpControllerActivator and IHttpControllerSelector.

Unfortunately DefaultHttpControllerSelector still doesn't support Areas by default. To support it you have to write your HttpControllerSelector from scratch. To be honest, I will derive my selector from DefaultHttpControllerSelector.

In this post I will show you how you can do it.

AreaHttpControllerSelector

First of all, you have to derive your class from DefaultHttpControllerSelector class:

    public class AreaHttpControllerSelector : DefaultHttpControllerSelector
    {
        private readonly HttpConfiguration _configuration;

        public AreaHttpControllerSelector(HttpConfiguration configuration)
            : base(configuration)
        {
            _configuration = configuration;
        }
    }

In the constructor mentioned above I called the base constructor and stored the HttpConfiguration. We will use it a little bit later.

My code will use two constants:

        private const string ControllerSuffix = "Controller";
        private const string AreaRouteVariableName = "area";

You can understand why we need first one by name. The second one contains the name of the variable which we will use to specify area name in Routes collection.

Somewhere we have to store all of the API controllers.

        private Dictionary<string, Type> _apiControllerTypes;

        private Dictionary<string, Type> ApiControllerTypes
        {
            get { return _apiControllerTypes ?? (_apiControllerTypes = GetControllerTypes()); }
        }

        private static Dictionary<string, Type> GetControllerTypes()
        {
            var assemblies = AppDomain.CurrentDomain.GetAssemblies();

            var types = assemblies.SelectMany(a => a.GetTypes().Where(t => !t.IsAbstract && t.Name.EndsWith(ControllerSuffix) && typeof(IHttpController).IsAssignableFrom(t)))
                .ToDictionary(t => t.FullName, t => t);

            return types;
        }

Method GetControllerTypes takes all the API controllers types from all of your assemblies, and store it inside the dictionary, where the key is FullName of the type and value is the type itself.
Of course we will set this dictionary only once. And then just use it.

Now we are ready to implement one of the important method:

        public override HttpControllerDescriptor SelectController(HttpRequestMessage request)
        {
            return GetApiController(request) ?? base.SelectController(request);
        }

In that method I try to take the HttpControllerDescriptor from method GetApiController and if it return null then call the base method.

And additional methods:

        private static string GetAreaName(HttpRequestMessage request)
        {
            var data = request.GetRouteData();

            if (!data.Values.ContainsKey(AreaRouteVariableName))
            {
                return null;
            }

            return data.Values[AreaRouteVariableName].ToString().ToLower();
        }

        private Type GetControllerTypeByArea(string areaName, string controllerName)
        {
            var areaNameToFind = string.Format(".{0}.", areaName.ToLower());
            var controllerNameToFind = string.Format(".{0}{1}", controllerName, ControllerSuffix);

            return ApiControllerTypes.Where(t => t.Key.ToLower().Contains(areaNameToFind) && t.Key.EndsWith(controllerNameToFind, StringComparison.OrdinalIgnoreCase))
                    .Select(t => t.Value).FirstOrDefault();
        }

        private HttpControllerDescriptor GetApiController(HttpRequestMessage request)
        {
            var controllerName = base.GetControllerName(request);

            var areaName = GetAreaName(request);
            if (string.IsNullOrEmpty(areaName))
            {
                return null;
            }

            var type = GetControllerTypeByArea(areaName, controllerName);
            if (type == null)
            {
                return null;
            }

            return new HttpControllerDescriptor(_configuration, controllerName, type);
        }
Method GetAreaName just takes area name from HttpRequestMessage.

Method GetControllerTypeByArea are tries to find the controller in the ApiControllerTypes by full name of the controller where the full name contains area's name surrounded by "." (e.g. ".Admin.") and ends with controller name + controller suffix (e.g. UsersController).

And if a controller type found then method GetApiController will create and return back HttpControllerDescriptor.

So, my AreaHttpControllerSelector is ready to be registered in my application.

Registering AreaHttpControllerSelector

The next thing you have to do is to say to your application to use this controller selector instead of DefaultHttpControllerSelector. And fortunately it is really easy - just add one additional line to the end of Application_Start method in Glogal.asax file:
        protected void Application_Start()
        {
            // your default code
                    GlobalConfiguration.Configuration.Services.Replace(typeof(IHttpControllerSelector), new AreaHttpControllerSelector(GlobalConfiguration.Configuration));
        }
That's all.

Using AreaHttpControllerSelector

If you did everything right, now you can forget about that "nightmare" code mentioned above. And just start to use it!

You have to add new HttpRoute to your AreaRegistration.cs file:

        public override void RegisterArea(AreaRegistrationContext context)
        {
            context.Routes.MapHttpRoute(
                name: "Admin_Api",
                routeTemplate: "api/admin/{controller}/{id}",
                defaults: new { area = "admin", id = RouteParameter.Optional }
            );

            // other mappings
        }

Or just use one global route in your Global.asax like:


            routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{area}/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );

That's all. Good luck, and have a nice day.

19 comments:

  1. This is great but it doesn't work if you are passing a complex type in the Get as per below:

    public IEnumerable Get([FromUri] ApiCriteria apiCriteria)

    When you pass values over in the query string that map to the complex type we get:

    No action was found on the controller 'Apis' that matches the request.

    but without the query string values it finds it.

    It works fine using the DefaultHttpControllerSelector and I haven't yet got to the bottom as to why.

    Shame as I could really do with using it to seperate my controllers into areas.

    ReplyDelete
    Replies
    1. Thanks for the comment.
      Yes, for now it is not so easy to fix it.

      But there is one workaround. You can create a special route and pass the parameters in a URL. So, you can split your complex type to simple types.

      For example, instead of:
      public IEnumerable Get(Person person) { ... }
      You can use:
      public IEnumerable Get(string firstName, string lastName) { ... }

      and map the route:
      routes.MapHttpRoute(
      name: "DefaultApiPerson",
      routeTemplate: "api/{area}/{controller}/{firstName}/{lastName}"
      );

      I hope it would help.

      Delete
    2. Hello Andrew and Anonymous.

      I found a solution that supports splitting webapi's across areas while maintaining querystring functionality. The trick is to store the area name in the route's DataTokens, rather than the default parameter list. The latter will confuse the routing engine and break querystring support. The updated version of Andrew's AreaHttpControllerSelector can be found in a blog post I just finished writing:

      ASP.NET MVC 4 RC: Getting WebApi and Areas to play nicely

      Thank you, Andrew, for writing this post. It helped me get on the right track! :)

      Delete
    3. Thank you Martin, for finish this topic.

      Delete
  2. Interestingly the namespaces default value extension got added to the MapHttpRoute in RC. This provides me with, (instead of using areas and this selector), another way of creating granular controllers grouped by namespaces but this suffers the same issue with not honouring the [FromUri] attribute.

    It is a pain because [FromUri] is the way to decorate and consume complex types in the Get methods for sure.

    ReplyDelete
  3. I decided to use your code as a base for a solution for versioning APIs:

    http://www.tuomistolari.net/blog/2012/8/23/webapi-convention-based-versioning-with-accept-headers.html

    ReplyDelete
  4. This is great ! I spent hours figuring out how to make Web Api and Areas work together and with your code, it just worked !

    Thanks a lot.

    ReplyDelete
  5. Your article was helpful, but I'm a little confused on what this gets you?

    I was able to add an area "Whatever" and add using System.Web.Http statement to the WhateverAreaRegistration class and map the route via context.Routes.MapHttpRoute in the RegisterArea method and the route get() just worked.

    I then thought maybe you were trying to make it such that you don't need to add the route in each area registration class, but I was able to accomplish that by removing what I did above and then doing the following in the Global.asax.cs Application_Start - RegisterRoutes method:

    routes.MapHttpRoute(
    name: "DefaultAreaApi",
    routeTemplate: "api/{area}/{controller}/{id}",
    defaults: new {id = RouteParameter.Optional}
    );

    routes.MapHttpRoute(
    name: "DefaultApi",
    routeTemplate: "api/{controller}/{id}",
    defaults: new { id = RouteParameter.Optional }
    );


    ReplyDelete
    Replies
    1. Could you try to create the API controllers with the same names in the different areas? ;)

      Delete
    2. I see. It wasn't clear to me that the problem this post aims to solve is this exception:

      Multiple types were found that match the controller named 'blah'. This can happen if the route that services this request ('services/{area}/{controller}/{id}') found multiple controllers defined with the same name but differing namespaces, which is not supported. The request for 'blah' has found the following matching controllers: MyProject.Web.Areas.Whatever.Controllers.BlahController MyProject.Web.Areas.Whatever2.Controllers.BlahController

      Makes sense now and works great. Thanks for the post and reply! ;)

      Delete
    3. Another question... It seems if I define the route in the global routes as api/{area}/{controller} it works. If I define the route as either {area}/api/{controller} or api/{area}/{controller} (flipping api and area) in each [Controller]AreaRegistration class it works either way. But if I flip it in the global route and try to define it as {area}/api/{controller} it doesn't work. Any thoughts on why this doesn't work (or are you able to do this)?

      Another caveat that I found is that you still can't have a controller at the root with the same name as a controller in an area. Is that correct?

      Thanks!

      Delete
    4. > Another caveat that I found is that you still can't have a controller at the root with the same name as a controller in an area. Is that correct?
      Yes, it's correct.

      > But if I flip it in the global route and try to define it as {area}/api/{controller} it doesn't work
      sorry, but I can't say exactly why it does not work now. I should check.

      Delete
  6. Thanks for you posting, but seems I can't find a controller by search ".{area}." with full controller class name.

    I'm using the self-host Web API, after loaded all controller types, the sample controller fullname with format SelfHost.ProductsController, even I already add area while config the MapHttpRoute.

    Am I missing any steps, or this way don't support self host Web API?

    ReplyDelete
  7. Ohh! Saved my life! thank you for an amazng fix.

    ReplyDelete
  8. Wow! thanks! this saved me a lot of agony. But I still have a problem. The automatic help generator generates other items that are not there because of the routing. I have the routes
    api/admin/{controller}/{id}
    api/{area}/{controller}/{id}
    I have controllers MyProject.Web.Admin.AccountsController and MyProject.Web.NewsController
    now in the documentation I get, options like
    GET api/admin/accounts
    GET api/admin/news
    GET api/accounts
    GET api/news
    Obviously that throws an exception. Any idea of how to solve that

    ReplyDelete
  9. This comment has been removed by the author.

    ReplyDelete
  10. This is really nice. Thanks for sharing this article
    Dot net training Chennai

    ReplyDelete