こんにちは、チョウです。
最近HTTP APIを利用できるようにクライアントを作ってみました。そこで使ってるHTTP Clientのライブラリはnet/httpです。ほかにいろいろライブラリがありますが、依存を増やしたくないため、見送りました。
HTTP API Clientで対応したいケース
- GET with query
- POST application/json
- PUT/PATCH application/json
- DELETE
- timeout
つまりRESTful HTTP APIでよく見られるケースです。最後のtimeoutはリクエストの内容ではなくて、クライアントは一定の時間内で返せる機能です。
net/httpのドキュメントを見ると、すぐできそうなのはGETのケースです。
uri = URI('http://example.com/index.html')
params = { :limit => 10, :page => 3 }
uri.query = URI.encode_www_form(params)
res = Net::HTTP.get_response(uri)
puts res.body if res.is_a?(Net::HTTPSuccess)
そしてPOSTの方はformが対応してるっぽいです。
uri = URI('http://www.example.com/search.cgi')
res = Net::HTTP.post_form(uri, 'q' => 'ruby', 'max' => '50')
puts res.body
application/jsonの場合は基本リクエストのbodyに内容を書き込むので… やはりnet/httpのソースコードを読むしかないのか。
いろいろ調べたところ、このコードでいけそうです。
uri = URI('http://www.example.com/search.cgi')
request = Net::HTTP::Post.new(uri.path, {'Content-Type' => 'application/json'})
request.body = {foo: 1, bar: 'a'}.to_json
response = Net::HTTP.start(uri.host, uri.port) do |http|
http.request(request)
end
PUTとPATCHは同じ方法で出来ます。DELETEのリクエストにはbodyがないため、GETと似たようなやり方です。
最後のtimeoutはNet::HTTP.startのblockで、httpで設定を変えるらしいです。でもopen_timeoutとread_timeout2つのtimeout設定に分けてます。open_timeoutはsocket接続する時使われるもので、read_timeoutはデータを送ったあとサーバーのレスポンスを待つtimeoutです。
response = Net::HTTP.start(uri.host, uri.port) do |http|
http.open_timeout = 1
http.read_timeout = 2
http.request(request)
end
これでHTTP APIのクライアントに必要なものが揃いましたので、クライアントを作ってみます。
実際のコードではnamespaceがありますが、ここでは省略しました。
まずRequestです。
class Request
attr_accessor :method # Symbol
attr_accessor :path # String e.g /bar
attr_accessor :headers # Hash<String, String>
attr_accessor :query_params # Hash<String, String/Array<String>>
attr_accessor :form_params # Hash<String, String/Array<String>>
attr_accessor :body # String
def initialize(hash = {})
@method = hash[:method]
@path = hash[:path]
@headers = hash[:headers] || {}
@query_params = hash[:query_params] || {}
@form_params = hash[:form_params] || {}
@body = hash[:body]
end
# @return [String]
def content_type
@headers['Content-Type']
end
# @return [Boolean]
def has_query_param?
@query_params.any?
end
# @return [Boolean]
def has_form_param?
@form_params.any?
end
end
ここで、query_params, form_paramsとbodyで別々のケースを対応する想定です。
- query_params => GET
- form_params => POST form
- body => POST/PUT/PATCH json
つぎはClientです。
class Client
def initialize(client_config)
@client_config = client_config
end
def execute(request)
net_http_request = build_net_http_request(request)
net_http_response = ::Net::HTTP.start(
@client_config.host, @client_config.port,
use_ssl: (@client_config.schema == 'https')) do |http|
http.open_timeout = @client_config.open_timeout
http.read_timeout = @client_config.read_timeout
http.request(net_http_request)
end
Response.new(net_http_response)
end
private
def build_net_http_request(request)
klass = determine_net_http_request_class(request.method)
r = klass.new(build_path_with_query(request), @client_config.default_headers.merge(request.headers))
if r.request_body_permitted?
if request.content_type == 'application/x-www-form-urlencoded' || request.content_type == 'multipart/form-data'
r.set_form(request.form_params, request.content_type)
else
r.body = request.body
end
end
r
end
def build_path_with_query(request)
if request.has_query_param?
query_params = request.query_params.reject {|k, v| v.nil?}
"#{request.path}?#{::URI.encode_www_form(query_params)}"
else
request.path
end
end
def determine_net_http_request_class(method)
case method
when :get
::Net::HTTP::Get
when :post
::Net::HTTP::Post
when :put
::Net::HTTP::Put
when :patch
::Net::HTTP::Patch
when :delete
::Net::HTTP::Delete
else
raise ::ArgumentError, "unsupported http method #{method}"
end
end
end
POSTのform、特にファイルが入ってるケースは直接bodyを入れることができないので、set_formを通してboundaryなどを付け加えてもらう必要があります。
最後はResponseとClientConfigです。
class Response
# @param [Net::HTTPResponse] net_http_response
def initialize(net_http_response)
@net_http_response = net_http_response
end
# @return [Fixnum]
def status_code
Integer(@net_http_response.code)
end
# @return [Hash<String,String>]
def headers
headers = {}
@net_http_response.each_header do |k, vs|
headers[k] = vs
end
headers
end
def content_type
@net_http_response.content_type
end
# @return [String]
def body_as_string
@net_http_response.body
end
end
特に注意すべきなのは、net/httpのresponseで取ったstatus_codeのタイプは数値ではなく、文字列です。アプリケーションのコードは基本数値を想定してるので、予め数値に変換するほうをおすすめします。あとなぜかResponseから直接全部のHEADERを取れなくて、コピーの方法でなんとかなりました。
class ClientConfig
attr_reader :schema # String
attr_accessor :host # String e.g www.example.com
attr_accessor :port # Fixnum e.g 80, 443
attr_accessor :open_timeout # Fixnum
attr_accessor :read_timeout # Fixnum
attr_accessor :logger # Logger
attr_accessor :default_headers # Hash
def initialize
@schema = 'http'
@host = nil
@port = 80
@open_timeout = 0
@read_timeout = 0
@logger = ::Logger.new(STDOUT)
@default_headers = {'User-Agent' => "MyApiClient 0.0.1"}
end
# @param [String] schema e.g http, https
def schema=(schema)
@schema = schema.downcase
end
end
ClientConfigはとくに複雑ではありません。
実際の使い方
# どっかてclientを初期化
client_config = ClientConfig.new
client_config.host = 'www.example.com'
client = Client.new(client_config)
request = Request.new(method: :get, path: '/')
response = client.execute(request)
puts response.body_as_string
もっと使いやすいようにmoduleを用意しました。
module HttpClientSupport
attr_writer :http_client
def execute_http_request(hash)
request = Request.new(hash)
@http_client.execute(request)
end
def http_get(path, query_params = {})
request = Request.new
request.method = :get
request.path = path
request.query_params = query_params
@http_client.execute(request)
end
def http_post(path, form_params)
request = Request.new
request.method = :post
request.path = path
request.form_params = form_params
request.headers = {'Content-Type' => 'application/x-www-form-urlencoded'}
@http_client.execute(request)
end
def http_post_json(path, json)
request = Request.new
request.method = :post
request.path = path
request.body = json
request.headers = {'Content-Type' => 'application/json'}
@http_client.execute(request)
end
def http_put_json(path, json)
request = Request.new
request.method = :put
request.path = path
request.body = json
request.headers = {'Content-Type' => 'application/json'}
@http_client.execute(request)
end
end
これでincludeしたクラスで
response = http_get('/')
だけで使えます。
ご参考になれば幸いです。