読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

RailsでRoarを使ってAPIサーバーとAPIクライアントを作る

Rails中級 API Rails Model

f:id:nipe880324:20150708005750j:plain:w420
samuelrodgers752 | Flickr - Photo Sharing!

RoarはRepresenterを使ってRESTなAPIをパース、レンダーすることができるgemです。
つまり、Roarを使うことで、RailsでJSONを返すAPIサーバーを作成したり、逆に、APIサーバーにアクセスするAPIクライアントをRubyで作れます。

下記に記載しましたが、有名なgemに比べて、メリットとしては、Rubyでサーバーとクライアントを作る場合、同じような箇所を幾分か共有できる点です。
デメリットとしては、個人的にパースやレンダー時にエラーが発生してもデバッグしづらく対処しづらいことです。

サーバー側でAPIを作る場合、「Ruby Toolbox - API Builders」によると、jbuilderやGrape、Rablなどが人気のようです。
APIにアクセスするクライアントを作る場合、「Ruby Toolbox - HTTP Clients」によるとRest-ClientやFaradayなどが人気です。


動作確認

  • Rails 4.2.3
  • Ruby 2.2.0
  • Roar 1.0.1

1. Roarの簡単な使い方

1.1. Railsにインストール

Gemfileに追加します。

gem 'roar-rails'

bundle installを実施すれば完了です。


1.2. Representerの定義

rails g representerコマンドでRepresenterを作ることができます。

rails g representer Tweet id content
      create  app/representers/tweet_representer.rb


app/representers配下にRpresenterが作成されます。
propertyでRepresenterでレンダーやパースする値を定義します。

# app/representers/user_representer.rb
module TweetRepresenter
  include Roar::JSON

  property :id
  property :content
end


Representerの定義では、他にも、パースやレンダー時に値を変換したり、パースやレンダーをスキップしたりといろいろとカスタマイズができるので、
困ったら以下のREADMEを読むと良いと思います。


1.3. レンダー(JSON, Hash, XML)

定義したRepresenterをextendすし、to_jsonto_hashメソッドを呼ぶことで、JSONやHashを出力することができます。
TweetRepresenteridcontentを定義しているのでその2つしか出力されません。

class Tweet < ActiveRecord::Base; end

tweet = Tweet.create(content: 'Hoge')
tweet.extend(::TweetRepresenter)

tweet.to_json
# =>"{\"id\":1,\"content\":\"Hoge\"}"

tweet.to_hash
# => {"id"=>1, "content"=>"Hoge"}


また、RailsでJSONを返したい場合は、次のようにします。
render json:は引数に渡したオブジェクトのto_jsonメソッドを呼び出した結果を返します。
そのため、Representerで定義したidcontentのみが返されます。

# app/controllers/api/tweets_controller.rb
class Api::TweetsController < ApplicationController
  skip_before_action :verify_authenticity_token

  def show
    tweet = Tweet.find(params[:id]).extend(::TweetRepresenter)
    render json: tweet
    # =>"{\"id\":1,\"content\":\"Hoge\"}"
  end
end


XML形式で出力したい場合は、RepresenterにRoar::XMLをincludeし、to_xmlメソッドを呼び出します。

class Tweet < ActiveRecord::Base; end

module TweetRepresenter
  include Roar::XML

  property :id
  property :content
end

tweet = Tweet.last
tweet.extend(::TweetRepresenter)
tweet.to_xml
#=> "<tweet>\n  <id>1</id>\n  <content>Hoge</content>\n</tweet>"


1.4. パース(JSON, Hash, XML)

定義したRepresenterをextendすし、to_jsonto_hashメソッドを呼ぶことで、JSONやHashを出力することができます。
TweetRepresenteridcontentを定義しているのでその2つしか出力されません。

class Tweet < ActiveRecord::Base; end

tweet = Tweet.new.extend(::TweetRepresenter)
tweet.from_json("{\"id\":1,\"content\":\"Hoge\"}")
# => #<Tweet id: 1, content: "Hoge", created_at: nil, updated_at: nil>

