kakudooo docs

FaradayでAPI Client実装のブレを抑える

API ClientWrapperのように、プログラムから外部APIを扱いやすくするためのクラスやモジュールを作成することがあり、そのようなクラスやモジュールの内部ではAPIの呼び出しにHTTPクライアントが用いられることが一般的である。

HTTPクライアントとはRubyであればNet::HTTPのようなライブラリやモジュールのことであり、言語によって標準ライブラリとして提供されているものを使うこともあれば、任意のライブラリを選択して使用することもある。

また、API ClientWrapperのようなクラス/モジュールの作成では、コードの共通化や標準化を目的としてHTTPクライアントライブラリ/モジュールの抽象化層を設けることが多い一方、開発者間で抽象化層の設計や実装にブレが出やすいという課題もある。

今回はこのような課題に対して「HTTPクライアントライブラリの抽象化レイヤー」(今回はFaradayを例とする)をライブラリ(gem)として導入することで、「HTTPクライアントの抽象化を実現しながら、開発者間での実装のブレに関しても極力抑えることができる」ということについて書くことにする。

API ClientとHTTPクライアントの抽象化とは?

例として、AbuseIPDB APIのAPI Clientを作成することを考える。 以下は、Rubyの標準ライブラリであるNet::HTTPを使って、あるエンドポイントから結果を取得するコード。

require "json"
require "net/http"

module AbuseIpDb
  class Client
    API_BASE_URL = "https://api.abuseipdb.com/api/v2"

    def check_endpoint(ip_address)
      params = {
        ipAddress: ip_address,
        maxAgeInDays: "90"
      }
      uri = URI.parse("#{API_BASE_URL}/check")
      uri.query = URI.encode_www_form(params)

      request = Net::HTTP::Get.new(uri)
      request["Accept"] = "application/json"
      request["Key"] = ENV["API_KEY"]

      response = Net::HTTP.start(
        uri.host,
        uri.port,
        use_ssl: uri.scheme == "https",
        open_timeout: 5,
        read_timeout: 10
      ) do |http|
        http.request(request)
      end

      JSON.parse(response.body)
    end
  end
end

更にAbuseIpDb::Client/blacklistエンドポイント追加を考える。

も合わせて共通化すると、例えば以下のようになる。

module AbuseIpDb
  class Client
    class ClientError < StandardError; end
    class ServerError < StandardError; end

    API_BASE_URL = "https://api.abuseipdb.com/api/v2"

    def check_endpoint(ip_address)
      params = {
        ipAddress: ip_address,
        maxAgeInDays: "90"
      }
      get("/check", params)
    end

    def blacklist_endpoint(confidence_minimum = "90")
      params = {
        confidenceMinimum: confidence_minimum
      }
      get("/blacklist", params)
    end

    # HTTP GETリクエストとしてinterfaceを抽象化
    def get(endpoint, params)
      uri = URI.parse("#{API_BASE_URL}#{endpoint}")
      uri.query = URI.encode_www_form(params)

      # 共通の認証やHeaderの定義
      request = Net::HTTP::Get.new(uri)
      request["Accept"] = "application/json"
      request["Key"] = ENV["API_KEY"]

      # 共通のHTTPリクエスト時のオプションを定義
      response = Net::HTTP.start(
        uri.host,
        uri.port,
        use_ssl: uri.scheme == "https",
        open_timeout: 5,
        read_timeout: 10
      ) do |http|
        http.request(request)
      end

      # 共通のエラーハンドリング処理を実装
      case response
      when Net::HTTPClientError
        raise ClientError
      when Net::HTTPServerError
        raise ServerError
      end

      JSON.parse(response.body)
    end
  end
end

HTTPリクエスト/レスポンスに関する処理だけでなく、認証ヘッダーの付与、ログ出力、リトライ、エラーハンドリングといった前後の共通処理も統一的に扱えるようにする考え方を、「HTTPクライアントの抽象化」と呼ぶ。

今回は、説明用にメソッドとして切り出すことで簡易的な抽象化を行なったが、クラスを定義して設定値や認証情報を状態として保持したり、レスポンスのJSONパースに対応させるところまで実装するのがより実践的である。

