octokit.rb活用時のSawyer::Resourceクラスの扱い方とデータマッピングの仕組み
はじめに
こんにちは、M-Yamashitaです。
今回の記事は、GitHub APIをRubyで扱うためのライブラリであるoctokitを使った際のレスポンスであるSawyer::Resource
クラスの扱い方と、そのデータマッピングの仕組みについて解説します。
octokitはGitHub APIをRubyで扱うためのgemです。APIのレスポンスをRubyのオブジェクトとして扱える非常に便利な機能を提供しています。一方でそのマッピングの仕組みについてはあまり記事がないようでした。
そのため本記事では基本的な使い方から始まり、octokitのソースコードを追いかけながらどのようにしてAPIのレスポンスがRubyのオブジェクトとして扱えるようになっているのかを解説します。
octokitを使いプロパティの値を取得する方法
ここでは例として、指定したリポジトリを取得する方法を解説します。
GitHubの公式ドキュメントでは、指定したリポジトリを取得する際にcurlを使う例が掲載されています。この使用方法に沿って、"octocat/Hello-World"リポジトリを取得してみます。
❯ curl -L \
… ❯ -H "Accept: application/vnd.github+json" \
… ❯ -H "Authorization: token <token>" \
… ❯ -H "X-GitHub-Api-Version: 2022-11-28" \
… ❯ https://api.github.com/repos/octocat/Hello-World
この時のレスポンスは以下の通りです。
{
"id": 1296269,
"node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5",
"name": "Hello-World",
"full_name": "octocat/Hello-World",
"private": false,
"owner": {
"login": "octocat",
"id": 583231,
"node_id": "MDQ6VXNlcjU4MzIzMQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/583231?v=4",
"gravatar_id": "",
・・・略・・・
ここでcurlの代わりに、octokitのgemを使ってリポジトリ情報を同様に取得できます。取得にはrepository
メソッドを使用します。取得した結果は以下の通りです。
irb(main):047> client = Octokit::Client.new(access_token: new_access_token)
=> #<Octokit::Client:0x000000013cd79898 ...>
irb(main):048> client.repository('octocat/Hello-World')
=>
{:id=>1296269,
:node_id=>"MDEwOlJlcG9zaXRvcnkxMjk2MjY5",
:name=>"Hello-World",
:full_name=>"octocat/Hello-World",
:private=>false,
:owner=>
{:login=>"octocat",
:id=>583231,
:node_id=>"MDQ6VXNlcjU4MzIzMQ==",
:avatar_url=>"https://avatars.githubusercontent.com/u/583231?v=4",
:gravatar_id=>"",
・・・略・・・
このレスポンスクラスにはSawyer::Resource
クラスを使用して表されています。
irb(main):049> client.repository('octocat/Hello-World').class
=> Sawyer::Resource
指定したリポジトリの取得メソッドのレスポンスだけに限らず、octokitはSawyer::Resource
クラスを使用して、GitHub APIのレスポンスをRubyのオブジェクトとして扱えます。これにより、APIから取得したデータを簡単に操作できます。
レスポンス内にある各プロパティの値を取り出したい時は、プロパティをメソッドの形として呼び出す方法や、プロパティをハッシュとして指定する方法もあります。例えばid
を取得したい場合、以下のように記述できます。
irb(main):050> client.repository('octocat/Hello-World').id
=> 1296269
irb(main):051> client.repository('octocat/Hello-World')[:id]
=> 1296269
データマッピングの仕組みを探る
先ほどレスポンスをオブジェクトとしてそのまま扱えると説明しました。ではなぜそういったデータマッピングが可能なのかソースコードを追いかけてみます。
Sawyer::Resource
クラスのインスタンスを生成するまで
レスポンスから先ほど使用したrepository
メソッドを確認します。
def repository(repo, options = {})
get Repository.path(repo), options
end
get
メソッドを呼び出しているだけのシンプルなコードですね。そのget
メソッドの中身は以下の通りです。
def get(url, options = {})
request :get, url, parse_query_and_convenience_headers(options)
end
get
メソッドではrequest
メソッドのget処理をラップしているだけなので、request
を追います。
def request(method, path, data, options = {})
if data.is_a?(Hash)
options[:query] = data.delete(:query) || {}
options[:headers] = data.delete(:headers) || {}
if accept = data.delete(:accept)
options[:headers][:accept] = accept
end
end
@last_response = response = agent.call(method, Addressable::URI.parse(path.to_s).normalize.to_s, data, options)
response_data_correctly_encoded(response)
rescue Octokit::Error => e
@last_response = nil
raise e
end
マッピング処理を追いかけるうえでの重要なコードは以下の2行です。
@last_response = response = agent.call(method, Addressable::URI.parse(path.to_s).normalize.to_s, data, options)
response_data_correctly_encoded(response)
まずは1つ目の方であるagent.call
を追いかけます。agent
自体はメソッドとなっており、次のようなメソッドとなっています。
def agent
@agent ||= Sawyer::Agent.new(endpoint, sawyer_options) do |http|
http.headers[:accept] = default_media_type
http.headers[:content_type] = 'application/json'
http.headers[:user_agent] = user_agent
http_cache_middleware = http.builder.handlers.delete(Faraday::HttpCache) if Faraday.const_defined?(:HttpCache)
if basic_authenticated?
http.request(*FARADAY_BASIC_AUTH_KEYS, @login, @password)
elsif token_authenticated?
http.request :authorization, 'token', @access_token
elsif bearer_authenticated?
http.request :authorization, 'Bearer', @bearer_token
elsif application_authenticated?
http.request(*FARADAY_BASIC_AUTH_KEYS, @client_id, @client_secret)
end
http.builder.handlers.push(http_cache_middleware) unless http_cache_middleware.nil?
end
end
agent
メソッドではSawyer::Agent
クラスのインスタンスを生成しています。このSawyer::Agent
クラスは、Faradayを使用してHTTPリクエストを行うためのクラスです。call
メソッドの定義は以下の通りです。
def call(method, url, data = nil, options = nil)
if NO_BODY.include?(method)
options ||= data
data = nil
end
options ||= {}
url = expand_url(url, options[:uri])
started = nil
res = @conn.send method, url do |req|
if data
req.body = data.is_a?(String) ? data : encode_body(data)
end
if params = options[:query]
req.params.update params
end
if headers = options[:headers]
req.headers.update headers
end
started = Time.now
end
Response.new self, res, :sawyer_started => started, :sawyer_ended => Time.now
end
このcall
メソッドでFaradayを使用したリクエストのレスポンスを、Response
クラスの初期化でインスタンス変数に格納しています。
それでは2つ目の重要なコードである、response_data_correctly_encoded(response)
メソッドを追いかけます。対象のメソッドは次のとおりです。
def response_data_correctly_encoded(response)
content_type = response.headers.fetch('content-type', '')
return response.data unless content_type.include?('charset') && response.data.is_a?(String)
reported_encoding = content_type.match(/charset=([^ ]+)/)[1]
response.data.force_encoding(reported_encoding)
end
このメソッドにて、先ほどのSawyer::Response
クラスのインスタンスを引数に渡しています。このメソッドの最後の行にて、data
メソッドを呼び出しています。これはSawyer::Response
クラスのメソッドとなっており、ここでResource
クラスのインスタンスを生成する処理が行われます。具体的な処理は以下の通りです。
def data
@data ||= begin
return(body) unless (headers[:content_type] =~ /json|msgpack/)
process_data(agent.decode_body(body))
end
end
# Turns parsed contents from an API response into a Resource or
# collection of Resources.
#
# data - Either an Array or Hash parsed from JSON.
#
# Returns either a Resource or Array of Resources.
def process_data(data)
case data
when Hash then Resource.new(agent, data)
when Array then data.map { |hash| process_data(hash) }
when nil then nil
else data
end
end
中身を見てみると、レスポンス情報を持つbody
のインスタンス変数をdecodeしてprocess_data
メソッドにdata
変数として渡しています。このprocess_data
メソッドでは、レスポンスのデータがHashの場合はSawyer::Resource
クラスのインスタンスを生成し、Arrayの場合はdata.map
してprocess_data
メソッドを再帰的に呼び出しています。これにより、Hashのデータを持つリポジトリ情報をSawyer::Resource
クラスのインスタンスとして取得できます。
Resource
クラスの初期化メソッドを見てみると、以下のようになっています。
def initialize(agent, data = {})
@_agent = agent
data, links = agent.parse_links(data)
@_rels = Relation.from_links(agent, links)
@_fields = Set.new
@_metaclass = (class << self; self; end)
@attrs = {}
data.each do |key, value|
@_fields << key
@attrs[key.to_sym] = process_value(value)
end
@_metaclass.send(:attr_accessor, *data.keys)
end
def process_value(value)
case value
when Hash then self.class.new(@_agent, value)
when Array then value.map { |v| process_value(v) }
else value
end
end
このinitialize
メソッドにて、レスポンスのデータから
- プロパティ名を
Set
クラスのインスタンスにセット - プロパティ名のシンボルとその値を
@attrs
のハッシュにセット- 値については
process_value
メソッドを呼び出して、Hashの場合は再帰的にSawyer::Resource
クラスのインスタンスを生成、配列の場合はmap
して同様に処理を実施、それ以外の場合はそのまま値をセット
- 値については
となるような処理を行なっています。
以上で、octokitのメソッドを呼び出した時にSawyer::Resource
クラスのインスタンスが生成され呼び出し元に返されることがわかりました。
レスポンスのプロパティの値を取得する際の仕組み
ここまでの説明で、Sawyer::Resource
クラスのインスタンスが生成されることがわかりました。次に、Sawyer::Resource
クラスのインスタンスを使ってプロパティの値を取得する際の仕組みについて解説します。
プロパティをメソッド名として使用し値を取得する場合
まずはメソッドとして呼び出す方法を見てみます。前述の例ではclient.repository('octocat/Hello-World').id
として呼び出していました。Sawyer::Resource
クラスにはid
メソッドが定義されていないため、method_missing
メソッドを使用してプロパティの値を取得しています。
def method_missing(method, *args)
attr_name, suffix = method.to_s.scan(/([a-z0-9\_]+)(\?|\=)?$/i).first
if suffix == ATTR_SETTER
@_metaclass.send(:attr_accessor, attr_name)
@_fields << attr_name.to_sym
send(method, args.first)
elsif attr_name && @_fields.include?(attr_name.to_sym)
value = @attrs[attr_name.to_sym]
case suffix
when nil
@_metaclass.send(:attr_accessor, attr_name)
value
when ATTR_PREDICATE then !!value
end
elsif suffix.nil? && SPECIAL_METHODS.include?(attr_name)
instance_variable_get "@_#{attr_name}"
elsif attr_name && !@_fields.include?(attr_name.to_sym)
nil
else
super
end
end
このmethod_missing
メソッドでは、メソッド名を正規表現で分解し、プロパティ名とサフィックスを取得しています。これにより、super
を返すケース除き、主に以下5パターンを判別しています。
-
suffix
が=
の場合、つまりid=
のような呼び出しの場合、プロパティの値をセットする処理を実施します -
suffix
がなかった場合、つまりid
のような呼び出しの場合、プロパティの値を取得する処理を実施します -
suffix
が?
の場合、つまりid?
のような呼び出しの場合、プロパティの値を真偽値として取得する処理を実施します -
suffix
がnil
で、呼び出しメソッドがagent rels fields
のいずれかの場合、このResourceクラスのインスタンス変数を取得する処理を実施します - 呼び出そうとしたメソッドがレスポンスにも
Resource
クラスにも存在しない場合、nil
を返します
今回のケースではid
メソッドが呼び出されているため、2のプロパティの値を取得する処理が実行されます。
ハッシュを使ってプロパティの値を取得する場合
次に、ハッシュとしてプロパティの値を取得する方法を見てみます。こちらは[]
メソッドを使用してプロパティの値を取得しています。
def [](method)
send(method.to_sym)
rescue NoMethodError
nil
end
渡されたハッシュをシンボルに変換しsend
メソッドを使用して呼び出しています。この呼び出し先はmethod_missing
メソッドに繋がります。
そのため[:id]
のように呼び出すとsend(:id)
が実行され、前述のプロパティをメソッド名として使用し値を取得する場合項目で説明したプロパティの値を取得する処理が実行されます。
まとめ
今回の記事では、octokitを使ってGitHub APIのレスポンスを取得する方法と、そのレスポンスをRubyのオブジェクトとして扱う仕組みについて解説しました。
今回の記事を書くにあたりコードを追いかけながら感じたこととして、このマッピングの仕組みがシンプルで綺麗という点があります。難しい仕組みにならず、非常にわかりやすいコードが魅力的ですね。
この記事が誰かのお役に立てれば幸いです。
Discussion