れこです。
久々に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
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
のように:付きのパスを定義する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クライアントを作るときの助けになれば幸いです。