RailsでAPIサーバを開発する(AngularJS, Ruby on Rails, SPA)

rails
134件のシェア(ちょっぴり話題の記事)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

RailsでJSONを返すAPIアプリケーションを3週間ぐらい試行錯誤しながら作成しています。少しですがノウハウも溜まってきたのでここにまとめておこうと思います。
今回のアプリケーションの構成は大体次のようになっています。

  • RailsはAPIサーバ(一般公開するAPIではなくSPA(シングルページアプリケーション)のサーバとしてJSONを返却する。HTMLは返却しない)
  • クライアントサイドはAngularJSで画面遷移、Viewの描画まで管理する
  • DBはMySql、Session管理はRedis(まだローカル開発なのであまり関係無い)

チームはサーバサイド、クライアントサイドで完全に分担して二人で作成しています(自分はサーバサイド担当)。
このブログエントリーでは次のことを書きます。

  • APIのルーティングの設定(JSONのみ返すようにする方法)
  • Session管理(CSRFトークンの受け渡し方法など)
  • APIのテスト方法(RSpecを使っています)

APIのルーティング

JSON formatの指定

各Routingをnamespace :api {format: 'json'}を全体に囲みます フォーマットをjson固定で指定したので、URLに「.json」をつけなくてもJSONが返却されます。 (「/api/users」というURLでJSONが返却されます)

namespace :api, {  format: 'json' } do
  resources :admin_users
  resources :domains do
     resources :sites
  end
  post 'login' => 'user_sessions#create'
  delete 'logout' =>'user_sessions#destroy'
  route to: 'welcome#index'
end

Controllerのnamespace

routes.rbでapiというnamespaceで全体を囲んでいるのでControllerにもnamespaceを付けます。付けたnamespaceに合わせてディレクトリも作成します(app/controllers/apiディレクトリを作成)。

module Api
  class UsersController < ApplicationController
  
  def index
    @users = User.all
    render json: @users
  end
end

Session(Cookie)の管理

CSRF-Tokenとsession_storeについて書きます。

CSRF-Token

今回のアプリケーションではサーバサイドでViewをレンダリングしないので、そのままではクライアントとのCSRF−Tokenの受け渡しができません。
なのでトークンの受け渡しをするためにクライアント(今回はAngularJS)側で、リクエストヘッダーのX−CSRF-Tokenにサーバから渡されたトークンを設定してリクエストを送ります。
とりあえず開発フェーズでそこを後回しにしたい場合はCSRF-Tokenを無効にすることもできます。

module Api
  class ApplicationController < ActionController::Base
    protect_from_forgery with: :exception
    skip_before_filter :verify_authenticity_token    
  end
end 

(protect_from_forgery with: null_sessionだとcookie自体が無効になってしまうのでこのように指定しています )

session_store

純粋なAPIサーバの場合、アクセストークンを発行して有効期限を設定するというやり方が一般的です。 ですが今回はSPA用のサーバとしてRailsアプリケーションをAPIサーバにしているだけなので、通常のRailsアプリケーションと同様にCookieを利用してSession管理をしています(session_idの受け渡し方法として)。
話はそれますが今回作成しているアプリケーションはsession_soreとしてRedisを使用しているのでそこの設定を書いておきます。

  • session_store.rbに書いてある元の設定は削除する
    # Rails.application.config.session_store :cookie_store, key: '_spa-demo_session'
    
  • 環境用設定ファイル(development.rb, production.rb)にredisの設定をする
    config.session_store :redis_store, {
                             key: '_spa-demo_session',
                             servers: {
                               host: 'localhost',
                               port: '6379',
                               db: '0'
                             },
                             expire_after: 60.minutes
                           }
    

APIのTest方法

spec/requestsを使ってテストします。今回のRailsアプリケーションは画面が無いためCapybaraは使いません。
コントローラのレスポンスをシュミレートするためにget、post、put、deleteメソッドを使います。
(以下はadmin_users_controller.rbというコントローラを作ったと仮定して、そのAPIに対するテストケースです)

一覧(index)API

describe 'GET admin_users API', type: :request do
  it 'populates an array of all admin_users and does not include delete_containers' do
    suzuki = create(:admin_user, user_name: "鈴木一朗", email: "suzuki@co.jp", container_nums: 5)
    yamada = create(:admin_user, user_name: "山田太郎", email: "yamada@co.jp", container_nums: 5)

    get "/api/admin/admin_users"
    expect(response.status).to eq 200
    expect(assigns(:admin_users)).to match_array([@admin_user, suzuki, yamada])
  end
end
  • レスポンスオブジェクトのステータスフィールドを確認します。
  • match_arrayメソッドで返却値の配列に作成したオブジェクトが含まれていることを確認します。なおmatch_arrayは順番は保証しないので、順番の確認が必要な場合は結果のJSON配列を確認する必要があります。

詳細(show)API

describe 'GET admin_users#show API', type: :request do
  before :each do
    @admin_user = create(:admin_user, id: 1)
    login @admin_user
  end

  it 'assigns the requested admin_user to @admin_user' do
    get "/api/admin/admin_users/#{@admin_user.id}", nil

    expect(response).to have_http_status(:success)
    json = JSON.parse(response.body)
    expect(json["id"]).to eq @admin_user.id
    expect(json["user_name"]).to eq @admin_user.user_name
    expect(json["admin_user_role_id"]).to eq @admin_user.admin_user_role_id
    expect(json["email"]).to eq @admin_user.email
  end
