Custom media types for ASP.NET Web API versioning
Edit on GitHubThere is a raging discussion on the interwebs on whether to version API’s by using their URL or by using a custom media type. Some argue that doing it in the URL breaks REST (since a different URL is a different resource while versions don’t necessarily mean a new resource is available). While I still feel good about both approaches, I guess it depends on the domain you are working with.
But that is not the topic of this talk. I recently found a sample on CodePlex providing support for routing versioned URL’s to different namespaces. In short, it maps /api/v1/values to MyApp.V1.Controllers and /api/v2/values to MyApp.V2.Controllers. Great! But that only supports the URL-versioning side of the discussion. Let’s implement this sample and build ASP.NET Web API support for versioning an API using custom media types…
Custom Media Types
If you have no clue about what I am talking about, no worries. I’ll give you a quick primer on this using the GitHub API as an example. Since their API version 3, endpoints for the API (or “resource addresses”) will no longer change every version of the API. Instead, they will be parsing the Accept HTTP header to determine the incoming message version and the expected response version.
Getting a list of repositories from the API? The URL will always be /users/repos. However different incoming and outgoing responses are possible, varying based on their media types. Want to use the V3 message format in JSON? Use application/vnd.github.v3+json. Prefer the V3 message format in XML? Use application/vnd.github.v3+xml. Whenever they update their messages, they can add a new media type such as application/vnd.github.v4 without changing any URL. Nifty trick, aye? Let’s do this for our own API.
IHttpControllerSelector
The IHttpControllerSelector interface allows you to interfere in selecting the right controller for the current request. This is an ideal location for grabbing all contextual information and providing ASP.NET Web API with a controller based on that context.
1 public class AcceptHeaderControllerSelector : IHttpControllerSelector 2 { 3 private const string ControllerKey = "controller"; 4 5 private readonly HttpConfiguration _configuration; 6 private readonly Func<MediaTypeHeaderValue, string> _namespaceResolver; 7 private readonly Lazy<Dictionary<string, HttpControllerDescriptor>> _controllers; 8 private readonly HashSet<string> _duplicates; 9 10 public AcceptHeaderControllerSelector(HttpConfiguration config, Func<MediaTypeHeaderValue, string> namespaceResolver) 11 { 12 _configuration = config; 13 _namespaceResolver = namespaceResolver; 14 _duplicates = new HashSet<string>(StringComparer.OrdinalIgnoreCase); 15 _controllers = new Lazy<Dictionary<string, HttpControllerDescriptor>>(InitializeControllerDictionary); 16 } 17 18 private Dictionary<string, HttpControllerDescriptor> InitializeControllerDictionary() 19 { 20 var dictionary = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase); 21 22 // Create a lookup table where key is "namespace.controller". The value of "namespace" is the last 23 // segment of the full namespace. For example: 24 // MyApplication.Controllers.V1.ProductsController => "V1.Products" 25 IAssembliesResolver assembliesResolver = _configuration.Services.GetAssembliesResolver(); 26 IHttpControllerTypeResolver controllersResolver = _configuration.Services.GetHttpControllerTypeResolver(); 27 28 ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver); 29 30 foreach (Type t in controllerTypes) 31 { 32 var segments = t.Namespace.Split(Type.Delimiter); 33 34 // For the dictionary key, strip "Controller" from the end of the type name. 35 // This matches the behavior of DefaultHttpControllerSelector. 36 var controllerName = t.Name.Remove(t.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length); 37 38 var key = String.Format(CultureInfo.InvariantCulture, "{0}.{1}", segments[segments.Length - 1], controllerName); 39 40 // Check for duplicate keys. 41 if (dictionary.Keys.Contains(key)) 42 { 43 _duplicates.Add(key); 44 } 45 else 46 { 47 dictionary[key] = new HttpControllerDescriptor(_configuration, t.Name, t); 48 } 49 } 50 51 // Remove any duplicates from the dictionary, because these create ambiguous matches. 52 // For example, "Foo.V1.ProductsController" and "Bar.V1.ProductsController" both map to "v1.products". 53 foreach (string s in _duplicates) 54 { 55 dictionary.Remove(s); 56 } 57 return dictionary; 58 } 59 60 // Get a value from the route data, if present. 61 private static T GetRouteVariable<T>(IHttpRouteData routeData, string name) 62 { 63 object result = null; 64 if (routeData.Values.TryGetValue(name, out result)) 65 { 66 return (T)result; 67 } 68 return default(T); 69 } 70 71 public HttpControllerDescriptor SelectController(HttpRequestMessage request) 72 { 73 IHttpRouteData routeData = request.GetRouteData(); 74 if (routeData == null) 75 { 76 throw new HttpResponseException(HttpStatusCode.NotFound); 77 } 78 79 // Get the namespace and controller variables from the route data. 80 string namespaceName = null; 81 foreach (var accepts in request.Headers.Accept) 82 { 83 namespaceName = _namespaceResolver(accepts); 84 if (namespaceName != null) 85 { 86 break; 87 } 88 } 89 if (namespaceName == null) 90 { 91 throw new HttpResponseException(HttpStatusCode.NotFound); 92 } 93 94 string controllerName = GetRouteVariable<string>(routeData, ControllerKey); 95 if (controllerName == null) 96 { 97 throw new HttpResponseException(HttpStatusCode.NotFound); 98 } 99 100 // Find a matching controller. 101 string key = String.Format(CultureInfo.InvariantCulture, "{0}.{1}", namespaceName, controllerName); 102 103 HttpControllerDescriptor controllerDescriptor; 104 if (_controllers.Value.TryGetValue(key, out controllerDescriptor)) 105 { 106 return controllerDescriptor; 107 } 108 else if (_duplicates.Contains(key)) 109 { 110 throw new HttpResponseException( 111 request.CreateErrorResponse(HttpStatusCode.InternalServerError, 112 "Multiple controllers were found that match this request.")); 113 } 114 else 115 { 116 throw new HttpResponseException(HttpStatusCode.NotFound); 117 } 118 } 119 120 public IDictionary<string, HttpControllerDescriptor> GetControllerMapping() 121 { 122 return _controllers.Value; 123 } 124 }
To be honest, I did not write much code in this. I grabbed the IHttpControllerSelector implementation from the sample on CodePlex and added just these lines to check the Accept header instead.
1 // Get the namespace and controller variables from the route data. 2 string namespaceName = null; 3 foreach (var accepts in request.Headers.Accept) 4 { 5 namespaceName = _namespaceResolver(accepts); 6 if (namespaceName != null) 7 { 8 break; 9 } 10 } 11 if (namespaceName == null) 12 { 13 throw new HttpResponseException(HttpStatusCode.NotFound); 14 }
The real logic in finding out the version that is called is delegated to the user of this IHttpControllerSelector. Let’s wire it up!
Wiring it up
ASP.NET Web API has a lot of “plugs”, among which there’s one where we can plug in our custom IHttpControllerSelector, Let’s override the default one and add our own:
1 config.Services.Replace(typeof(IHttpControllerSelector), 2 new AcceptHeaderControllerSelector(config, accept => 3 { 4 foreach (var parameter in accept.Parameters) 5 { 6 if (parameter.Name.Equals("version", StringComparison.InvariantCultureIgnoreCase)) 7 { 8 switch (parameter.Value) 9 { 10 case "1.0": return "v1"; 11 case "2.0": return "v2"; 12 } 13 } 14 } 15 16 return "v2"; // default namespace, return null to throw 404 when namespace not given 17 }));
As you can see, we can pass in a lambda which gets called with the contents of the Accept header and must return the namespace obtained from the header. The above example will work when using the version property of a header, e.g.: application/json;version=1.0 and application/json;version=2.0. The last statement returns “v2” as the default version when no specific media header is given. Return null if you want this to result in a 404 Page Not Found.
Using this header scheme is recommended but of course other options are possible. It’s your lambda!
Another approach would be going "GitHub style" and use things like application/vnd.api.v1+json and similar?
1 config.Services.Replace(typeof(IHttpControllerSelector), 2 new AcceptHeaderControllerSelector(config, accept => 3 { 4 var matches = Regex.Match(accept.MediaType, @"application\/vnd.api.(.*)\+.*"); 5 if (matches.Groups.Count >= 2) 6 { 7 return matches.Groups[1].Value; 8 } 9 return "v2"; // default namespace, return null to throw 404 when namespace not given 10 }));
Note that when using the GitHub-style media type, it’s best to also configure the default media type formatters to recognize these new types. That way you can even use different media type formats for each API version.
1 // Add custom media types as supported to their default formatters 2 config.Formatters.JsonFormatter.SupportedMediaTypes.Add(new MediaTypeWithQualityHeaderValue("application/vnd.api.v1+json")); 3 config.Formatters.JsonFormatter.SupportedMediaTypes.Add(new MediaTypeWithQualityHeaderValue("application/vnd.api.v2+json")); 4 5 config.Formatters.XmlFormatter.SupportedMediaTypes.Add(new MediaTypeWithQualityHeaderValue("application/vnd.api.v1+xml")); 6 config.Formatters.XmlFormatter.SupportedMediaTypes.Add(new MediaTypeWithQualityHeaderValue("application/vnd.api.v2+xml"));
That’s basically it. We can now implement our controllers in different namespaces, like so:
1 namespace TestSelector.Controllers.V1 2 { 3 public class ValuesController : ApiController 4 { 5 public string Get() 6 { 7 return "This is a V1 response."; 8 } 9 } 10 } 11 12 namespace TestSelector.Controllers.V2 13 { 14 public class ValuesController : ApiController 15 { 16 public string Get() 17 { 18 return "This is a V2 response."; 19 } 20 } 21 }
When providing different Accept headers, we now get routed to the correct namespace depending on our custom media type. REST maturity level up!
I’ve issued a pull request on the official samples page, in the meanwhile here’s the download: AcceptHeaderControllerSelector.zip (238.43 kb)
Enjoy!
[edit] there's a project on GitHub containing other implementations as well, check http://github.com/Sebazzz/SDammann.WebApi.Versioning
This is an imported post. It was imported from my old blog using an automated tool and may contain formatting errors and/or broken images.
8 responses