ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

net/httpでHTTP API Clientを作ってみました

こんにちは、チョウです。

最近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('/')

だけで使えます。

ご参考になれば幸いです。