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.


Category: Development
Tags: API, Open Source, Ruby