Versioning APIs Internally
There's a fair amount that's been written on how to version APIs, but what nobody talks about is what goes on behind the scenes--how versioning is implemented at a code level.
One reason behind this is that the implementation is often a mess, a nestled spaghetti of conditional statements littering your codebase that usually looks something like this:
if params[:version] == 1
show_something
else
show_something_else
end
At Clearbit we've been considering how we're going to approach this problem and plan for the future when presumably we'll have a lot of different versions. I think the approach we've come up with strikes a good balance between being simple and being DRY. While this post is Ruby & Sinatra specific, the general conventions should apply to any stack.
Date based versioning
Whenever you first make an API call to one of our APIs we save today's date to your account and use that as a version reference from then on. You can override the version on a per request basis (with a custom header) but the client's default version is whatever is in the database. When a client is ready to upgrade, say to gain access to a new data attribute, they can do so in their account's settings.
Version based routes
If we're changing something at a route based level, say altering the default behavior or data returned, we create a new route and mount them in reverse chronological order.
We then filter routes by the version they represent using Sinatra conditions which means they'll only get executed if the version matches [1].
# people_2015_05_11.rb
get '/v1/combined/email/:email', version: '2015-05-11' do
# ...
end
# people_2015_04_30.rb
get '/v1/combined/email/:email', version: '2015-04-30' do
# ...
end
This approach has the downside that you're repeating code (not very DRY), but the significant upside that it's very straightforward to test each API version, and that removing a deprecated version is as simple as deleting a file.
Version based serializer
We use the idea of a 'serializer' to represent an object (usually wrapping a model) that can be serialized to JSON and returned via our API. Whenever we want to make changes to the data our API returns (say remove an attribute) it's the serializer that needs changing.
For major changes we version using the same route based technique shown above, having two different resources with dates in the filename representing how the serializer is presented for different API versions.
However, copying a file to make smaller changes is clearly a bit excessive. That's when we fallback to conditional logic:
def attribute
account.version_has?(:fancy_feature) ? attribute_v2 : attribute_v1
end
We have a mapping of version dates to more readable version names so dates aren't littered throughout the codebase. We also have a convention of using different methods for each versioned attribute rather than an inline if/else
statement.
Early days
While it's early days for us and we're still making improvements, the above approaches combined with rigorous testing for each API version seems to be a good approach. If you've got any additional thoughts about best practices we'd love to hear them.
[1] - If you're interested in the Sinatra extension we wrote to do condition based routing, here it is.