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