Class: Mailkube::NetHttpAdapter

Inherits:
Object
  • Object
show all
Defined in:
lib/mailkube/net_http_adapter.rb,
sig/mailkube/net_http_adapter.rbs

Overview

The default HTTP adapter, and the only file in this gem that touches Net::HTTP.

The adapter contract

An adapter responds to #call(method:, url:, headers:, body:) and returns an HttpResponse. It raises ConnectionError when the request never produced a response, and raises nothing else: mapping an HTTP status to an exception is Transport's job, so a replacement adapter never has to know the API's error envelope.

Pass your own through Client.new(http:) to route through a proxy, add instrumentation, or drive the client from a test.

Why a fresh connection per request

Net::HTTP.start opens a connection, yields, and closes it. Sharing one Net::HTTP instance across callers is not a crash bug, it is response cross-talk: two threads interleave on one socket and one receives the other's body. Inside a single process that is a confidentiality bug, and it is exactly what spec/concurrency_spec.rb exists to catch.

Connection reuse is therefore left to whoever needs it, with the reason stated: a correct pool has to be safe under threads and under a fiber scheduler, which is more than a scaffold should assume on a caller's behalf.

Constant Summary collapse

METHODS =

The HTTP verbs this adapter can issue, mapped to their Net::HTTP request classes.

A frozen table rather than const_get on a caller-supplied string, which would turn a method name into an arbitrary constant lookup.

Returns:

  • (Hash[String, untyped])
{
  "GET" => Net::HTTP::Get,
  "POST" => Net::HTTP::Post,
  "PATCH" => Net::HTTP::Patch,
  "DELETE" => Net::HTTP::Delete
}.freeze
TRANSPORT_ERRORS =

Errors Net::HTTP raises when no response was produced. Each becomes a ConnectionError.

Returns:

  • (Array[untyped])
[
  IOError,
  SocketError,
  SystemCallError,
  Timeout::Error,
  OpenSSL::SSL::SSLError,
  Net::HTTPBadResponse,
  Net::ProtocolError
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(timeout: Config::DEFAULT_TIMEOUT) ⇒ NetHttpAdapter

Returns a new instance of NetHttpAdapter.

Parameters:

  • timeout (Integer, Float) (defaults to: Config::DEFAULT_TIMEOUT)

    the open and read timeout in seconds.

  • timeout: (Numeric) (defaults to: Config::DEFAULT_TIMEOUT)


56
57
58
59
# File 'lib/mailkube/net_http_adapter.rb', line 56

def initialize(timeout: Config::DEFAULT_TIMEOUT)
  @timeout = timeout
  freeze
end

Instance Method Details

#build_request(method, uri, headers, body) ⇒ Net::HTTPRequest

Returns the request object for a method, URL, headers and body.

Parameters:

  • (String)
  • (URI::Generic)
  • (Hash[String, String])
  • (String, nil)

Returns:

  • (Net::HTTPRequest)

    the request object for a method, URL, headers and body.



107
108
109
110
111
112
113
114
115
# File 'lib/mailkube/net_http_adapter.rb', line 107

def build_request(method, uri, headers, body)
  request_class = METHODS.fetch(method.upcase) do
    raise ConfigurationError, "unsupported HTTP method #{method.inspect}"
  end
  request = request_class.new(uri)
  headers.each { |name, value| request[name] = value }
  request.body = body unless body.nil?
  request
end

#call(method:, url:, headers:, body: nil) ⇒ HttpResponse

Perform one HTTP round trip.

Parameters:

  • method (String)

    the HTTP method; must be a key of METHODS.

  • url (String)

    the absolute URL to request.

  • headers (Hash{String => String})

    the request headers.

  • body (String, nil) (defaults to: nil)

    the already-serialized request body.

  • method: (String)
  • url: (String)
  • headers: (Hash[String, String])
  • body: (String, nil) (defaults to: nil)

Returns:

Raises:



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/mailkube/net_http_adapter.rb', line 69

def call(method:, url:, headers:, body: nil)
  uri = parse_url(url)
  # `Config#build_url` only ever produces a URL with a host, but this adapter is public and
  # can be driven directly, so the guard is real rather than defensive noise.
  host = uri.hostname
  raise ConfigurationError, "URL has no host: #{url.inspect}" if host.nil?

  request = build_request(method, uri, headers, body)

  Net::HTTP.start(host, uri.port, use_ssl: uri.scheme == "https",
                                  open_timeout: @timeout, read_timeout: @timeout) do |http|
    to_response(http.request(request))
  end
rescue *TRANSPORT_ERRORS => e
  raise ConnectionError, "#{e.class}: #{e.message}"
end

#parse_url(url) ⇒ URI::Generic

Parse the URL, keeping URI's own exception out of the caller's rescue clauses.

Everything this adapter can refuse already raises ConfigurationError — an unsupported verb, a URL with no host — and a malformed URL was the one case where a foreign type escaped instead. rescue Mailkube::Error is the documented way to catch anything this gem raises, so a bare URI::InvalidURIError reaching a caller made that promise false. The adapter is public and can be driven directly, so this is reachable without going through Config#build_url, which already maps the same two exceptions the same way.

Parameters:

  • url (String)

    the URL to parse.

  • (String)

Returns:

  • (URI::Generic)

    the parsed URL.

Raises:



100
101
102
103
104
# File 'lib/mailkube/net_http_adapter.rb', line 100

def parse_url(url)
  URI.parse(url)
rescue URI::InvalidURIError, URI::InvalidComponentError => e
  raise ConfigurationError, "invalid URL #{url.inspect}: #{e.message}"
end

#to_response(raw) ⇒ HttpResponse

Returns the adapter-neutral view of a Net::HTTPResponse.

Parameters:

  • (Object)

Returns:

  • (HttpResponse)

    the adapter-neutral view of a Net::HTTPResponse.



118
119
120
121
# File 'lib/mailkube/net_http_adapter.rb', line 118

def to_response(raw)
  headers = raw.each_header.to_h { |name, value| [name.downcase, value] }
  HttpResponse.new(status: raw.code.to_i, headers: headers, body: raw.body.to_s)
end