Skip to content

Instantly share code, notes, and snippets.

@iamvery
Last active August 29, 2015 13:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iamvery/10317343 to your computer and use it in GitHub Desktop.
Save iamvery/10317343 to your computer and use it in GitHub Desktop.

Adding Versions to a Rails API

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.

An example API

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"}]

Versioning is a Thing

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

Route Constraint (commits b5b8aecf and 16f503c1)

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"}]

Version Representations, Not Locations

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.

Conclusion

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?
@stevenharman
Copy link

If we're talking about a RESTful API, I'd use the term "resource" as opposed to "endpoint."

@stevenharman
Copy link

I'm not sure of the current status of Rails support for it, but I'd far prefer to pass the version as a media type parameter:

vnd.articles+json; version=1.0

The problem with making it part of the producer name is that you must re-register with IANA just to change the version. Related, you probably want to add the media type suffix of +json.

UPDATE: Rails might get proper Accept Header parsing 🔜? rails/rails#14540

@stevenharman
Copy link

Oh, and despite popular misconception, these are not MIME types. MIME is an old email thing, these are "Internet Media Types", and vnd.articles+json is an "Internet Media Type Identifier." /nitpick 😏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment