Rails5 apiモード + JSONAPI ResourcesでAPIサーバを作る

jsonapi-resourcesはこちら
cerebris/jsonapi-resources: A resource-focused Rails library for developing JSON API compliant servers.

下準備

インストールまで

いつものなのでサクサクいきます。

$ bundle init
# Gemfile
source 'https://rubygems.org'

gem 'rails', '5.0.0.rc1'
$ bundle install
$ bundle exec rails new . --api
# 上書きを確認されるので適当にYesしておく
# Gemfile
# 以下を追記。
# rubygemsにあるのだとRails5に対応していないのでgithubから取得していることに注意。
gem 'jsonapi-resources', git: '[email protected]:cerebris/jsonapi-resources.git', ref: '5e4fd81e516fe73395d8607520e96d6a013ff741'

設定変更

Controllerに機能追加(必須)

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include JSONAPI::ActsAsResourceController
end

開発しやすいようにconfigいじっておく(任意)

# config/environments/development.rb
Rails.application.configure do
  # 以下2つを変更。
  config.eager_load = true
  config.consider_all_requests_local = false
end

開発しやすいようにinitializersとしてちょっといじっておく(こっちも任意)

#config/initializers/jsonapi-resources.rb
# 各設定の意味はこちらを参照: https://github.com/cerebris/jsonapi-resources/blob/master/lib/jsonapi/configuration.rb
JSONAPI.configure do |config|
  # :underscored_key, :camelized_key, :dasherized_key, or custom
  # jsで扱い易いようにJSONのkeyをlowerCamelCaseにする。
  config.json_key_format = :camelized_key
  # :none, :offset, :paged, or a custom paginator name
  # pagenationの形式をoffsetに変更
  config.default_paginator = :offset
end

実装

サンプルはtwitter風な何かを考えましょう。
Userがいて各UserはTweet複数もちます。
Userはpasswordを直接DBにもちます(サンプルなのに真面目にやるのが面倒なので)

$ bin/rails g model User screen_name:string name:string password:string
$ bin/rails g model Tweet user_id:integer text:text

associationも貼りましょう。

# app/models/User.rb
class User < ApplicationRecord
  has_many :tweets
end
# app/models/Tweet.rb
class Tweet < ApplicationRecord
  belongs_to :user
end

はい、ここからがJSONAPI::Resourcesを使う本番です。

app/resources配下にAPIを書いていくことになります。

attributesAPIとして公開したい情報を書きます。
ここに記載されないものはAPIからは公開されません。
しかしassociationのものをここに書いてはいけません。 has_manyhas_one を使って記述します
(※ これらは違う書き方もできます。詳しくは公式のREADMEを参照。)

# app/resources/user_resource.rb
# class名はmodel名 + Resource
class UserResource < JSONAPI::Resource
  # attributesでmodelにdispatchしたいメソッドを書く
  attributes :name, :screen_name
  # associationも書く
  has_many :tweets
end
# app/resources/tweet_resource.rb
class TweetResource < JSONAPI::Resource
  attributes :text
end

これでAPIの定義はできたのでroutingとcontrollerを書いておきましょう。
actionは勝手に定義してくれるのでなんもなくてOKです。

$ bin/rails g controller users
$ bin/rails g controller tweets
Rails.application.routes.draw do
  jsonapi_resources :users
  jsonapi_resources :tweets
end

さて、では動作確認をしましょう。

$ bundle exec rake db:migrate
$ rake routes
                   Prefix Verb      URI Pattern                                    Controller#Action
user_relationships_tweets GET       /users/:user_id/relationships/tweets(.:format) users#show_relationship {:relationship=>"tweets"}
                          POST      /users/:user_id/relationships/tweets(.:format) users#create_relationship {:relationship=>"tweets"}
                          PUT|PATCH /users/:user_id/relationships/tweets(.:format) users#update_relationship {:relationship=>"tweets"}
                          DELETE    /users/:user_id/relationships/tweets(.:format) users#destroy_relationship {:relationship=>"tweets"}
              user_tweets GET       /users/:user_id/tweets(.:format)               tweets#get_related_resources {:relationship=>"tweets", :source=>"users"}
                    users GET       /users(.:format)                               users#index
                          POST      /users(.:format)                               users#create
                     user GET       /users/:id(.:format)                           users#show
                          PATCH     /users/:id(.:format)                           users#update
                          PUT       /users/:id(.:format)                           users#update
                          DELETE    /users/:id(.:format)                           users#destroy
                   tweets GET       /tweets(.:format)                              tweets#index
                          POST      /tweets(.:format)                              tweets#create
                    tweet GET       /tweets/:id(.:format)                          tweets#show
                          PATCH     /tweets/:id(.:format)                          tweets#update
                          PUT       /tweets/:id(.:format)                          tweets#update
                          DELETE    /tweets/:id(.:format)                          tweets#destroy
