Web API 2 - API versioning & Multiple Controllers with Same Name

ASP.NET Web API by default does not allow 2 controllers with same name.

Basically, this is what happens:

  • IHttpControllerSelector is resolved (by default it’s DefaultHttpControllerSelector) to call IHttpControllerSelector.GetControllerMapping()
  • IHttpControllerSelector.GetControllerMapping() will resolve (through a HttpControllerTypeCache cache manager) IHttpControllerTypeResolver to call IHttpControllerTypeResolver.GetControllerTypes().
  • By default IHttpControllerTypeResolver is DefaultHttpControllerTypeResolver, in which GetControllerTypes() will loop through all loaded assemblies and will return all types which implement IHttpController and which end with "Controller".
  • Going back… IHttpControllerSelector.GetControllerMapping() will return a map of all controllers, but in case will ignore namespace, strip "Controller" suffix, and will also ignore duplicates (they are moved into another dictionary which is used just to throw an exception that there are multiple matching controllers).
  • IHttpControllerSelector.SelectController is invoked on each request and will also use a cached map of controllers (cached by that same cache manager HttpControllerTypeCache) which will lookup controller name in the route (like {controller}) and will choose the correct controller.

I’ve found some some solutions to solve the problem of duplicated names, but most sounded too complex to me, since they were based on tagging the namespaces of each controller as DataTokens applied to the routes registered.

Since I’m using Attribute-Based routes and since I have (for legacy reasons) some non-attribute-based (regular explicitly defined routes), I ended up developing my own solution.

This NamespaceHttpControllerSelector below allows the use of multiple controllers with same name (obviously in different namespaces) by prepending controller identifier with their namespaces (if they have [RoutePrefix] attribute). And if there are controllers without [RoutePrefix] they are just registered as usual, without their namespace, and will be matched by their route {controller} as usual.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;
using System.Web.Http.Controllers;
using System.Web.Http.Dispatcher;
using System.Web.Http.Routing;

/// <summary>
/// Allows the use of multiple controllers with same name (obviously in different namespaces) 
/// by prepending controller identifier with their namespaces (if they have [RoutePrefix] attribute).
/// Allows attribute-based controllers to be mixed with explicit-routes controllers without conflicts.
/// </summary>
public class NamespaceHttpControllerSelector : DefaultHttpControllerSelector
{
    // Some sample references: 
    // https://www.strathweb.com/2013/08/customizing-controller-discovery-in-asp-net-web-api/
    // https://github.com/WebApiContrib/WebAPIContrib/blob/master/src/WebApiContrib/Selectors/NamespaceHttpControllerSelector.cs 
    // https://github.com/umbraco/Umbraco-CMS/blob/7.2.0/src/Umbraco.Web/WebApi/NamespaceHttpControllerSelector.cs
    // https://github.com/mono/aspnetwebstack/blob/master/src/System.Web.Http/Dispatcher/DefaultHttpControllerSelector.cs
    // https://github.com/mono/aspnetwebstack/blob/master/src/System.Web.Http/Dispatcher/HttpControllerTypeCache.cs

    private HttpConfiguration _configuration;
    private IHttpControllerSelector _previousSelector;
    private readonly Lazy<Dictionary<string, HttpControllerDescriptor>> _controllers;

    public NamespaceHttpControllerSelector(HttpConfiguration httpConfiguration) : base(httpConfiguration)
    {
        _configuration = httpConfiguration;
        _previousSelector = _configuration.Services.GetService(typeof(IHttpControllerSelector)) as IHttpControllerSelector;
        _controllers = new Lazy<Dictionary<string, HttpControllerDescriptor>>(InitializeControllerDictionary);
    }

    public override IDictionary<string, HttpControllerDescriptor> GetControllerMapping()
    {
        return _controllers.Value; // just cache the list of controllers, so we load only once at first use
    }

    /// <summary>
    /// The regular DefaultHttpControllerSelector.InitializeControllerDictionary() does not 
    ///  allow 2 controller types to have same name even if they are in different namespaces (they are ignored!)
    /// 
    /// This method will map ALL controllers, even if they have same name, 
    /// by prepending controller names with their namespaces if they have [RoutePrefix] attribute
    /// </summary>
    /// <returns></returns>
    private Dictionary<string, HttpControllerDescriptor> InitializeControllerDictionary()
    {
        IAssembliesResolver assembliesResolver = _configuration.Services.GetAssembliesResolver();
        IHttpControllerTypeResolver controllersResolver = _configuration.Services.GetHttpControllerTypeResolver(); 
        ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver); 