tweet = Tweet.new.extend(::TweetRepresenter)
tweet.from_hash({ 'id' => 1, 'content' => 'Hoge' })
# => #<Tweet id: 1, content: "Hoge", created_at: nil, updated_at: nil>

# from_hashはHashのキーがシンボルの場合うまく認識してくれません。
tweet = Tweet.new.extend(::TweetRepresenter)
tweet.from_hash({ id: 1, content: 'Hoge' })
# => #<Tweet id: nil, content: nil, created_at: nil, updated_at: nil>

# with_indifferent_accessを使いましょう
tweet.from_hash({ id: 1, content: 'Hoge' }.with_indifferent_access)
# => #<Tweet id: 1, content: "Hoge", created_at: nil, updated_at: nil>


また、RailsでJSONやHashを受け取って、オブジェクトを作成したい場合は、from_jsonfrom_hashを使います。
Representerで定義したidcontentのみが取得してオブジェクトを作成します。

# app/controllers/api/tweets_controller.rb
class Api::TweetsController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    tweet = Tweet.new.extend(::TweetRepresenter)
    tweet.from_hash(params[:tweet])      # httpリクエストの場合
    # tweet.from_json(request.body.read) # jsonリクエストの場合

    if tweet.save
      render json: tweet, status: :created
    else
      render json: tweet.errors.full_messages, status: :unprocessable_entity
    end
  end
end


XMLをパースしたい場合も、レンダーと同じようにRepresenterにRoar::XMLをincludeし、from_xmlを呼び出します。

class Tweet < ActiveRecord::Base; end

module TweetRepresenter
  include Roar::XML

  property :id
  property :content
end

xml =<<XML
<tweet>
  <id>1</id>
  <content>Hoge</content>
</tweet>
XML

tweet = Tweet.new
tweet.extend(::TweetRepresenter)
tweet.from_xml xml
# => #<Tweet id: 1, content: "Hoge", created_at: nil, updated_at: nil>


1.5. Decoratorの定義と使い方

パフォーマンスやオブジェクト汚染のためextendが嫌いな人のために、デコレーターで実行することもできます。
次のようにRoar::Decoratorを継承することでデコレーターを定義します。

# app/representers/tweet_representer.rb
class TweetRepresenter < Roar::Decorator
  include Roar::JSON
  include Roar::Hypermedia

  property :id
  property :name

  # Decorator内の represented はデコレートするモデルを表します。
end


作成したデコレーターでラップします。

# app/controllers/api/tweets_controller.rb
class Api::TweetsController < ApplicationController
  skip_before_action :verify_authenticity_token

  def show
    tweet = Tweet.find(params[:id])
    decorator = TweetRepresenter.new(tweet)
    render json: decorator
    # =>"{\"id\":1,\"content\":\"Hoge\"}"
  end
end

2. Roarでクライアントとサーバーの連携

2.1. 概要

Roarでサーバー側のRailsアプリ(Tweet)とクライアント側のRailsアプリ(Blog)を連携するようにします。
サンプルはroar_test - GitHubにあります。

シナリオとしては、Tweetアプリ(サーバー側)を既に運用しており、新しいBlogアプリ(クライアント側)を立ち上げようと考えていて、Tweetアプリにデータを公開したいというという感じをイメージして作りました。
ER図は次の通りで、クライアント側はartcilesしかないが、artcileを投稿した時に、合わせてTag付きでTweetも投稿できるみたいなことをしています。
f:id:nipe880324:20150708005219p:plain:w420


詳細はGitHubを参照してみればいいので、RoarでCRUDをしながら連携する方法の抜粋(かなり雑です)を記載しました。


2.2. showアクション(単一アイテムの取得)

クライアントの詳細画面で、articleとtweetを表示します。
f:id:nipe880324:20150708005205p:plain:w420


クライアントのコントローラーは次の通りです。

