Class: Mailkube::Transport

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

Overview

Performs one HTTP round trip and turns the result into a model or a mapped error.

This is the layer that knows the API's error envelope and nothing about Net::HTTP: the actual socket work lives behind the injected adapter (see NetHttpAdapter), which is what lets the whole suite run without network access.

A resource depends on the narrowest thing it needs, which in Ruby means an object responding to the one verb it calls. Resources::Emails needs only #send_email. A new capability adds a method here; it never widens an existing one.

Instance Method Summary collapse

Constructor Details

#initialize(config, adapter) ⇒ Transport

Returns a new instance of Transport.

Parameters:

  • config (Config)

    the resolved configuration.

  • adapter (#call)

    the HTTP adapter performing the round trip.

  • (Config)
  • (_HttpAdapter)


72
73
74
75
76
# File 'lib/mailkube/transport.rb', line 72

def initialize(config, adapter)
  @config = config
  @adapter = adapter
  freeze
end

Instance Method Details

#decode(raw) ⇒ Hash

Best-effort JSON decode: an empty or undecodable body becomes an empty hash, so a malformed error response still maps by status.

Parameters:

  • raw (String)

    the raw response body.

  • (String)

Returns:

  • (Hash)

    the decoded object, or an empty hash.



188
# File 'lib/mailkube/transport.rb', line 188

def decode(raw) = decode_object(raw) || {}

#decode_object(raw) ⇒ Hash{String => Object}?

Decode a body strictly: nil when it is empty, undecodable, or not a JSON object.

Split out of #decode rather than inlined, because the two callers need opposite things from the same parse: #error_for must stay lenient, so a malformed error body still maps by status, and #request_json must not, so a 2xx that is not an object is reported rather than silently becoming an empty model. One parse, two contracts.

Parameters:

  • raw (String)

    the raw response body.

  • (String)

Returns:

  • (Hash{String => Object}, nil)

    the decoded object, or nil.



174
175
176
177
178
179
180
181
# File 'lib/mailkube/transport.rb', line 174

def decode_object(raw)
  return nil if raw.empty?

  decoded = JSON.parse(raw)
  decoded.is_a?(Hash) ? decoded : nil
rescue JSON::ParserError
  nil
end

#error_for(response) ⇒ APIError

Build the exception for a non-2xx response.

Parameters:

Returns:

  • (APIError)

    the exception to raise.



153
154
155
156
157
158
159
160
161
162
163
# File 'lib/mailkube/transport.rb', line 153

def error_for(response)
  payload = decode(response.body)
  Mailkube.error_class_for(response.status).new(
    payload["message"],
    error_name: payload["name"].is_a?(String) ? payload["name"] : "",
    status_code: response.status,
    body: payload,
    retry_after: response.header("Retry-After")&.to_i,
    request_id: response.header("X-Request-Id")
  )
end

#perform(spec) ⇒ HttpResponse

Perform the round trip, raising the mapped error for any non-2xx status.

This is the single place a status becomes an exception, so every verb, present and future, reports failures identically.

Parameters:

Returns:



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/mailkube/transport.rb', line 133

def perform(spec)
  url = @config.build_url(spec.path, spec.params)
  headers = @config.default_headers.merge(spec.headers)
  Logging.request(spec.method, url, headers)
  response = @adapter.call(
    method: spec.method,
    url: url,
    headers: headers,
    body: spec.body.nil? ? nil : JSON.generate(spec.body)
  )
  Logging.response(response.status, url, response.header("X-Request-Id"))
  return response if (200..299).cover?(response.status)

  raise error_for(response)
end

#request_json(spec) ⇒ Hash{String => Object}

Perform a request and return its decoded JSON object body.

The second transport verb, and deliberately not a widening of the first: #send_email builds one specific model out of a body plus a response header, which is a send concern. This one hands back the decoded object and lets the resource name its model, which is what keeps Resources::Emails depending on #send_email alone.

It takes no model argument on purpose. Ruby has no generics and RBS cannot tie a class argument to a return type, so a request(spec, model) form would make Steep prove generic structural conformance for something the resource already knows statically. Naming the model is a resource decision; decoding is this layer's.

Parameters:

Returns:

  • (Hash{String => Object})

    the decoded 2xx body.

Raises:

  • (APIError)

    on any non-2xx response, or a 2xx body that is not a JSON object.

  • (ConnectionError)

    on a transport failure or timeout.



116
117
118
119
120
121
122
# File 'lib/mailkube/transport.rb', line 116

def request_json(spec)
  response = perform(spec)
  payload = decode_object(response.body)
  raise APIError.new("expected a JSON object body", status_code: response.status) if payload.nil?

  payload
end

#send_email(spec) ⇒ Email

Perform a send request and build the accepted-send result.

Parameters:

Returns:

  • (Email)

    the accepted-send result.

Raises:



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/mailkube/transport.rb', line 84

def send_email(spec)
  response = perform(spec)
  payload = decode(response.body)
  id = payload["id"]
  raise APIError.new("expected a JSON body with an 'id'", status_code: response.status) unless id.is_a?(String)

  Email.new(
    id: id,
    message_id: payload["message_id"],
    idempotent_replayed: response.header("Idempotent-Replayed")&.downcase == "true",
    status: payload["status"],
    scheduled_at: payload["scheduled_at"],
    batch_id: payload["batch_id"]
  )
end