Class: Mailkube::Transport
- Inherits:
-
Object
- Object
- Mailkube::Transport
- 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
-
#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.
-
#decode_object(raw) ⇒ Hash{String => Object}?
Decode a body strictly: nil when it is empty, undecodable, or not a JSON object.
-
#error_for(response) ⇒ APIError
Build the exception for a non-2xx response.
-
#initialize(config, adapter) ⇒ Transport
constructor
A new instance of Transport.
-
#perform(spec) ⇒ HttpResponse
Perform the round trip, raising the mapped error for any non-2xx status.
-
#request_json(spec) ⇒ Hash{String => Object}
Perform a request and return its decoded JSON object body.
-
#send_email(spec) ⇒ Email
Perform a send request and build the accepted-send result.
Constructor Details
#initialize(config, adapter) ⇒ Transport
Returns a new instance of Transport.
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.
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.
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.
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.
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.
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.
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 |