Faradayとは?

Faraday is an HTTP client library abstraction layer that provides a common interface over many adapters (such as Net::HTTP) and embraces the concept of Rack middleware when processing the request/response cycle.

複数のAdapter(例: Net::HTTP)上に共通のインターフェースを提供するHTTPクライアントライブラリの抽象化レイヤー。リクエスト/レスポンスの処理サイクルにおいて、Rack middlewareの概念が取り入れられている。

Faradayの構成図

の3コンポーネントで構成されている。

FaradayによるHTTPクライアントの抽象化

FaradayによるHTTPクライアント抽象化の図

Connection

抽象化層の本体となるFaraday::Connectionクラスおよびオブジェクトを指す。 FaradayではこのFaraday::Connectionクラスのオブジェクトから、HTTPリクエストを実行することが推奨されている。

以下は、例

conn = Faraday.new(
  url: 'http://httpbingo.org',
  params: {param: '1'},
  headers: {'Content-Type' => 'application/json'}
)

response = conn.post('/post') do |req|
  req.params['limit'] = 100
  req.body = {query: 'chunky bacon'}.to_json
end
# => POST http://httpbingo.org/post?param=1&limit=100

参照: https://lostisland.github.io/faraday/#/getting-started/quick-start?id=faraday-connection

Middleware

HTTP request/responseの前後で必要な以下のような各機能を、再利用および組み合わせ可能な形で扱うことを可能にする層。

前述したようにFaradayは、リクエストの作成にRackに影響を受けたMiddleware stackを使う実装になっている。 抽象化のフレームワークを提供しつつ、個別具体の機能については使用者側でカスタマイズできるところがMiddlewareの仕組みを取り入れていることのメリットである。

また、middlewareは自作することも可能。いくつかのmiddlewareは標準でFaradayに組み込まれており、その他のものもgemとして提供されている。

以下は、例

require 'faraday'

conn = Faraday.new do |f|
  f.request :json # encode req bodies as JSON
  f.response :logger # logs request and responses
  f.response :json # decode response bodies as JSON
  f.adapter :net_http # Use the Net::HTTP adapter
end

response = conn.get("http://httpbingo.org/get")

Adapter

実際のHTTP request/response処理を担当する層。 使用するHTTPクライアントは差し替え可能であり、デフォルトではNet::HTTPが設定されている。

以下は、例

グローバルな設定方法

require 'faraday'
require 'faraday/httpclient'

Faraday.default_adapter = :httpclient

Connection毎に設定する方法

require 'faraday'
require 'faraday/httpclient'

conn = Faraday.new do |f|
  f.adapter :httpclient
end

参照: https://lostisland.github.io/faraday/#/adapters/index

Faradayを使うと

前述した「API ClientとHTTPクライアントの抽象化とは?」のサンプルコードを、Faradayを使用して書き換えてみる。

module AbuseIpDb
  class PlainClient
    API_BASE_URL = "https://api.abuseipdb.com/api/v2"

    def check_endpoint(ip_address)
      params = {
        ipAddress: ip_address,
        maxAgeInDays: "90"
      }

      connection.get("/check", params)
    end

    def blacklist_endpoint(confidence_minimum = "90")
      params = {
        confidenceMinimum: confidence_minimum
      }

      connection.get("/blacklist", params)
    end

    private

    def connection
      Faraday.new(url: API_BASE_URL, request: { open_timeout: 5, read_timeout: 10 }) do |http|
        http.headers["Accept"] = "application/json"
        http.headers["Key"] = ENV["API_KEY"]
        http.response :json
        http.response :raise_error
      end
    end
  end
end

なぜFaraday(HTTPクライアントの抽象化層)を使うことで実装のブレが抑えられるのか?

以下のような理由で、開発者間の設計方針や実装のブレを抑えることができると考えている。

また、Octokit,Signet,slack-ruby-clientはじめ有名サービスのAPI ClientライブラリでもFaradayが採用されていることも多い。結果的に依存として入るのであれば、プロダクトコードでAPI Clientを実装する場合もFaradayを使うと工数の削減や保守性の担保という観点からコスパのよい選択肢になるのではないか。