# app/controllers/artcles_controller.rb
def show
  # ローカル(クライアント)DBから取得
  @article = Article.find(params[:id])

  # Roarでサーバー側からデータを取得
  @tweet = ::Json::Tweet::Client.build.show(@article.remote_tweet_id)
end


Roarの定義とクライアントのコードです。
Roar::Clientをincludeすることで、get, post, put, deleteメソッドがincludeされます。
buildメソッドでクライアントを作成し、showメソッドで、サーバー側のRailsにアクセスしています。
レスポンスは、ReprsenterClientで定義されている、id, content, tagsをパースし、OpenStructの値として代入します。
このとき、asオプションを使うことで、Tweet.id を Article.remote_tweet_id に変換しています。

# app/representer/json/tweet.rb
module Json
  class Tweet < OpenStruct
    module Representer
      include Roar::JSON
      collection_representer class: ::Json::Tweet

      property :id
      property :content

      collection :tags, class: ::Json::Tag, extend: ::Json::Tag::Representer
    end

    # only client side
    module Client
      include Roar::JSON
      include Representer
      include Roar::Client

      # ServerからClientへ受け取ったときの変換処理
      property :remote_tweet_id, as: :id,    # Tweet.id => Article.remote_tweet_id に変換
               skip_render: true

      collection :tags, class: ::Json::Tag, extend: ::Json::Tag::Client

      # Clientの作成メソッド(Singular用)
      def self.build
        ::Json::Tweet.new.extend(::Json::Tweet::Client)
      end

      # APIのURL
      def self.api_url
        "http://localhost:3001/api/tweets"
      end

      # リモートのTweetsController#showにアクセス
      def show(id)
        get(uri: "#{::Json::Tweet::Client.api_url}/#{id}", as: 'application/json')
      end
    end
  end
end


サーバー側では、単純にServerをextendしているだけです。

# app/controllers/api/tweets_controller.rb

def show
  @tweet = Tweet.find(params[:id])
  render json: @tweet.extend(::Json::Tweet::Server)
    # {
    #   "id": 1,
    #   "content": "tweet 1",
    #   "tags": [
    #     { "id": 1000, "name": "tag 1" },
    #     { "id": 1001, "name": "tag 2" }
    #   ]
    # }
end


サーバー側のRepresenterです。

module Json
  class Tweet < OpenStruct
    module Representer
      include Roar::JSON
      collection_representer class: ::Json::Tweet

      property :id
      property :content

      collection :tags, class: ::Json::Tag, extend: ::Json::Tag::Representer
    end

    module Server
      include Roar::JSON
      include Representer

      collection :tags, class: ::Tag, extend: ::Json::Tag::Server,
                        parse_strategy: :find_or_instantiate
    end
  end
end


2.3. indexアクション(複数アイテムの取得)


コレクションの取得の場合、コレクション用のクライアントを作成し、一覧を取得します。

# app/controllers/articls_controller.rb
def index
  @articles = Article.all
  # コレクション用のクライアントを作成し、allメソッドで一覧を取得
  @tweets = ::Json::Tweet::Client.build_collection.all
end

コレクションを取得するには、配列をextendします。また、Representer.for_collectionをextendする必要が有ります。
allメソッドは、サーバー側のTweetsController#indexアクションにアクセスします。

# app/representers/json/tweet.rb
module Json
  class Tweet < OpenStruct
    ...

    # only client side
    module Client
      ...

      # Clientの作成メソッド(Collection用)
      def self.build_collection
        [].extend(::Json::Tweet::Client).extend(::Json::Tweet::Representer.for_collection)
      end

      # リモートのTweetsController#indexにアクセス
      def all
        get(uri: ::Json::Tweet::Client.api_url, as: 'application/json')
      end
    end
  end
end


サーバーのコントローラーでもfor_collectionを使って、コレクションを返すようにしています。
>|ruby
def index
@tweets = Tweet.all
render json: @tweets.extend(::Json::Tweet::Server.for_collection)
# [
# {
# "id": 1,
# "content": "tweet 1",
# "tags": [
# { "id": 1000, "name": "tag 1" },
# { "id": 1001, "name": "tag 2" }
# ]
# },
# {
# "id": 2,
# "content": "tweet 2",
# "tags": []
# }
# ]
end
|