end
  • 詳細APIではオブジェクトのそれぞれのフィールドを確認しています。responseオブジェクトのbodyフィールドをjsonオブジェクトに変換してから確認します。
  • beforeメソッドのなかで実施している「login @admin_user」はこのAPIを表示するための認証処理をパスするために行っています(APIテストには直接関係ない処理ですが後で説明します)。

更新(update)API

describe 'PUT admin_users#update API', type: :request do
  before :each do
    @admin_user = create(:admin_user)
    login @admin_user
  end

  context "valid attributes" do
    it "changes admin_user's attributes" do
      updated_date = DateTime.new(2015, 7, 7, 11, 12, 13)
      admin_user = create(:admin_user,
                          id: 2,
                          email: "before@co.jp",
                          user_name: '変更前',
                          admin_user_role_id: 1,
                          updated_at: updated_date)
      new_attributes = { id: 2,
                         email: 'after@co.jp',
                         user_name: '変更後',
                         admin_user_role_id: 2,
                         updated_at: updated_date}
      put "/api/admin/admin_users/#{admin_user.id}", new_attributes
      admin_user.reload
      expect(response).to be_success
      expect(admin_user.email).to eq('after@co.jp')
      expect(admin_user.user_name).to eq('変更後')
      expect(admin_user.admin_user_role_id).to eq(2)
    end
  end
end                                                                  
  • APIをコールする時はリクエストパラメータをハッシュで指定するので、ここでもnew_attributesというハッシュを作成してputメソッドの引数に渡しています。
  • putメソッド実行後、オブジェクトをreloadメソッドで更新して各フィールドがAPI実行後に期待値に変わったか確認しています

登録(post)API

describe 'POST admin_users API', type: :request do
  it 'creates admin_user' do
    params = { email: "test@co.jp", user_name: "テスト管理者", password: "password",
               password_confirmation: "password"}
    # 登録が成功してDBのレコードが一件増えていることを確認する
    expect {
      post "/api/admin/admin_users", params
    }.to change(AdminUser, :count).by(1)
     expect(response.status).to eq 201
  end
end                                                                                                                                                                                                                                                  

ここでは登録APIが成功していることを「expect { XXX }.to change(YYY).by(1) 」の箇所で確認しています。 私は今回のプロジェクトでほぼ初めてRSpecを使ったのですが、この書き方は最初ビビりました。。が、慣れてくると見やすいですね、多分。
これはRSpecのchangeというマッチャで次のように使います。

expect{ XXX }.to change{ YYY }.from(AAA).to(BBB)

XXXの処理をするとYYYのオブジェクトがAAAの状態からBBBの状態に変わることを期待する

削除(delete)API

登録とは反対に削除されることを確認するだけなのでコードは省略します。 changeマッチャを使って一件レコードが減っていることを確認すればよいだけです!

expect {
  delete "/api/admin/admin_users", id
}.to change(AdminUser, :count).by(-1)

認証チェックをパスしてテストする方法

先ほど詳細APIのところで少し触れた以下の記述ですが、これはAPIをテストするために認証処理をシミュレートをするための記述です。
認証処理にSorceryというgemを使用していますが、独自の認証処理でもあってシミュレートする仕組みは大体同じだと思います。

before :each do
  @admin_user = create(:admin_user)
  login @admin_user
end

1.ログインをシミュレートするためのモジュールの作成

「rspec/support」ディレクトリに「authentication.rb」というファイルを作成して次のように処理を書きます。

module Authentication
  def login user, password = 'password'
    user.update_attribute :password, password
    post '/api/admin/login', { email: user.email, password: password }
  end
end
  • postメソッドの引数にはloginのパスを指定する
  • emailではなくてユーザー名で認証している場合は{ user_name: user.name, password: password }にする

2.作成したモジュールを読み込ませる

rails_helperに今回作成したモジュールを追加してテスト実行時に利用できるようにします

RSpec.configure do |config|
  config.include Sorcery::TestHelpers::Rails::Integration, type: :request # 追加
  config.include Authentication # 追加
end

ここまでの設定をすれば、APIのテスト時に認証処理をパスすることができるようになります。

現段階でのAngularJS & RailsのSPAアプリケーションについての感想

最後にまだまだ触ったばかりでしっかりと理解はできていないのですがRailsとAngularJSによるSPAについての雑感です。
メリット次のようなものがあると感じています。

  • サーバサイド、クライアントサイドにエンジニアを用意してチームを組めるのであれば分担して開発ができる
  • クライアント開発(JS、デザイン)の実力者がいればその人の能力をフルに活かせる

一方デメリットはこんな感じでしょうか。

  • 単純な画面(ログイン、一覧画面等)までクライアントサイドで全て作るのはむしろ生産性が下がる(気がする)
  • APIのインターフェースが変更になるとどちらも影響を受ける

まだまだ始めたばかりなのでもう少しこのスタイルの開発を噛み砕いてきたらまた感想などを書きたいと思います。