こんにちは、チョウです。
最近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('/')
だけで使えます。
ご参考になれば幸いです。