🐈

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"リポジトリを取得してみます。
https://docs.github.com/ja/rest/repos/repos?apiVersion=2022-11-28#get-a-repository

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メソッドを使用します。取得した結果は以下の通りです。
https://github.com/octokit/octokit.rb/blob/ea3413c3174571e87c83d358fc893cc7613091fa/lib/octokit/client/repositories.rb#L26

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メソッドを確認します。

repositories.rb
      def repository(repo, options = {})
        get Repository.path(repo), options
      end

https://github.com/octokit/octokit.rb/blob/ea3413c3174571e87c83d358fc893cc7613091fa/lib/octokit/client/repositories.rb#L26

getメソッドを呼び出しているだけのシンプルなコードですね。そのgetメソッドの中身は以下の通りです。

connection.rb
    def get(url, options = {})
      request :get, url, parse_query_and_convenience_headers(options)
    end

https://github.com/octokit/octokit.rb/blob/ea3413c3174571e87c83d358fc893cc7613091fa/lib/octokit/connection.rb#L18

getメソッドではrequestメソッドのget処理をラップしているだけなので、requestを追います。

connection.rb
    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

https://github.com/octokit/octokit.rb/blob/ea3413c3174571e87c83d358fc893cc7613091fa/lib/octokit/connection.rb#L149

マッピング処理を追いかけるうえでの重要なコードは以下の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自体はメソッドとなっており、次のようなメソッドとなっています。

connection.rb
    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

https://github.com/lostisland/sawyer/blob/f5f080d5c5260e094069139ffc7c13d0acba4ab5/lib/sawyer/agent.rb#L90

このcallメソッドでFaradayを使用したリクエストのレスポンスを、Responseクラスの初期化でインスタンス変数に格納しています。
https://github.com/lostisland/sawyer/blob/f5f080d5c5260e094069139ffc7c13d0acba4ab5/lib/sawyer/response.rb#L14

それでは2つ目の重要なコードである、response_data_correctly_encoded(response)メソッドを追いかけます。対象のメソッドは次のとおりです。

connection.rb
    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

https://github.com/octokit/octokit.rb/blob/ea3413c3174571e87c83d358fc893cc7613091fa/lib/octokit/connection.rb#L212

このメソッドにて、先ほどのSawyer::Responseクラスのインスタンスを引数に渡しています。このメソッドの最後の行にて、dataメソッドを呼び出しています。これはSawyer::Responseクラスのメソッドとなっており、ここでResourceクラスのインスタンスを生成する処理が行われます。具体的な処理は以下の通りです。

response.rb
    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

https://github.com/lostisland/sawyer/blob/f5f080d5c5260e094069139ffc7c13d0acba4ab5/lib/sawyer/response.rb#L25

中身を見てみると、レスポンス情報を持つbodyのインスタンス変数をdecodeしてprocess_dataメソッドにdata変数として渡しています。このprocess_dataメソッドでは、レスポンスのデータがHashの場合はSawyer::Resourceクラスのインスタンスを生成し、Arrayの場合はdata.mapしてprocess_dataメソッドを再帰的に呼び出しています。これにより、Hashのデータを持つリポジトリ情報をSawyer::Resourceクラスのインスタンスとして取得できます。

Resourceクラスの初期化メソッドを見てみると、以下のようになっています。

resource.rb
    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

https://github.com/lostisland/sawyer/blob/f5f080d5c5260e094069139ffc7c13d0acba4ab5/lib/sawyer/resource.rb#L15

この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メソッドを使用してプロパティの値を取得しています。

resource.rb
    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

https://github.com/lostisland/sawyer/blob/f5f080d5c5260e094069139ffc7c13d0acba4ab5/lib/sawyer/resource.rb#L81

このmethod_missingメソッドでは、メソッド名を正規表現で分解し、プロパティ名とサフィックスを取得しています。これにより、superを返すケース除き、主に以下5パターンを判別しています。

  1. suffix=の場合、つまりid=のような呼び出しの場合、プロパティの値をセットする処理を実施します
  2. suffixがなかった場合、つまりidのような呼び出しの場合、プロパティの値を取得する処理を実施します
  3. suffix?の場合、つまりid?のような呼び出しの場合、プロパティの値を真偽値として取得する処理を実施します
  4. suffixnilで、呼び出しメソッドがagent rels fieldsのいずれかの場合、このResourceクラスのインスタンス変数を取得する処理を実施します
  5. 呼び出そうとしたメソッドがレスポンスにもResourceクラスにも存在しない場合、nilを返します

今回のケースではidメソッドが呼び出されているため、2のプロパティの値を取得する処理が実行されます。

ハッシュを使ってプロパティの値を取得する場合

次に、ハッシュとしてプロパティの値を取得する方法を見てみます。こちらは[]メソッドを使用してプロパティの値を取得しています。

resource.rb
    def [](method)
      send(method.to_sym)
    rescue NoMethodError
      nil
    end

https://github.com/lostisland/sawyer/blob/f5f080d5c5260e094069139ffc7c13d0acba4ab5/lib/sawyer/resource.rb#L57

渡されたハッシュをシンボルに変換しsendメソッドを使用して呼び出しています。この呼び出し先はmethod_missingメソッドに繋がります。
そのため[:id]のように呼び出すとsend(:id)が実行され、前述のプロパティをメソッド名として使用し値を取得する場合項目で説明したプロパティの値を取得する処理が実行されます。

まとめ

今回の記事では、octokitを使ってGitHub APIのレスポンスを取得する方法と、そのレスポンスをRubyのオブジェクトとして扱う仕組みについて解説しました。

今回の記事を書くにあたりコードを追いかけながら感じたこととして、このマッピングの仕組みがシンプルで綺麗という点があります。難しい仕組みにならず、非常にわかりやすいコードが魅力的ですね。

この記事が誰かのお役に立てれば幸いです。

Money Forward Developers

Discussion