When you create an application programming interface, you're establishing a contract with everyone who uses it. This too is true for web service APIs. As soon as someone begins using an API cost is incurred to change it. In order to allow breaking changes to an interface we can version it so clients may specify exactly what representation they expect for their requests.
Note: Each heading in this walkthrough will have one or more accompanying commits. You can work through it yourself or follow along on Github.
As a baseline for this post, we'll consider a very simple, contrived API. This
API has only one endpoint /articles
. You can grab a copy of this example
by cloning it from Github and setting up as follows:
# Clone the repo
$ git clone git@github.com:iamvery/rails-api-example.git
$ cd rails-api-example
# Checkout the repo in it's "initial" state, before versions are implemented
$ git checkout -b starting-point initial-api-implementation
# Install dependencies, watch specs pass
$ bundle install
$ bin/rspec
... 0 failures
Once you've got the project setup, let's run the local server and see what the article response looks like:
# Run the rails server in a separate terminal window
$ bin/rails server
$ curl http://localhost:3000/articles.json
[{"id":123,"name":"The Things"}]
At this our API has a single, unversioned articles endpoint. As a first step we'll introduce versioning as a vendor mime type for our current endpoint. Call it "articles, version 1".
Namespaces (commit 623488ec
)
To keep things organized, we'll wrap up our existing controller in a V1
namespace.
Move app/controllers/articles_controller.rb
to
app/controllers/v1/articles_controller.rb
and wrap the class in a module.
module V1
class ArticlesController < ApplicationController
def index
articles = [
{ id: 123, name: 'The Things' },
]
render json: articles
end
end
end
Since we don't want to affect our URI structure for the endpoint, we can use
the :module
scope to namespace the controller and not the URI.
Rails.application.routes.draw do
scope module: :v1 do
resources :articles, only: :index
end
end
Next we'll implement a route constraint.
class ApiConstraint
attr_reader :version
VENDOR_MIME = 'application/vnd.articles-v%d'
def initialize(options)
@version = options.fetch(:version)
end
def matches?(request)
request
.headers
.fetch(:accept)
.include?(VENDOR_MIME % version)
end
end
We can use this constraint to route requests based on the specified version in the request's accept header.
Rails.application.routes.draw do
scope module: :v1, constraints: ApiConstraint.new(version: 1) do
With this change, we now must specify the desired version in the request's headers to get the desired response. If your development server is still running at this point, it will probably need to be restarted.
$ curl -H "accept: application/vnd.articles-v1" http://localhost:3000/articles.json
[{"id":123,"name":"The Things"}]
Version 2 (commit fec6a358
)
Now that we have namespaces for our versioned controllers and constraints for routing, we can introduce a version 2 articles endpoint. Version 2 will wrap the response in a root node. This is not backwards compatable with the version 1 representation, but this is irrelevant to existing clients as they are still requesting version 1 articles.
scope module: :v2, constraints: ApiConstraint.new(version: 2) do
resources :articles, only: :index
end
module V2
class ArticlesController < ApplicationController
def index
articles = [
{ id: 123, name: 'The Things' },
]
render json: { articles: articles }
end
end
end
We can request the version 2 representation as well as version 1 as follows.
$ curl -H "accept: application/vnd.articles-v2" http://localhost:3000/articles.json
{"articles":[{"id":123,"name":"The Things"}]}
$ curl -H "accept: application/vnd.articles-v1" http://localhost:3000/articles.json
[{"id":123,"name":"The Things"}]
I was first tuned to this idea by a post from Steve Klabnik about REST and HTTP. Later I dug a little deeper and found a long answer on StackOverflow that goes into more detail. The prevaling sentiment is:
resource URIs that API users depend on should be permalinks
This cannot be true if the version is included in the URI, which changes over time. Using Klabnik's suggestion we push this knowledge into the request headers and keep URIs permanent for all future representations of our resources.
Versioning code is a Good Thing™. It allows us to continue to extend our APIs without breaking compatablility for existing users. Introducing API versions after a release may be a little painful, but it's doable.
- What do you think of this solution?
- How do you think it'd work at scale with a real system?
If we're talking about a RESTful API, I'd use the term "resource" as opposed to "endpoint."