ASP.NET Web Api - how to include API version number in the path as routing parameter

0
=
0
+
0
No specific Bitcoin Bounty has been announced by author. Still, anyone could send Bitcoin Tips to those who provide a good answer.
0

How to include API version number v1, v2, v3, etc in the path (like .../v1/... ) and have it:

  • being used to select proper version of Controller class
  • carried over to Controller as a parameter (optional)

HTTP Calls

Here is how HTTP calls to different API versions would look like:

/v1/User/SomeAction?param=zzz
/v2/User/SomeAction?param=zzz    
....
/vNN/User/SomeAction?param=zzz

Controllers

Now let's take a look at the server side. In most scenarios it would be convenient to have 3 controller classes with the same name UserController but in different namespaces:

namespace Api.Controllers.V1
{
    // V1 controller
    public class UserController : ApiController {  }
}

namespace Api.Controllers.V2
{
    // V2 controller, inherits from V1 controller
    public class UserController : Api.Controllers.V1.UserController  {  }
}

namespace Api.Controllers.V3
{
    // V3 controller, inherits from V2 controller
    public class UserController : Api.Controllers.V2.UserController {  }
}

Note that here I've also made V3 inherit from V2 and V2 inherit from V1. This may not necessarily be the best design choice. It may depend on the following:

  • your personal preferences
  • how different on average are your API versions (small releases few changes VS large releases with lots of changes)
  • do you ever delete / deprecate old API methods, or you only add new methods and old methods are never deleted

Regardless of your choice, Web API routing problems would remain the same.

Question

The problem with this approach is that Web API locates controllers by class name only, ignoring the namespace. So there’s no way to make this work using the default routing logic.

Question - how to set up routing so that the value of "vNN" part from the HTTP would be used to select correct controller class? Thanks!

1 Answer

1
=
0
=
$0
Internet users could send Bitcoin Tips to you if they like your answer!

You may need to program your custom routing logic by implementing IHttpControllerSelector interface to change default routing behavior. Method SelectController is used to select a controller for a given request - HttpRequestMessage. You would need to return type object for the controller type you want to be used.

On Application Start:

  • use Reflection to iterate through all the types in loaded Assembly, check their names, if name ends with "Controller" then add controller's type object into static Dictionary cache variable using special key crafted from full type name's segments to extract preceding namespace part and form a string like this v1.user
  • register route in config:

    // Register default route
    config.Routes.MapHttpRoute(
        name: "DefaultApi",
        routeTemplate: "{namespace}/{controller}/{action}"
    );
    
  • inject your custom class CustomControllerSelector into routing config:

    config.Services.Replace(typeof(System.Web.Http.Dispatcher.IHttpControllerSelector), new CustomControllerSelector(config)); ...

On Every Request:

the injected CustomControllerSelector.SelectController(HttpRequestMessage request) would get called for you automatically, and from request parameter variable you would need to build that Key = v1.user and use it in that static Dictionary to look up cached controller type object that corresponds to that Key. Done!

Complete Code - CustomControllerSelector.cs :

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


    public class CustomControllerSelector : IHttpControllerSelector
    {
        private const string NamespaceKey = "namespace";
        private const string ControllerKey = "controller";

        private readonly HttpConfiguration _configuration;
        private readonly Lazy<Dictionary<string, HttpControllerDescriptor>> _controllers;
        private readonly HashSet<string> _duplicates;

        public CustomControllerSelector(HttpConfiguration config)
        {
            _configuration = config;
            _duplicates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            _controllers = new Lazy<Dictionary<string, HttpControllerDescriptor>>(InitializeControllerDictionary);
        }

        private Dictionary<string, HttpControllerDescriptor> InitializeControllerDictionary()
        {
            var dictionary = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);

            // http routing: '/v1/User/SomeAction'  => Controllers.V1.UserController 
            //
            // Create a lookup table where key is "namespace.controller", i.e. "V1.User" or "V2.User"
            // Controllers.V1.UserController => "V1.User"
            //
            IAssembliesResolver assembliesResolver = _configuration.Services.GetAssembliesResolver();
            IHttpControllerTypeResolver controllersResolver = _configuration.Services.GetHttpControllerTypeResolver();

            ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver);

            foreach (Type t in controllerTypes)
            {
                var segments = t.Namespace.Split(Type.Delimiter);

                // For the dictionary key, strip "Controller" from the end of the type name.
                // This matches the behavior of DefaultHttpControllerSelector.
                var controllerName = t.Name.Remove(t.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length);

                var key = String.Format(CultureInfo.InvariantCulture, "{0}.{1}", segments[segments.Length - 1], controllerName);

                // Check for duplicate keys.
                if (dictionary.Keys.Contains(key))
                {
                    _duplicates.Add(key);
                }
                else
                {
                    dictionary[key] = new HttpControllerDescriptor(_configuration, t.Name, t);  
                }
            }

            // Remove any duplicates from the dictionary, because these create ambiguous matches. 
            // For example, "Foo.V1.ProductsController" and "Bar.V1.ProductsController" both map to "v1.products".
            foreach (string s in _duplicates)
            {
                dictionary.Remove(s); 
            }
            return dictionary;
        }

        // Get a value from the route data, if present.
        private static T GetRouteVariable<T>(IHttpRouteData routeData, string name)
        {
            object result = null;
            if (routeData.Values.TryGetValue(name, out result))
            {
                return (T)result;
            }
            return default(T);
        }

        public HttpControllerDescriptor SelectController(HttpRequestMessage request)
        {
            IHttpRouteData routeData = request.GetRouteData();
            if (routeData == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }

            // Get the namespace and controller variables from the route data.
            string namespaceName = GetRouteVariable<string>(routeData, NamespaceKey);
            if (namespaceName == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }

            string controllerName = GetRouteVariable<string>(routeData, ControllerKey);
            if (controllerName == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }

            // Find a matching controller.
            string key = String.Format(CultureInfo.InvariantCulture, "{0}.{1}", namespaceName, controllerName);

            HttpControllerDescriptor controllerDescriptor;
            if (_controllers.Value.TryGetValue(key, out controllerDescriptor))
            {
                return controllerDescriptor;
            }
            else if (_duplicates.Contains(key))
            {
                throw new HttpResponseException(
                    request.CreateErrorResponse(HttpStatusCode.InternalServerError,
                    "Multiple controllers were found that match this request."));
            }
            else
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
        }

        public IDictionary<string, HttpControllerDescriptor> GetControllerMapping()
        {
            return _controllers.Value;
        }
    }

Code Fragment from WebApiConfig.cs

If you created Web API project from standard template, then this class should be already in your project. Its method Register is called ONCE on application start. So just make sure you have the following routing config code in there:

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // Web API configuration and services

        // Web API routes
        config.MapHttpAttributeRoutes();

        // Register default route
        config.Routes.MapHttpRoute(
            name: "DefaultApi",
            routeTemplate: "{namespace}/{controller}/{action}"
        );

        config.Services.Replace(typeof(System.Web.Http.Dispatcher.IHttpControllerSelector), new CustomControllerSelector(config));
    }
}

Credit & References

  • This answer has been inspired by this article and code is borrowed almost completely from here
  • If you want to be able to fall back to Default Controller Selector logic, then read this SO answer on A) how to "inherit" from Default class and B) how to break into the chain of Services but keep the reference to previous Selector object to be able to call it when there is a need to fall back to default behavior.

...

SEND BITCOIN TIPS
0

Too many commands? Learning new syntax?

FavScripts.com is a free tool to save your favorite scripts and commands, then quickly find and copy-paste your commands with just few clicks.

Boost your productivity with FavScripts.com!

Post Answer