初めて書いたのでメモ程度ですが記録を残しておきます。
はじめに
どの API の Strategy を書いた?
Timely というタイムトラッキング系のアプリです。
会社だと Toggl や自動で記録する RescueTime などを使ってるというのを聞きましたが、細かい時間管理というよりは、一日のどれぐらいの割合をレビューとかタスクに使ってるのかというのを知りたかったので、単純にタスクの見積もりと実績を計測するだけという Timely にしました。
見積もりを入れるという行為が、タスクを小さくしたり、レビューを一定時間で打ち切る動機にもなりますし、集中スイッチ的に働いて良いです。が、まあこの手のツールはいくらでもあるので、これが特段素晴らしいと push するつもりもないですね
個人的には、ノルウェー産(?)というので少しポイント上がりました、心からどうでもいいけど。
API はどんなもの?
上記ページが全てのようです。 OAuth2 Authentication となっています。
書いてみた
書いたものはこちら https://github.com/dany1468/omniauth-timely
omniauth-oauth2 を使う
OAuth2 対応となっていたので、omniauth-oauth2
を利用しました。
OAuth2 を用いた Strategy を簡単に書けるようにしてくれています。
以下の記事がとても詳しいので合わせて参照すると良さそうです。
OmniAuth OAuth2 を使って OAuth2 のストラテジーを作るときに知っていると幸せになれるかもしれないこと
README にある通りですが、もっともそのまま使える状態だと以下のような箇所だけ書けばよいようです。
require 'omniauth-oauth2'
module OmniAuth
module Strategies
class SomeSite < OmniAuth::Strategies::OAuth2
# NOTE strategy の名前になるので指定する。
option :name, 'some_site'
# NOTE user_id など unique な id。
uid { raw_info['id'] }
info do
{
name: raw_info['name'],
email: raw_info['email']
}
end
# NOTE skip_info の判定は有効にしておいて良さそう
extra do
skip_info? ? {} : {raw_info: raw_info}
end
def raw_info
@raw_info ||= access_token.get('/me').parsed
end
end
end
end
以下のようなメジャーなサービスの Strategy もこのライブラリを利用していますね。(カスタマイズはされていますが)
ここからは Timely の Strategy を作っていくにあたり、つまずいた点を中心に紹介します。
Strategy 作成時につまずいた点
Timely は callback_url が登録されたものと完全一致しか許可しない
omniauth はデフォルトだと callback_url を以下のように生成します。
def callback_url
full_host + script_name + callback_path + query_string
end
しかし Timely は query_string
を含んだ URL を callback_url に指定すると invalid_credentials
となってしまいます。どうやら、アプリケーション登録時に設定した URL と全く同じでないといけないようです。
よって、 callback_url
をオーバーライドし、query_string
を無くした形にしました。 コード
raw_info の取得後のパースでエラーが出る
Timely は /accounts
にアクセスすることでユーザー情報を取得できるはずなのですが、 access_token.get('/1.0/accounts').parsed
とするだけではエラーになってしまいました。
まず、access_token は何者か?
omniauth-oauth2
に出てくる access_token
は oauth2
ライブラリのクラスを指しています。
access_token クラス
https://github.com/intridea/oauth2/blob/master/lib/oauth2/access_token.rb
access_token 内部で使われる client と access_token の生成箇所
https://github.com/intridea/oauth2/blob/master/lib/oauth2/client.rb#L128-L142
access_token.get はどうなっているか
以下の 2 箇所を中心に見ていきます。
- https://github.com/intridea/oauth2/blob/master/lib/oauth2/access_token.rb#L107-L117
- https://github.com/intridea/oauth2/blob/master/lib/oauth2/client.rb#L88-L120
client クラスの #request
は全てのリクエストで使われるため get に限定されてものではありませんが、option や Response をどう処理しているかが分かります。
取得したトークンをどう用いて API にアクセスするか
以下の access_token の #initialize
で取得したトークンをどういう形式で渡すかを設定できるのが分かります。
- https://github.com/intridea/oauth2/blob/master/lib/oauth2/access_token.rb#L51-L53
- https://github.com/intridea/oauth2/blob/master/lib/oauth2/access_token.rb#L148-L150
def initialize(client, token, opts = {})
# 略
@options = {:mode => opts.delete(:mode) || :header,
:header_format => opts.delete(:header_format) || 'Bearer %s',
:param_name => opts.delete(:param_name) || 'access_token'}
# 略
end
def headers
{'Authorization' => options[:header_format] % token}
end
デフォルトは header に "Authorization: Bearer #{access_token}"
のような形で渡されることになります。もし、Strategy を作りたい API がこれ以外の渡し方を必要とするならば、変更する必要がありますね。
パースエラーの対処方法
access_token.get
には option を渡せます。上述した client クラスに parse
オプションがあるのがわかると思います。
これは、返却された値をパースする方式を指定するのですが、デフォルトは automatic
となっており、Content-Type によって自動で選択されるようです。
今回は直接 parse: :json
とオプションを指定することで解決できました。
refresh_token はあるが、expires が取得できない
API ドキュメントの通りですが、Timely API はなぜか refresh_token
は取得できるのですが、expires
に関する情報が取れません。
omniauth-oauth2
は以下のように expires
系が存在しないと refresh_token
を credential
情報として認識してくれません。
credentials do
hash = {"token" => access_token.token}
# expires が無いと refresh_token があってもダメ
hash.merge!("refresh_token" => access_token.refresh_token) if access_token.expires? && access_token.refresh_token
hash.merge!("expires_at" => access_token.expires_at) if access_token.expires?
hash.merge!("expires" => access_token.expires?)
hash
end
もちろん、今回も Strategy 側でこの Credential を上書きすれば良かったのですが、expires
が無い以上は使いみちの無い refresh_token
を受け取ったところで保存することも無さそうなので取得しない事にしました。
その他
テストを書く際の Strategy の initialize について
Strategy のテストコードを書くのに、この Strategy はどういう風にインスタンス化されるのか追ってみると以下のような箇所にたどり着きました。両方とも omniauth
のコードです。
- https://github.com/intridea/omniauth/blob/1cc1cf4b2821a7d2a4a376a5ca93c61b6bd8b5f1/lib/omniauth/strategy.rb#L129-L146
- https://github.com/intridea/omniauth/blob/1cc1cf4b2821a7d2a4a376a5ca93c61b6bd8b5f1/lib/omniauth/builder.rb
Rack::Builder
継承のクラスがどのように振る舞うのかきちんと理解できていませんが、Strategy は initialize 時の第一引数が Rack application、第二引数以降で、Strategy の引数、オプションと続くということが分かったことでテストが書けました。
# Initializes the strategy by passing in the Rack endpoint,
# the unique URL segment name for this strategy, and any
# additional arguments. An `options` hash is automatically
# created from the last argument if it is a hash.
#
# @param app [Rack application] The application on which this middleware is applied.
#
# @overload new(app, options = {})
# If nothing but a hash is supplied, initialized with the supplied options
# overriding the strategy's default options via a deep merge.
# @overload new(app, *args, options = {})
# If the strategy has supplied custom arguments that it accepts, they may
# will be passed through and set to the appropriate values.
#
# @yield [Options] Yields options to block for further configuration.
def initialize(app, *args, &block) # rubocop:disable UnusedMethodArgument
API 側と omniauth-oauth2 とのギャップを埋めることのできるポイント
今回も callback_url
をオーバーライドし、やりませんでしたが credentials
も検討しました。では、どこまでが簡易にオーバーライドできる対象なのかということですが、結局は Strategy
として include される対象しか直接のオーバーライドは難しいので以下の 2 箇所になりそうです。
- https://github.com/intridea/omniauth/blob/1cc1cf4b2821a7d2a4a376a5ca93c61b6bd8b5f1/lib/omniauth/strategy.rb
- https://github.com/intridea/omniauth-oauth2/blob/master/lib/omniauth/strategies/oauth2.rb
デバッグについて
今回も callback_url
の問題とパースの問題は多少面倒で、簡単な Sinatra アプリを書いて動作確認をしつつでした。
パースの方は、自分で書いた Strategy の方にログを入れて状況確認し、後はコードを追ってなんとかしましたが、callback_url
の方は何が起こっているのかさっぱりでした。
その時役に立ったのがoauth2
ライブラリの client クラスは DEBUG オプションです。
https://github.com/intridea/oauth2/blob/master/lib/oauth2/client.rb#L89
connection.response :logger, ::Logger.new($stdout) if ENV['OAUTH_DEBUG'] == 'true'
これで Faraday のロガーを有効にしてくれるので、どのフェーズでエラーになったのかや、どういうリクエストを送っているのかが分かりデバッグが捗りました。
ただ、この DEBUG オプションは罠が潜んでいて以下の issue の事象が発生します。よって、トークンの取得まで進んだらこのオプションは外しましょう。