2.4. create, update, destroyアクション(アイテムの作成、更新、削除)

コレクションを作成し、作成、更新、削除を行います。

# app/controllers/articles_controller.rb

# POST /articles
def create
  @article = Article.new(article_params)

  @tweet = ::Json::Tweet::Client.build.from_hash(params[:article])
  @tweet.create
  @article.remote_tweet_id = @tweet.remote_tweet_id

  if @article.save
    redirect_to @article, notice: 'Article was successfully created.'
  else
    render :new
  end
end

# PATCH/PUT /articles/1
def update
  @article = Article.find(params[:id])

  @tweet = ::Json::Tweet::Client.build.from_hash(params[:article])
  @tweet.update(@article.remote_tweet_id)

  if @article.update(article_params)
    redirect_to @article, notice: 'Article was successfully updated.'
  else
    render :edit
  end
end

# DELETE /articles/1
def destroy
  @article = Article.find(params[:id])

  @tweet = ::Json::Tweet::Client.build
  @tweet.destroy(@article.remote_tweet_id)

  @article.destroy
  redirect_to articles_url, notice: 'Article was successfully destroyed.'
end


2.5. 多対多関連のCUD

TweetとTagは多対多関係です。

まず、クライアントサイドでは次のようにして、リクエストを送ります。

# 画面から次のようなパラメータがフォームから送られてきます。
{
  "utf8"=>"", "authenticity_token"=>"xxx",
  "article" => {
    "title" => "article 1", "content" => "client article",
    "tags" => [{ "id" => "1000" }, { "id" => "1001" }, { "id" => "", "name" => "new tag" }]
  },
  "commit"=>"Update Article",
  "id"=>"1"
}

# コントローラーで画面のフォーム情報をfrom_hashでパースして取得します
# createメソッドでリクエストを送ります。
def create
  ...
  @tweet = ::Json::Tweet::Client.build.from_hash(params[:article])
  @tweet.create
  ...
end

# from_hashのパース時に取得されるデータは次のように定義しています。
module Json
  class Tweet < OpenStruct
    module Representer
      include Roar::JSON
      collection_representer class: ::Json::Tweet

      property :id
      property :content

      collection :tags, class: ::Json::Tag, extend: ::Json::Tag::Representer
    end

    module Client
      include Roar::JSON
      include Representer
      include Roar::Client

      # ClientからServerへのリクエストを送るときの変換処理
      property :title, as: :content,  # Article.title => Tag.content 用にキー名を変換
               render_filter: -> (value, _doc, _args) { value.to_s[0, 10] + '...' } # Twitter用に文字列を短くする
      collection :tags, class: ::Json::Tag, extend: ::Json::Tag::Client
    end
  end
end

module Json
  class Tag < OpenStruct
    module Representer
      include Roar::JSON
      collection_representer class: ::Json::Tag

      property :id
      property :name
    end

    # only client side
    module Client
      include Roar::JSON
      include Representer
      include Roar::Client
    end
  end
end

サーバーサイドでは次の通りです。

# POSTされるjsonデータは次のようになりmす
{"id"=>"1", "content"=>"client art...", "tags"=>[{"id"=>"1000"}, {"id"=>"1001"}, {"id"=>"", "name"=>"new tag"}] }

# controllerで取得し、from_jsonでパースし、値を設定し保存
tweet = Tweet.new.extend(::Json::Tweet::Server).from_json(request.body.read)
tweet.save

# パースの内容はRepresenterで定義
# parse_strategy: :find_or_instantiate はidが既にあればそのインスタンスを返し、
# idがなければ新しいインスタンスを作成する
module Server
  include Roar::JSON
  include Representer

  collection :tags, class: ::Tag, extend: ::Json::Tag::Server,
                    parse_strategy: :find_or_instantiate
end


以上です。