#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)
商品管理用のアプリケーションがあります。扱っている商品の一覧や個別の商品の詳細情報を見ることができ、また商品情報の新規作成・編集・削除も可能です。
それに加えて、HTMLインターフェース以外でも商品を管理できるようにREST APIも提供したいと思います。アプリケーションのProductsController
はすでにRESTfulなスタイルに従っているので、これを利用して以下のように各アクションにrespond_to
ブロックを追加するだけでJSONリクエストに対応できます。
def index @products = Product.all respond_to do |format| format.html format.json { render json: @products } end end
/products.json
にアクセスすると、商品のリストがJSON形式で取得できます。
これでJSON APIは完成でしょうか? 実はこの方法には大きな問題があります。それはバージョン管理に関する問題です。APIは一貫性がとても重要です。例えば各商品にはreleased_on
という属性があり、商品の発売日を返します。もしここで時間も返したいのでカラム名をreleased_at
に変えるという場合、このAPIを利用しているアプリケーションは、特にそのreleased_on
属性を使用している場合に、動作しなくなります。
JSON APIをHTMLインターフェースと連動させるのは、一貫性のあるAPIを作るという観点から言うと、最善の方法とは言えない場合が多く、この問題を解決するためのgemがいくつかあります。Versionistはアプリケーションのルーティングによってバージョン管理されたAPIを簡単に扱うことができ、いくつかのジェネレータを提供します。RocketPantsはREST APIを簡単に作成できるようにするgemの一つです。このgemにはバージョン管理以外にも多くの機能があります。もし使用を検討している場合は、READMEを一読することをお勧めします。
今回のエピソードではgemは使わずゼロからAPIを書くことによって、RailsのroutesのしくみとそれがAPIのバージョン管理にどう適用できるかについてより理解を深めていきます。このアプリケーションの現状のroutesファイルは以下の通りです。
Store::Application.routes.draw do resources :products root to: 'products#index' end
API用にルートをいくつか追加して、これらのルートをHTMLインターフェース用のルートとは別にします。そのためにapi
という名前空間を使用するので、そこで定義されたルートにはパスに/api
が付きます。このapi
名前空間の代わりにサブドメイン制約を加えることもできるのですが、今回の目的には名前空間を利用する方法の方がより合っているでしょう。バージョン管理をどういう方法で行なうかも考えなくてはいけません。一つの方法はバージョンをURLの一部として保持するやり方で、これもnamespace
を呼び出すことで対応可能です。この名前空間で定義されたコントローラやルートはすべて一つの名前空間内にあるものとして扱われます。とりあえずproducts
リソースをここに置いて、商品情報をRESTfulスタイルで設定します。
Store::Application.routes.draw do namespace :api do namespace :v1 do resources :products end end resources :products root to: 'products#index' end
/app/controllers
ディレクトリにapi
ディレクトリを、さらにその下にv1
サブディレクトリを作成します。このディレクトリにProductsController
を作成します。
module Api module V1 class ProductsController < ApplicationController end end end
このクラスはApplicationController
を継承していますが、もしアプリケーションのすべてのAPIコントローラとふるまいを共有させたい場合はApi::BaseController
を継承させるという方法もあります。コントローラができたのでアクションを追加します。
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
各アクションを、JSON形式で応答できるようrespond_with
の呼び出しを使って定義しました。これは単純化されたやり方で、実際には必要なJSON APIのためにさらに設定が必要になるでしょう。/api/v1/products.json
にアクセスするとAPIを確認できます。
このURLではJSON形式を指定する必要があり、.json
拡張子を付けないと何も応答がありません。JSONをデフォルト形式にするのであれば、routesで次のようにdefaults
オプションを使用します。
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
http://localhost:3000/api/v1/products
にアクセスしてもJSONが返されるようになりました。
APIの新バージョンを作成する
これまでのところはうまくいっていますが、APIの仕様を変更しなければならない場合はどうすればいいでしょうか? 例えばreleased_on
列をreleased_at
に名称変更したいとしましょう。これを実際に試すために、この列の名称と型を変更するmigrationを生成します。
$ rails g migration change_products_released_on
migrationのコードは以下のとおりです。
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
rake db:migrate
を実行するとデータベースに変更が加えられます。ブラウザでAPIのページをリロードするとreleased_on
列がreleased_at
に置き換わってタイムスタンプが入っています。これは後方互換の変更ではありません。
このようなAPIの一貫性を壊すような変更が自動的にわかるようにするしくみを持つべきなので、APIの新しいバージョンを作成する前に現状のものを修正します。コントローラでrespond_with
を使用しているため、これは少し複雑になります。もっとも簡単な方法は、エピソード322で行なったRABLなどを使うやり方です。この方法ではJSONレスポンスで返される属性をより自由にカスタマイズできます。
今回のシンプルなAPIに対しては、とりあえず動くようにするために手早く直に手を加える方法をとります。APIがよくテストされている場合であれば、これは現実的なアプローチだと言えるでしょう。今回行なうことは、既存のProduct
モデルクラスを継承した新規のProduct
クラスをProductsController
の中に作成し、それに対して変更を加えます。これによって、コントローラ内でのProductへの参照はすべてオリジナルではなく新規に作成したサブクラスに対して行なわれます。この新規クラスでメソッドをオーバーライドすることで、APIのこのバージョンだけで違うふるまいをさせることができます。ここでto_json
を上書きして、JSONの出力にreleased_on
属性を追加することができます。
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
バージョン1のAPIページをリロードすると、再度機能するようになりreleased_on
属性が元に戻りました。
もしアプリケーションにテストがあったら、ここで再度成功に戻るはずです。各商品についてreleased_at
属性が返されるようになりましたが、これは古いAPIを利用しているユーザの移行を簡単にするという理由で利点であると捉えることができます。厳密に言うとこの時点でAPIの新しいバージョンを公開する必要はありませんが、せっかくここまでやったので公開しないわけにはいかないでしょう。
新しいバージョンを公開するには、app/controllers/api/v1
のコードを新しいv2
ディレクトリにコピーするだけです。
$ cp -R app/controllers/api/v1 app/controllers/api/v2
routesファイルでv1
名前空間のroutesを新しいv2
名前空間にコピーします。
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
routesファイル内に重複部分が増えてきたら、lambdaを作成して各バージョンのlambdaブロックを渡すこともできますが、これはバージョン間でroutesファイルが同じである場合のみ可能です。新しいv2
ProductsController
で、v1
に加えた変更部分を削除してモジュール名を変更します。
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
APIのバージョン2にアクセスすると、deprecated(廃止予定)のreleased_on
属性がない、よりきれいな出力が得られます。
この方法でバージョン管理を行なうと、コードの重複が増えるように見えるかも知れません。バージョン2のProductsController
はバージョン1とほとんど同じように見えます。しかしこれはDRYの原則を破っている訳ではありません。あるバージョンのコードを変更するときに、別のバージョンも変更する必要があるかどうかを常に意識することは重要です。しかしそれはここでは当てはまりません。古いバージョンに新バージョンの機能を追加するというニーズはほとんどないからです。それよりも、古いバージョンにはそれ専用のコードを持っておいて後方互換性を確保するべきでしょう。また、いつかのタイミングで古いバージョンを削除することも考えられるので、ここでこれ以上のリファクタリングを行なうのはあまり意味がありません。それでも必要だと考えるのであれば、APIの両方のバージョンが使用する共有のふるまいを定義したスーパークラスを別に作成することもできます。
バージョン番号
続いてはバージョン番号を指定する方法についてです。現状のURLパスに含む方法はシンプルで直接的ですが、最善の方法ではないという意見もあります。例えばGithubは、バージョン番号をURLに含むのではなくAccept
ヘッダで渡されるように仕様を変更しました。今回のアプリケーションで同じことを行なうにはどうすればいいでしょうか?
まず最初にroutesの名前空間をscope
の呼び出しに置き換えます。これによって、使用するモジュールと制約を指定することができます。制約のロジックは複雑なので別のクラスに移すことにします。
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
v2
の制約に特別なオプションを追加して、これがデフォルトのバージョンであることを指定していることに注意してください。routesファイルで使用するApiConstraints
クラスは今まで通り定義する必要があり、これを/lib
ディレクトリで定義します。
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
このクラスはとてもシンプルです。initialize
でオプションをインスタンス変数に抽出します。またrouterが制約のために起動するmatches?
メソッドも定義します。ここで、デフォルトバージョンが求められているのか、あるいはリクエストのAccept
ヘッダと与えられたバージョン文字列が一致するかをチェックします。この文字列はヘッダの中で一致させたいどんな文字列でもかまいません。
利便性をよくするためにroutesファイルの最初でこのクラスをrequireします。
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
サーバの再起動後に/api/products
パスにアクセスすると、APIのデフォルトバージョンであるバージョン2を取得できます。ターミナルでcurl
を使ってAccept
ヘッダを指定すれば別のバージョンも取得できます。
$ curl -H 'Accept: application/vnd.example.v1' http://localhost:3000/api/products
これによって、APIのバージョン1に一致する、released_on
属性を含んだ商品リストが返されます。