API ClientやWrapperのように、プログラムから外部APIを扱いやすくするためのクラスやモジュールを作成することがあり、そのようなクラスやモジュールの内部ではAPIの呼び出しにHTTPクライアントが用いられることが一般的である。
HTTPクライアントとはRubyであればNet::HTTPのようなライブラリやモジュールのことであり、言語によって標準ライブラリとして提供されているものを使うこともあれば、任意のライブラリを選択して使用することもある。
また、API ClientやWrapperのようなクラス/モジュールの作成では、コードの共通化や標準化を目的としてHTTPクライアントライブラリ/モジュールの抽象化層を設けることが多い一方、開発者間で抽象化層の設計や実装にブレが出やすいという課題もある。
今回はこのような課題に対して「HTTPクライアントライブラリの抽象化レイヤー」(今回はFaradayを例とする)をライブラリ(gem)として導入することで、「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 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の概念が取り入れられている。

の3コンポーネントで構成されている。
FaradayによるHTTPクライアントの抽象化

抽象化層の本体となる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
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")
実際の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
前述した「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::Connectionオブジェクトの利用
:json、:raise_error)による共通化
以下のような理由で、開発者間の設計方針や実装のブレを抑えることができると考えている。
API Clientの作成に必要なHTTPクライアントとしての機能が概ね揃っているまた、Octokit,Signet,slack-ruby-clientはじめ有名サービスのAPI ClientライブラリでもFaradayが採用されていることも多い。結果的に依存として入るのであれば、プロダクトコードでAPI Clientを実装する場合もFaradayを使うと工数の削減や保守性の担保という観点からコスパのよい選択肢になるのではないか。