れこです。
久々にRubyの記事です。
仕事でよくChatWorkを使用するので、いい加減オレオレAPIクライアントじゃなくてちゃんとしたのを作ろう
ということで、ActiveResourceを利用したAPIクライアントを作ってみました。
ActiveResourceは基本的にRuby on Railsで作られたアプリケーション用のAPIクライアントなのですが、汎用的に作られているのでChatWorkのAPIにも対応できました。
ということで他のAPIにもActiveResourceを利用するために備忘録を残しておきます
gem化してGithubに上げてあります。
GitHub – Leko/activeresource-chatwork: ActiveResource classes for ChatWork API
gemの作り方については、こちらの記事がとても参考になりました。
ChatWorkのAPIは、リクエストはx-www-form-urlencodedに対しレスポンスはapplication/jsonという特殊な要件なので、
ActiveResource::Formats::JsonFormatを拡張したフォーマッタを作成しました。
リポジトリのlib/chatwork/base.rbに定義してます。コードはこんな感じ。
class FormToJsonParser
  include ActiveResource::Formats::JsonFormat
  def mime_type
    'application/x-www-form-urlencoded'
  end
  def decode(json)
    ActiveSupport::JSON.decode(json)
  end
end
class Base < ActiveResource::Base
  self.format = FormToJsonParser.new
  # ...
  def to_json(options = {})
    json = if include_root_in_json
             super({ root: self.class.element_name }.merge(options))
           else
             super(options)
           end
    hash = JSON.parse(json)
    URI.encode_www_form(hash)
  end
endActiveResource::Base#format_extensionを読んでいたら発見。
class Base < ActiveResource::Base
  self.include_format_in_path = false
endで対応できました。
ChatWorkは/v1/rooms/:room_id/messages/:message_idのように、ネストしたルーティングが必要になります。
ActiveResourceにはActiveRecordのようにリレーションの機能があるようですが、一部要件を満たせなかった(※後述)ので、下記の記事も参考にしつつ試してみました。
ActiveResource : Passing prefix options
ちなみに情報が古いのかupdate_attributesに関しては上手く動きませんでした。
リポジトリのlib/chatwork/message.rbに実装例が有りますが、大枠としては
prefixプロパティに:hoge_idのように:付きのパスを定義するparamsにhoge_idを指定するhas_many的なものは
has_many :members, class_name: 'chatwork/member'で定義できます。
class_nameオプションを渡さないと、クラスが定義されている名前空間によらずトップレベルの名前空間が指定されてしまうので注意です。
注意点として、この方法ではクエリパラメータを渡すことが出来ません 解決方法は後述します。
belongs_to的なものは、残念ながらChatWorkでは意図したとおりに動きません。
これも後述します。
利用方法はテストを見ていただくほうが早いと思います。
has_manyはオプション引数を受け取ってくれないので、クエリパラメータが必要な場合、has_manyを利用することが出来ません。
ということでリレーションが使えないならメソッドを自作します。
実装にあたり、下記の記事がとても参考になりました。
wholemeal: Active Resource - Associations and Nested Resources
lib/chatwork/room.rbに定義してます。
def messages(params = {})
    Message.all(params: subroute_params(params))
  endという感じに、リレーションっぽいメソッド名で.allや.find、.first等を使用してそれっぽく見せてます。
ちなみに多用すると N+1のHTTPリクエスト という甚大なボトルネックが生まれます。
まぁHTTP+ActiveResourceではSQL+ActiveRecordのような柔軟さは実現できないので、性能に難が出ない程度にすっぱり諦めた方が良いと思います。。。
おそらくActiveResourceは
# /users/1.json
{ id: 1, name: 'xxx' }
# /users/1/comments.json
{ id: 100, user_id: 1, content: 'xxx' }のようなものを想定しているため、レスポンスの中にuser_idに相当するフィールドがないとcommentsからuserを見ることが出来ません。
ChatWorkでの例に置き換えると、
/rooms/:room_id/membersのレスポンスにroom_idが含まれていないので、belongs_toでは紐付けが出来ません。
belongs_toはパスを生成する時にレスポンスの中身しか見てくれないないようです。なぜかprefix_optionsを見てくれません。
ということでメソッドを自作します。
lib/chatwork/nest_of_room.rbに定義してます。
module ChatWork
  module NestOfRoom
    def room
      Room.find(prefix_options[:room_id])
    end
  end
end
module ChatWork
  class Member < Base
    include ChatWork::NestOfRoom
  end
endという感じで、prefix_optionsを使ってRoom.findすれば、レスポンスにroom_idが無くてもなんとかできます。
/v1/my/tasksのように、Railsのルーティングに対応してないURLもなんとかしたい。
ActiveResourceにはカスタムメソッドの機能があります。
# {ActiveResource::Baseを継承したクラス}.{HTTPメソッド(小文字)}(:パス, オプション)
ChatWork::My.get(:tasks, status: 'open')という感じで、ただのHTTPクライアント的な使い方もできるようです。
戻り値が配列だったらArray、戻り値がオブジェクトならHashのインスタンスが返るようです。
このままではActiveResourceのメソッド郡が使えないのでなんとかしたい。。。
カスタムメソッドを使うとHashかArrayになってしまうので、なんとかしたい。
ActiveResource::Base.newはHashを受け取るので、受け取ったレスポンスをそのまま渡せます。
つまりゴリ押しです。もしかしたら相当するオプションが有るのかもしれません。
lib/chatwork/my.rbに定義してます
def self.tasks(params = {})
      get(:tasks, params).map { |t| ChatWork::Task.new(t, true) }
    endこれを応用すれば、レスポンスを任意のクラスに変換できそうです。
件数の多いAPIだと.newのオーバーヘッドが地味にありそうなので、ご利用は計画的に。
デフォルトだとレスポンス内のidというフィールドを主キーと見なす、という作りになっています。
ChatWorkでいえば、/roomsのレスポンス内の主キーはroom_idというフィールド名になっています。
このままではフィールド名が噛み合わずsaveやdestroyの挙動に支障をきたします。
これを上書きするには、primary_keyというプロパティを変更します。
self.primary_key = 'room_id'こうすれば、レスポンス内部にidというキーがなくてもマッピングしてくれました。
やはりRailsでないアプリケーションにActiveResourceを対応させるのは少々無理が生じるようです。
それでもChatWorkのURL構造はだいぶRailsにRESTfulな感じなので、比較的軽度に収まりました。
もしオレオレ全開なAPIに対応するとしたら、カスタムメソッドを多用することになりそうだなぁ、、、と感じました。
この内容が、少しでもActiveResourceでRails以外のAPIクライアントを作るときの助けになれば幸いです。