        // simple alternative? in case you want to map maybe "UserAPI" instead of "UserController"
        // var controllerTypes = System.Reflection.Assembly.GetExecutingAssembly().GetTypes()
        // .Where(t => t.IsClass && t.IsVisible && !t.IsAbstract && typeof(IHttpController).IsAssignableFrom(t));

        var controllers = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);
        foreach (Type t in controllerTypes)
        {
            var controllerName = t.Name;

            // ASP.NET by default removes "Controller" suffix, let's keep that convention
            if (controllerName.EndsWith(ControllerSuffix))
                controllerName = controllerName.Remove(controllerName.Length - ControllerSuffix.Length);

            // For controllers with [RoutePrefix] we'll register full name (namespace+name). 
            // Those routes when matched they provide the full type name, so we can match exact controller type.
            // For other controllers we'll register as usual
            bool hasroutePrefixAttribute = t.GetCustomAttributes(typeof(RoutePrefixAttribute), false).Any();
            if (hasroutePrefixAttribute)
                controllerName = t.Namespace + "." + controllerName;

            if (!controllers.Keys.Contains(controllerName))
                controllers[controllerName] = new HttpControllerDescriptor(_configuration, controllerName, t);
        }
        return controllers;
    }

    /// <summary>
    /// For "regular" MVC routes we will receive the "{controller}" value in route, and we lookup for the controller as usual.
    /// For attribute-based routes we receive the ControllerDescriptor which gives us 
    /// the full name of the controller as registered (with namespace), so we can version our APIs
    /// </summary>
    /// <param name="request"></param>
    /// <returns></returns>
    public override HttpControllerDescriptor SelectController(HttpRequestMessage request)
    {
        HttpControllerDescriptor controller;
        IDictionary<string, HttpControllerDescriptor> controllers = GetControllerMapping();
        IDictionary<string, HttpControllerDescriptor> controllersWithoutAttributeBasedRouting =
            GetControllerMapping().Where(kv => !kv.Value.ControllerType
               .GetCustomAttributes(typeof(RoutePrefixAttribute), false).Any())
            .ToDictionary(kv => kv.Key, kv => kv.Value);

        var route = request.GetRouteData();

        // regular routes are registered explicitly using {controller} route - and in case we'll match by the controller name,
        // as usual ("CourseController" is looked up in dictionary as "Course").
        if (route.Values != null && route.Values.ContainsKey("controller"))
        {
            string controllerName = (string)route.Values["controller"];
            if (controllersWithoutAttributeBasedRouting.TryGetValue(controllerName, out controller))
                return controller;
        }

        // For attribute-based routes, the matched route has subroutes, 
        // and we can get the ControllerDescriptor (with the exact name that we defined - with namespace) associated, to return correct controller
        if (route.GetSubRoutes() != null)
        {
            route = route.GetSubRoutes().First(); // any sample route, we're just looking for the controller

            // Attribute Routing registers a single route with many subroutes, and we need to inspect any action of the route to get the controller
            if (route.Route != null && route.Route.DataTokens != null && route.Route.DataTokens["actions"] != null)
            {
                // if it wasn't for attribute-based routes which give us the ControllerDescriptor for each route, 
                // we could pick the correct controller version by inspecting version in accepted mime types in request.Headers.Accept
                string controllerTypeFullName = ((HttpActionDescriptor[])route.Route.DataTokens["actions"])[0].ControllerDescriptor.ControllerName;
                if (controllers.TryGetValue(controllerTypeFullName, out controller))
                    return controller;
            }
        }

        throw new HttpResponseException(HttpStatusCode.NotFound);
    }

}

How to register this - before auto-mapping your routes (in your Startup.cs, or RouteConfig.cs, or Global.asax.cs, or similar) you register your new IHttpControllerSelector:

config.Services.Replace(typeof(IHttpControllerSelector), new NamespaceHttpControllerSelector(config));
config.MapHttpAttributeRoutes();
comments powered by Disqus