#350 REST API Versioning
- Download:
- source codeProject Files in Zip (95 KB)
- mp4Full Size H.264 Video (32.3 MB)
- m4vSmaller H.264 Video (14.5 MB)
- webmFull Size VP8 Video (12.1 MB)
- ogvFull Size Theora Video (36.8 MB)
We have an application for managing products. With it we can view a list of the existing items or the details of a single one and also create, edit and delete products.
We’d also like to provide a REST API so that we can manage products outside the HTML interface. The application’s ProductsController
already follows the RESTful style so we could just use this and add a respond_to
block to each action so that it responds to JSON requests, like this:
def index @products = Product.all respond_to do |format| format.html format.json { render json: @products } end end
When we visit /products.json
now we’ll get a list of our products in JSON.
So do we now have a JSON API? Well, there’s a serious issue with this approach and that is versioning. It’s important that an API stays consistent. For example each product has an released_on
attribute which returns the date on which the product was released. If we want to return the time as well and rename the column to released_at
we could do so but this will break any application that uses this API, especially if it uses that released_on
attribute.
Keeping a JSON API alongside the HTML interface is often not the best approach if we’re trying to build a consistent API and there are several gems that can help out with this problem. Versionist makes it easy to handle versioned APIs through the application’s routing and includes several generators. RocketPants is another gem that can help with building a REST API. This includes versioning and a lot more. If you’re considering using it the README is well worth reading.
In this episode we won’t use a gem as writing our API from scratch will give us a better idea of how Rails’ routes work and how they can be applied to API versioning. The current routes for this application look like this:
Store::Application.routes.draw do resources :products root to: 'products#index' end
We’ll add some routes specifically for the API so that these routes stay separate from the routes for the HTML interface. We’ll use an api
namespace to do this which means that any routes defined in it will be prefixed with an /api
path. We could add a subdomain constraint instead of using this api
namespace but this approach will work perfectly well for us here. We also have to decide how to do the versioning. One option is to store the version as part of the URL and we can do this with another call to namespace
. Any controllers or routes defined in this namespace will be expected to be under the same namespace. For now we’ll just put the products
resource in here to serve up our products in a RESTful style.
Store::Application.routes.draw do namespace :api do namespace :v1 do resources :products end end resources :products root to: 'products#index' end
In our /app/controllers
directory we now need to make an api
directory with a v1
subdirectory. In this directory we’ll create a ProductsController
.
module Api module V1 class ProductsController < ApplicationController end end end
This class inherits from ApplicationController
although we could have it inherit from, say, an Api::BaseController
if we wanted to share behaviour between all of our application’s API controllers. Now that we have our controller we can add the actions we want to it.
module Api module V1 class ProductsController < ApplicationController respond_to :json def index respond_with Product.all end def show respond_with Product.find(params[:id]) end def create respond_with Product.create(params[:product]) end def update respond_with Product.update(params[:id], params[:products]) end def destroy respond_with Product.destroy(params[:id]) end end end end
Here we’ve defined each action using a call to respond_with
so that it responds in the JSON format. This is a really simplified approach; you’ll probably want to more here for your own JSON API. When we visit /api/v1/products.json
now we’ll see our API.
This URL requires that we specify the JSON format and if we remove the .json
extension we get no response. To make JSON the default format we can use the defaults
option in the routes like this:
Store::Application.routes.draw do namespace :api, defaults: {format: 'json'} do namespace :v1 do resources :products end end resources :products root to: 'products#index' end
Visiting http://localhost:3000/api/v1/products
will now return a JSON response.
Creating a New Version of Our API
So far things are working well but what do we do when we need to make a breaking change to our API? Let’s say that we want to rename the released_on
column to released_at
. To demonstrate this we’ll generate a migration to change this column’s name and type.
$ rails g migration change_products_released_on
The migration’s code looks like this.
class ChangeProductsReleasedOn < ActiveRecord::Migration def up rename_column :products, :released_on, :released_at change_column :products, :released_at, :datetime end def down change_column :products, :released_at, :date rename_column :products, :released_at, :released_on end end
Running rake db:migrate
will make the change in the database. When we reload the API page in the browser now the released_on
column has been replaced by released_at
which has a time stamp. This isn’t a backwards-compatible change.
We should have an automated system set up so that we know when we break the API like this and before we create the new version of our API we’ll fix the current one. This is a little tricky as we’re using respond_with
on the controller. The easiest approach would be to use something like RABL as we did in episode 322. This gives us more control over the attributes that are returned in the JSON response.
For our simple API we’ll do a quick hack to get it working again. This can be an acceptable approach, especially if the API is well-covered with tests. What we’ll do is create a new Product
class inside the ProductsController
that inherits from our existing Product
model class and make the changes there. This way any references to Product in the controller will use our new subclass instead of the original one. We can override methods in this new class to make them behave differently just for this version of the API. We’ll overwrite to_json
here so that we can add the released_on
attribute back to the JSON output.
module Api module V1 class ProductsController < ApplicationController class Product < ::Product def as_json(options={}) super.merge(released_on: released_at.to_date) end end respond_to :json # Actions omitted end end end
When we reload the version 1 API page now it works again and the released_on
attribute is restored.
If our application had tests we should be back in the green now. We have a released_at
attribute returned for each product now but this can be considered a benefit as this will ease the transition for those people still using the old API. Technically at this point we don’t need to release a new version of the API but if we create enough of these hacks and deprecations then we’ll have to do so.
To release a new version we can just copy the code in app/controllers/api/v1
into a new v2
directory.
$ cp -R app/controllers/api/v1 app/controllers/api/v2
In the routes file we can copy the routes in the v1
namespace into and new v2
namespace.
Store::Application.routes.draw do namespace :api, defaults: {format: 'json'} do namespace :v1 do resources :products end namespace :v2 do resources :products end end resources :products root to: 'products#index' end
If there’s a lot of duplication in the routes here we could make a lambda and pass that lambda block of each of the versions here but this will only work if the routes are the same between versions. Now in our new v2
ProductsController
we can remove the hacks we added in v1
and change the module name.
module Api module V2 class ProductsController < ApplicationController respond_to :json def index respond_with Product.all end def show respond_with Product.find(params[:id]) end def create respond_with Product.create(params[:product]) end def update respond_with Product.update(params[:id], params[:products]) end def destroy respond_with Product.destroy(params[:id]) end end end end
Visiting version 2 of the API now shows us the cleaner output without the deprecated released_on
attribute.
When we follow this approach to versioning it might look like there’s a lot of code duplication going on. The ProductsController
in version 2 looks nearly identical to version 1’s. This isn’t really a violation of the DRY principle, though. It’s important that we ask ourselves whether we’ll need to change the code in one version if we change it in the other. This isn’t the case here as we’ll rarely go back to an older version and need to bring a feature back from a newer version. It’s far more likely that we’ll have different code in the older version to keep it backwards compatible. Also it’s likely that we’ll remove an older version at some point so doing extensive refactoring which isn’t really worth it. If you do think this is necessary you could make a different superclass with shared behaviour that both versions of the API will use.
Version Numbers
Next we’ll talk about how we specify the version number. Currently it’s in the URL path which is simple and direct but which isn’t considered a best practice by some. Github, for example, made a change so that instead of including the version number in the URL it’s passed in an Accept
header. How do we do this in our application?
First we’ll replace the namespaces in our routes with a call to scope
. This way we can specify what module to use and also the constraints. The constraints logic can be rather complex so we’ll move it into another class.
Store::Application.routes.draw do namespace :api, defaults: {format: 'json'} do scope module: :v1, constraints: ApiConstraints.new(version: 1) do resources :products end scope module: :v2, constraints: ApiConstraints.new(version: 2, default: :true) do resources :products end end resources :products root to: 'products#index' end
Note that we’ve added an extra option to the v2
constraints to specify that this is the default version. We still need to define the ApiConstraints
class that we use in the routes file and we’ll define this in the /lib
directory.
class ApiConstraints def initialize(options) @version = options[:version] @default = options[:default] end def matches?(req) @default || req.headers['Accept'].include?("application/vnd.example.v#{@version}") end end
This class is fairly simple. In initialize
we extract the options out into instance variables. We also provide a matches?
method which the router will trigger for the constraint. This checks to see if if the default version is required or if the request’s Accept
header matches the given version string. This string can be whatever you want the header to match.
For convenience we’ll go to the top of the routes file and require this class there.
require 'api_constraints' Store::Application.routes.draw do namespace :api, defaults: {format: 'json'} do scope module: :v1, constraints: ApiConstraints.new(version: 1) do resources :products end scope module: :v2, constraints: ApiConstraints.new(version: 2, default: :true) do resources :products end end resources :products root to: 'products#index' end
After restarting the server if we visit the /api/products
path we’ll get the default version of our API, version 2. We can use curl
in the terminal to specify the Accept
header to get a different version.
$ curl -H 'Accept: application/vnd.example.v1' http://localhost:3000/api/products
This returns a response with products that have a released_on
attribute which matches version 1 of our API.