I had an interesting problem with API versioning. The API behaviour for a given resource was going to change between version 1.x and 1.y, but the controller logic was going to stay the same. Usually when your behaviour changes it's a big enough change that you need a new controller and you can direct the request appropriately - either using a version number in the route, or (my preference) with a version in the request header and a custom IHttpControllerSelector.
But in this case the business logic in the controller was the same for 1.x and 1.y, but the pipeline logic was different. This was a POST request which accepted a GZipped payload, so the 1.x version used a delegating handler which was scoped to the route:
routes.MapHttpRoute(
name: "GZippedReports",
routeTemplate: "reports",
defaults: new { controller = "Reports" },
handler: new CompressedRequestHandler{ InnerHandler = new HttpControllerDispatcher(GlobalConfiguration.Configuration) });
That's a nice approach, because it means we're only interrupting the default pipeline for this one controller, which is the only one that will get GZip requests. That way we save the cost of checking every incoming request to see if it's compressed, but where we expect the requests will be compressed we're using the right part of the pipeline, and separating the formatting logic from the controller logic.
For version 1.y we'd be using the same endpoint (/reports) and the same controller, but with an additional handler to increase security around the API. We did something like adding a digital signature to the request and checking it in the API to ensure the payload hadn't been tampered with.
Assuming the request signature was OK, we'd go on to do the same thing - decompress it and move on to the controller, so the route would be the same as for 1.x but with an additional outer handler:
routes.MapHttpRoute(
name: "SignedGZippedReports",
routeTemplate: "reports",
defaults: new { controller = "Reports" },
handler: new SignedRequestHandler { InnerHandler = new CompressedRequestHandler{ InnerHandler = new HttpControllerDispatcher(GlobalConfiguration.Configuration) } });
But we want to keep those routes separate for 1.x and 1.y, and by doing it with Route Constraints we can keep all the logic in the route mapping and support versioning by both request header and query string, and keep everyone happy.
Here's how the final route mapping looks:
routes.MapHttpRoute(
name: "GZippedReports",
routeTemplate: "reports/{version}",
defaults: new { controller = "Reports", version = RouteParameter.Optional },
handler: new CompressedRequestHandler{ InnerHandler = new HttpControllerDispatcher(GlobalConfiguration.Configuration) },
constraints: new {version = new ApiVersionRouteConstraint { IsDefault = true, Maximum = 1.3 }});
routes.MapHttpRoute(
name: "SignedGZippedReports",
routeTemplate: "reports/{version}",
defaults: new { controller = "Reports", version = RouteParameter.Optional },
handler: new SignedRequestHandler { InnerHandler = new CompressedRequestHandler{ InnerHandler = new HttpControllerDispatcher(GlobalConfiguration.Configuration) } },
constraints: new { version = new ApiVersionRouteConstraint { Minimum = 1.4 } });
The route constraints disambiguate requests to /reports, by using the optional parameter {version}, and a custom route constraint specifying the minimum or maximum API version for each route. In my case the routes are using the same controller with different handler pipelines, but you could use different controllers too.
With these routes, any request which specifies an API version up to 1.3 - or which doesn't specify an API version - will use the decompress pipeline; any request which specifies version 1.4 or higher will use the check-signature-then-decompress pipeline.
ApiRouteConstraint is a custom implementation of IHttpRouteConstraint. The Match() method gets called for every route that is a potential match. If it returns true, WebAPI uses that route; if it returns false, WebAPI carries on checking other possible matches. You can also throw an HttpResponseException from the constraint to send a specific response back to the client - useful if they send a version header which is in the wrong format.
The Match implementation for the version route constraint is very simple. It assumes a versioning format of <major>.<minor> where new releases are always a higher number, and it prefers clients requesting the version number in the header, although it supports having the version in the URL. The constraint allows a route to declare itself as the default, so it will be used if the client doesn't specify a version in the request:
public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary values, HttpRouteDirection routeDirection)
{
var version = GetVersion(request.Headers) ?? GetVersion(request.RequestUri) ?? 0;
if (version == 0 && IsDefault)
return true;
return (version >= Minimum && (Maximum == 0 ||version <= Maximum));
}
It's a simple way to get versioning into your API, and to express the version as part of the route table (you can also use the constraint in attribute versioning). Full code and tests on github here: sixeyed/webapi-routeconstraintversioning.