ActiveResourceでChatworkのAPIクライアントを作る際にハマったところと解決策

れこです。
久々に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
end

URL末尾から.json等のフォーマットを消したい

ActiveResource::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のように:付きのパスを定義する
  • paramshoge_idを指定する

has_many的なものは

has_many :members, class_name: 'chatwork/member'

で定義できます。
class_nameオプションを渡さないと、クラスが定義されている名前空間によらずトップレベルの名前空間が指定されてしまうので注意です。
注意点として、この方法ではクエリパラメータを渡すことが出来ません 解決方法は後述します。

belongs_to的なものは、残念ながらChatworkでは意図したとおりに動きません。
これも後述します。

利用方法はテストを見ていただくほうが早いと思います。

クエリパラメータが必要なhas_manyを作りたい

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のような柔軟さは実現できないので、性能に難が出ない程度にすっぱり諦めた方が良いと思います。。。

レスポンスに主キーがなくてもbelongs_toしたい

おそらく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が無くてもなんとかできます。

Railsのルーティングに反するURLに対応したい

/v1/my/tasksのように、Railsのルーティングに対応してないURLもなんとかしたい。
ActiveResourceにはカスタムメソッドの機能があります。

# {ActiveResource::Baseを継承したクラス}.{HTTPメソッド(小文字)}(:パス, オプション)
Chatwork::My.get(:tasks, status: 'open')

という感じで、ただのHTTPクライアント的な使い方もできるようです。
戻り値が配列だったらArray、戻り値がオブジェクトならHashのインスタンスが返るようです。
このままではActiveResourceのメソッド郡が使えないのでなんとかしたい。。。

カスタムメソッドでもActiveResource::Baseのインスタンスを返したい

カスタムメソッドを使うと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というフィールド名になっています。
このままではフィールド名が噛み合わずsavedestroyの挙動に支障をきたします。

これを上書きするには、primary_keyというプロパティを変更します。

self.primary_key = 'room_id'

こうすれば、レスポンス内部にidというキーがなくてもマッピングしてくれました。

まとめ

やはりRailsでないアプリケーションにActiveResourceを対応させるのは少々無理が生じるようです。
それでもChatworkのURL構造はだいぶRailsにRESTfulな感じなので、比較的軽度に収まりました。
もしオレオレ全開なAPIに対応するとしたら、カスタムメソッドを多用することになりそうだなぁ、、、と感じました。

この内容が、少しでもActiveResourceでRails以外のAPIクライアントを作るときの助けになれば幸いです。