$ bin/rails s

意気揚々とchromeとかでアクセスするとエラーが出ます。

{"errors":[{"title":"Not acceptable","detail":"All requests must use the 'application/vnd.api+json' Accept without media type parameters. This request specified 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'.","id":null,"href":null,"code":"406","source":null,"links":null,"status":"406","meta":null}]}

ちゃんとheaderを設定してcurlしてあげましょう。

$ curl -i -H "Accept: application/vnd.api+json" http://localhost:3000/users
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/vnd.api+json; charset=utf-8
ETag: W/"8fe32e407a1038ee38753b70e5374b3a"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: d80811e1-20b7-4259-8865-2ea1ad823899
X-Runtime: 0.012141
Transfer-Encoding: chunked

{"data":[]}

適当にデータを突っ込みます。(seedでいいのかっていうのはおいといて)

# db/seed.rb
if User.count == 0
  3.times do |i|
    User.create(name: "name#{i}", screen_name: "screen_name#{i}", password: "pass#{i}")
  end
end

if Tweet.count == 0
  User.all.each do |u|
    10.times do |i|
      Tweet.create(user_id: u.id, text: "#{u.name} < text#{i}")
    end
  end
end
$ bundle exec rake db:seed
$ curl -H "Accept: application/vnd.api+json" http://localhost:3000/users | jq '.data | .[0]'
{
  "id": "1",
  "type": "users",
  "links": {
    "self": "http://localhost:3000/users/1"
  },
  "attributes": {
    "name": "name0",
    "screenName": "screen_name0"
  },
  "relationships": {
    "tweets": {
      "links": {
        "self": "http://localhost:3000/users/1/relationships/tweets",
        "related": "http://localhost:3000/users/1/tweets"
      }
    }
  }
}

attributesに指定していないのでpasswordが含まれていませんね。
またhas_manyでtweetsをもつとしたのでrelationshipsをもっています。

$ curl -H "Accept: application/vnd.api+json" http://localhost:3000/tweets | jq '.data | .[0]'
{
  "id": "1",
  "type": "tweets",
  "links": {
    "self": "http://localhost:3000/tweets/1"
  },
  "attributes": {
    "text": "name0 < text0"
  }
}

tweetにはrelationshipsを指定していないのでattributesのみです。

注意点

belongs_toがない

has_oneを使いましょう。
APIリソースとして表現する以上、そのリソースを主体として考えるべきでbelongs_toなんてことはないんだ、という理由なのかと推測している。

attributeとしてassociationのもの指定してしまう

attributesでassociationを取れる名前を指定すればassociationの指定がなくてもとってくることは可能。
しかしN+1問題が起きてしまうためやるべきではないと思う

# app/resources/user_resource.rb
class UserResource < JSONAPI::Resource
  attributes :name, :screen_name
  has_many :tweets
end
$ curl -H "Accept: application/vnd.api+json" 'http://localhost:3000/users?include=tweets'
Started GET "/users?include=tweets" for ::1 at 2016-05-22 14:02:42 +0900
Processing by UsersController#index as API_JSON
  Parameters: {"include"=>"tweets"}
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 10], ["OFFSET", 0]]
  Tweet Load (0.4ms)  SELECT "tweets".* FROM "tweets" WHERE "tweets"."user_id" IN (1, 2, 3)
   (0.2ms)  SELECT COUNT(*) FROM "users"
Completed 200 OK in 24ms (Views: 7.9ms | ActiveRecord: 0.8ms)

一方でattributesで指定したとき。

# app/resources/user_resource.rb
class UserResource < JSONAPI::Resource
  attributes :name, :screen_name, :tweets
end
$ curl -H "Accept: application/vnd.api+json" http://localhost:3000/users
Started GET "/users" for ::1 at 2016-05-22 14:04:04 +0900
Processing by UsersController#index as API_JSON
  User Load (0.1ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 10], ["OFFSET", 0]]
   (0.2ms)  SELECT COUNT(*) FROM "users"
  Tweet Load (0.3ms)  SELECT "tweets".* FROM "tweets" WHERE "tweets"."user_id" = ?  [["user_id", 1]]
  Tweet Load (0.3ms)  SELECT "tweets".* FROM "tweets" WHERE "tweets"."user_id" = ?  [["user_id", 2]]
  Tweet Load (0.2ms)  SELECT "tweets".* FROM "tweets" WHERE "tweets"."user_id" = ?  [["user_id", 3]]
Completed 200 OK in 59ms (Views: 28.0ms | ActiveRecord: 2.4ms)