Caravan: Ruby API Versioning & Enforcement
So you’ve built a Ruby API. Awesome sauce! You’ve set yourself up for success whether you build a rich client-side JavaScript app or native app. Hell, you could even shove it into a much larger Service-Oriented Architecture cluster. Nice.
But something is nagging at you: what you wrote works, and you’ll be able to keep it working through updates and maintenance, but you have doubts about how the consumers of your API will handle that. The idea of versioning your API comes to mind, but that’s all it is… an idea. You’re pretty sure you could implement versioning, but it’ll probably be ad hoc and just a bit too loosey-goosey.
You dread the day you’ll need to version your first endpoint.
Fear not! Bendyworks just released Caravan, a sample implementation of a version-enforced API server. We wrote it using Sinatra, but it should be sufficiently adaptable to any Rack-based server. Caravan uses Interpol, a tool that lets you enforce versioned APIs, and it even includes documentation tooling. I’ll illustrate some of the key points of how Caravan works here.
Rack App
To get things kicked off, we need a Rack app. With a simple enough config.ru, we can define a stack with 4 items: a request validator, a response validator, an error handler, and our API endpoints:
class Caravan
def self.new
app = Rack::Builder.new do
use Interpol::RequestBodyValidator
use Interpol::ResponseSchemaValidator
use Apps::Middleware::ErrorHandler
map('/users') { run Apps::Users.new }
end
Rack::Builder.app do
run app
end
end
end
If we had more endpoints than just /users
, we’d specify them here as separate Sinatra apps. If we were to use Rails, we’d probably just use the built-in router. The full source of the Rack app without elision is on GitHub.
Users Service
Inside the Sinatra-based Users service, we see where our API gets versioned. We create a hash map containing every allowed version as a key, with corresponding EndpointModels
classes as values. Now we can do a hash lookup for the version rather than string together a bunch of if statements:
class Apps::Users < CaravanBase
user_versions = {
'1.0' => EndpointModels::UserV10,
'2.0' => EndpointModels::User
}
# RestClient.get 'https://server.com/users/chell'
get '/:username' do
endpoint = user_versions.fetch(get_preferred_version('2.0', '1.0'))
respond_with endpoint.data_for(params)
end
end
The full source of the Users Service is on GitHub.
Using Rails, “Controllers” can substitute for the term “EndpointModels,” and you would likely want to use Advanced Routing Constraints to implement the version switching.
Endpoint Definitions
With our API now sufficiently versioned, we want to create different “EndpointModels” to serve up data from (probably) the same data source. This is where things usually get gnarly from the maintenance perspective, especially if we try to leverage inheritance for short-term gain. Fortunately, however, we’re using Interpol under the covers, which allows us to enforce API shapes over multiple versions.
The endpoint definition mechanism is in YAML, and I’ll avoid including it here for brevity. Check out user.yml on GitHub to see how it defines requests, responses, and examples over multiple versions of an endpoint.
Endpoint Models
With the different versions of our endpoints defined, we can now write EndpointModels (“Controllers” in Rails-land) to serve up the different shapes of the same underlying data. We already used a helper in our Service called respond_with
with converts a Hash to a JSON string, so we just need to return the appropriate hash of values as defined in our Definition file:
class User
extend ExplicitParams
require_params :username
def data
u = ::Users.find(login: username)
raise NotFoundError unless u
u.values
end
end
This is a minor departure from what we actually wrote, and the full source for both v1 and v2 are on GitHub.
Conclusion
We like using Interpol for Ruby APIs because it not only provides versioning, but enforcement of versioning. If a request is malformed, the user agent receives an appropriate error. In addition, if a downstream service provides malformed data back to our API server, the user agent receives a 500. Also not mentioned thus far is its ability to auto-generate online documentation from the endpoint definitions files. These features put Interpol beyond simply using API-version constraints in Rails routing. Many thanks to Moz for developing and open-sourcing Interpol!
If Ruby isn’t your thing, perhaps you’d be interested in the still-evolving blog series on writing an API server in Haskell. Part 1 is currently available on our blog.