patient_http 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,14 +116,16 @@ module PatientHttp
116
116
  # @param timeout [Float] default timeout in seconds
117
117
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] default names of
118
118
  # preprocessors registered on the configuration to apply to requests
119
+ # @param processor [String, Symbol, nil] default processor name for requests
119
120
  # @return [void]
120
- def request_template(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil)
121
+ def request_template(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil, processor: nil)
121
122
  @patient_http_request_template = RequestTemplate.new(
122
123
  base_url: base_url,
123
124
  headers: headers,
124
125
  params: params,
125
126
  timeout: timeout,
126
- preprocessors: preprocessors
127
+ preprocessors: preprocessors,
128
+ processor: processor
127
129
  )
128
130
  end
129
131
 
@@ -145,6 +147,8 @@ module PatientHttp
145
147
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
146
148
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
147
149
  # registered on the configuration to apply to the request when it is sent
150
+ # @param processor [String, Symbol, nil] name of the processor that should execute
151
+ # the request
148
152
  # @return [Object] return value from the registered request handler
149
153
  def async_request(
150
154
  method,
@@ -157,10 +161,11 @@ module PatientHttp
157
161
  timeout: nil,
158
162
  raise_error_responses: nil,
159
163
  callback_args: nil,
160
- preprocessors: nil
164
+ preprocessors: nil,
165
+ processor: nil
161
166
  )
162
167
  template = async_request_template
163
- kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout, preprocessors: preprocessors}
168
+ kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout, preprocessors: preprocessors, processor: processor}
164
169
  request = if template
165
170
  template.request(method, url, **kwargs)
166
171
  else
@@ -206,6 +211,8 @@ module PatientHttp
206
211
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
207
212
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
208
213
  # registered on the configuration to apply to the request when it is sent
214
+ # @param processor [String, Symbol, nil] name of the processor that should execute
215
+ # the request
209
216
  # @return [Object] return value from the registered request handler
210
217
  def async_request(
211
218
  method,
@@ -218,7 +225,8 @@ module PatientHttp
218
225
  timeout: nil,
219
226
  raise_error_responses: nil,
220
227
  callback_args: nil,
221
- preprocessors: nil
228
+ preprocessors: nil,
229
+ processor: nil
222
230
  )
223
231
  self.class.async_request(
224
232
  method,
@@ -231,7 +239,8 @@ module PatientHttp
231
239
  timeout: timeout,
232
240
  raise_error_responses: raise_error_responses,
233
241
  callback_args: callback_args,
234
- preprocessors: preprocessors
242
+ preprocessors: preprocessors,
243
+ processor: processor
235
244
  )
236
245
  end
237
246
 
@@ -28,6 +28,13 @@ module PatientHttp
28
28
  headers = @config.secret_manager.resolve_headers(request.headers.to_h)
29
29
  headers["x-request-id"] = request_id
30
30
  headers["user-agent"] ||= @config.user_agent if @config.user_agent
31
+ # Compressed responses are inflated by ResponseReader during response
32
+ # decoding rather than by a client middleware wrapper. Requesting gzip is
33
+ # the default because it is what the reader can decode, but a caller that
34
+ # sets the header keeps its own value: "identity" opts out of compression,
35
+ # and any other encoding is delivered still encoded with its
36
+ # content-encoding header intact.
37
+ headers["accept-encoding"] ||= "gzip"
31
38
  url = @config.secret_manager.resolve_url(request.url, request.secret_params)
32
39
 
33
40
  outgoing = OutgoingRequest.new(
@@ -236,7 +236,8 @@ module PatientHttp
236
236
  body: redirect_body,
237
237
  timeout: request.timeout,
238
238
  max_redirects: request.max_redirects,
239
- preprocessors: redirect_preprocessors
239
+ preprocessors: redirect_preprocessors,
240
+ processor: request.processor
240
241
  )
241
242
 
242
243
  redirect_task_id = "#{id.split("/").first}/#{@redirects.size + 2}"
@@ -34,12 +34,14 @@ module PatientHttp
34
34
  # @param timeout [Float] Default request timeout in seconds
35
35
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Default preprocessors
36
36
  # to apply to all requests
37
- def initialize(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil)
37
+ # @param processor [String, Symbol, nil] Default processor name for all requests
38
+ def initialize(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil, processor: nil)
38
39
  @base_url = base_url
39
40
  @headers = HttpHeaders.new(headers)
40
41
  @params = params
41
42
  @timeout = timeout
42
43
  @preprocessors = preprocessors
44
+ @processor = processor
43
45
  end
44
46
 
45
47
  # Build an async HTTP request. Returns a Request object.
@@ -52,8 +54,10 @@ module PatientHttp
52
54
  # @param params [Hash, nil] query parameters to add to URL
53
55
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] preprocessors to apply
54
56
  # to the request (overrides the template default)
57
+ # @param processor [String, Symbol, nil] processor name for the request (overrides the
58
+ # template default)
55
59
  # @return [Request] request object
56
- def request(method, uri, body: nil, json: nil, headers: nil, params: nil, timeout: nil, preprocessors: nil)
60
+ def request(method, uri, body: nil, json: nil, headers: nil, params: nil, timeout: nil, preprocessors: nil, processor: nil)
57
61
  full_uri = @base_url ? URI.join(@base_url, uri.to_s) : URI(uri)
58
62
 
59
63
  merged_headers = headers&.any? ? @headers.merge(headers) : @headers
@@ -68,7 +72,8 @@ module PatientHttp
68
72
  json: json,
69
73
  params: merged_params,
70
74
  timeout: timeout || @timeout,
71
- preprocessors: preprocessors || @preprocessors
75
+ preprocessors: preprocessors || @preprocessors,
76
+ processor: processor || @processor
72
77
  )
73
78
  end
74
79
 
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Reads and validates HTTP response bodies.
4
+ # Reads and decodes HTTP response bodies.
5
5
  #
6
- # Encapsulates the logic for reading async HTTP responses with size validation
7
- # and building Response objects from the raw response data.
6
+ # Reading happens on the reactor thread and collects the raw (possibly
7
+ # compressed) body chunks with size validation. Decoding joining the
8
+ # chunks, inflating compressed content, and applying the charset — is a
9
+ # separate step so it can run on a completion worker thread instead of
10
+ # blocking the event loop.
8
11
  class ResponseReader
9
12
  # Raised when a body read is aborted because the processor was stopped
10
13
  # past its shutdown deadline. The shutdown sequence re-enqueues the task,
@@ -13,41 +16,204 @@ module PatientHttp
13
16
  # @api private
14
17
  class ReadAbortedError < StandardError; end
15
18
 
19
+ # Content encodings that are inflated during decoding, mapped to the window
20
+ # bits of each wire format the encoding may arrive in. Formats are tried in
21
+ # order until one inflates the body.
22
+ #
23
+ # A "deflate" body should carry a zlib header (RFC 9110 specifies the zlib
24
+ # format), but some servers send a bare deflate stream instead, so the raw
25
+ # format is kept as a fallback.
26
+ INFLATE_WINDOW_BITS = {
27
+ "gzip" => [Zlib::MAX_WBITS | 16].freeze,
28
+ "deflate" => [Zlib::MAX_WBITS, -Zlib::MAX_WBITS].freeze
29
+ }.freeze
30
+
31
+ # Content encoding that means the body was not encoded at all. It needs no
32
+ # work to decode, but it still has to be recognized so it does not stop the
33
+ # decode of the encodings applied before it.
34
+ IDENTITY_ENCODING = "identity"
35
+
36
+ class << self
37
+ # Split the encodings named in the content-encoding header into the ones
38
+ # that stay applied to the body and the ones that can be decoded.
39
+ #
40
+ # A body can carry more than one encoding. They are listed in the order
41
+ # they were applied, so decoding runs from the last name backwards and
42
+ # stops at the first name it does not recognize. Everything before that
43
+ # point stays applied to the body.
44
+ #
45
+ # @param headers_hash [Hash] the response headers
46
+ # @return [Array(Array<String>, Array<String>)] the encodings that remain
47
+ # applied and the encodings that can be decoded, both in applied order
48
+ def split_encodings(headers_hash)
49
+ encodings = content_encodings(headers_hash)
50
+ boundary = encodings.rindex { |name| !decodable?(name) }
51
+ return [[], encodings] if boundary.nil?
52
+
53
+ [encodings[0..boundary], encodings[(boundary + 1)..]]
54
+ end
55
+
56
+ # Parse the content-encoding header into encoding names.
57
+ #
58
+ # @param headers_hash [Hash] the response headers
59
+ # @return [Array<String>] the lowercased encoding names in applied order
60
+ def content_encodings(headers_hash)
61
+ headers_hash["content-encoding"].to_s.split(",").filter_map do |name|
62
+ name = name.strip.downcase
63
+ name unless name.empty?
64
+ end
65
+ end
66
+
67
+ # @param name [String] a lowercased content encoding name
68
+ # @return [Boolean] true if the reader can remove this encoding
69
+ def decodable?(name)
70
+ name == IDENTITY_ENCODING || INFLATE_WINDOW_BITS.key?(name)
71
+ end
72
+
73
+ # Restate the content-encoding header for a decoded body. The header is
74
+ # removed when nothing is left applied, and narrowed to the encodings the
75
+ # reader could not remove otherwise, so the header always describes the
76
+ # body delivered with it.
77
+ #
78
+ # @param headers_hash [Hash] the response headers
79
+ # @return [Hash] the headers with content-encoding updated or removed
80
+ def rewrite_content_encoding(headers_hash)
81
+ return headers_hash unless headers_hash.key?("content-encoding")
82
+
83
+ remaining, _decodable = split_encodings(headers_hash)
84
+
85
+ if remaining.empty?
86
+ headers_hash.except("content-encoding")
87
+ else
88
+ headers_hash.merge("content-encoding" => remaining.join(", "))
89
+ end
90
+ end
91
+ end
92
+
16
93
  # Initialize the reader.
17
94
  #
18
- # @param processor [Processor] the processor object
19
- def initialize(processor)
95
+ # Reading needs a processor so it can abort once the processor is past its
96
+ # shutdown deadline. Decoding needs only the configuration, so a caller
97
+ # that does its own reading can supply the configuration on its own.
98
+ #
99
+ # @param processor [Processor, nil] the processor object
100
+ # @param config [Configuration, nil] the configuration; defaults to the
101
+ # processor's configuration
102
+ def initialize(processor, config: nil)
20
103
  @processor = processor
104
+ @config = config || processor.config
21
105
  end
22
106
 
23
- # Read the response body with size validation.
107
+ # Read the raw response body chunks with size validation.
24
108
  #
25
109
  # Reads the async HTTP response body asynchronously to completion, which allows
26
110
  # the connection to be reused. The async-http client handles connection pooling
27
111
  # and keep-alive internally. Using iteration instead of read() ensures non-blocking
28
- # I/O that yields to the reactor.
112
+ # I/O that yields to the reactor. The chunks are the wire bytes: when the
113
+ # response is compressed, the size check here applies to the compressed
114
+ # bytes and {#decode_body} applies the same limit to the inflated bytes.
29
115
  #
30
116
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
31
117
  # @param headers_hash [Hash] the response headers
32
- # @return [String, nil] the response body or nil if no body present
33
- # @raise [ResponseTooLargeError] if body exceeds max_response_size
118
+ # @return [Array<String>, nil] the raw body chunks or nil if no body present
119
+ # @raise [ResponseTooLargeError] if the body exceeds max_response_size
34
120
  # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
35
- def read_body(async_response, headers_hash)
121
+ def read_raw_body(async_response, headers_hash)
36
122
  return nil unless async_response.body
37
123
 
38
124
  validate_content_length(headers_hash)
39
- body = read_body_chunks(async_response)
125
+ read_body_chunks(async_response)
126
+ end
127
+
128
+ # Decode raw body chunks into the final body string.
129
+ #
130
+ # Joins the chunks, inflates gzip/deflate content (enforcing
131
+ # max_response_size on the inflated bytes), and applies the charset from
132
+ # the Content-Type header. This is CPU-bound work intended to run on a
133
+ # completion worker thread.
134
+ #
135
+ # An encoding the reader does not support leaves the body encoded, and a
136
+ # body that is still encoded keeps its binary encoding because the charset
137
+ # does not describe it. Use {.split_encodings} to find what stays applied
138
+ # so the content-encoding header delivered with the response describes the
139
+ # body it carries.
140
+ #
141
+ # @param chunks [Array<String>, nil] the raw body chunks
142
+ # @param headers_hash [Hash] the response headers
143
+ # @return [String, nil] the decoded body or nil if there was no body
144
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
145
+ def decode_body(chunks, headers_hash)
146
+ return nil if chunks.nil?
147
+
148
+ remaining, decodable = self.class.split_encodings(headers_hash)
149
+ warn_undecodable(remaining) unless remaining.empty?
150
+
151
+ body = inflate_encodings(chunks, decodable).join
152
+ body.force_encoding(Encoding::ASCII_8BIT)
153
+ # A body that is still encoded is not text yet, so the charset does not
154
+ # describe its bytes. Leave it binary for the caller to decode.
155
+ return body unless remaining.empty?
156
+
40
157
  apply_charset_encoding(body, headers_hash)
41
158
  end
42
159
 
43
160
  private
44
161
 
162
+ # Remove the given encodings from the body, starting with the one applied
163
+ # last. Identity needs no work; every other name here inflates.
164
+ #
165
+ # @param chunks [Array<String>] the encoded body chunks
166
+ # @param encodings [Array<String>] decodable encoding names in applied order
167
+ # @return [Array<String>] the decoded chunks
168
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
169
+ def inflate_encodings(chunks, encodings)
170
+ encodings.reverse_each do |name|
171
+ chunks = [inflate_encoding(chunks, name)] if INFLATE_WINDOW_BITS.key?(name)
172
+ end
173
+
174
+ chunks
175
+ end
176
+
177
+ # Inflate one encoding, trying each wire format the encoding can use. The
178
+ # chunks are all in memory, so a format that turns out to be wrong can be
179
+ # abandoned and the next one started from the beginning of the body.
180
+ #
181
+ # @param chunks [Array<String>] the encoded body chunks
182
+ # @param name [String] a lowercased content encoding name
183
+ # @return [String] the inflated body
184
+ # @raise [Zlib::Error] if no format could inflate the body
185
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
186
+ def inflate_encoding(chunks, name)
187
+ formats = INFLATE_WINDOW_BITS.fetch(name)
188
+ last_index = formats.size - 1
189
+
190
+ formats.each_with_index do |window_bits, index|
191
+ return inflate_chunks(chunks, window_bits)
192
+ rescue Zlib::DataError, Zlib::BufError
193
+ raise if index == last_index
194
+ end
195
+ end
196
+
197
+ # Report an encoding that could not be removed. The body is still delivered
198
+ # with its content-encoding header, so the caller can decode it, but the
199
+ # server ignored the accept-encoding header and that is worth recording.
200
+ #
201
+ # @param remaining [Array<String>] the encodings left on the body
202
+ # @return [void]
203
+ def warn_undecodable(remaining)
204
+ logger&.warn(
205
+ "[PatientHttp] Cannot decode response body with content-encoding " \
206
+ "'#{remaining.join(", ")}'; returning the encoded body"
207
+ )
208
+ nil
209
+ end
210
+
45
211
  def max_response_size
46
- @processor.config.max_response_size
212
+ @config.max_response_size
47
213
  end
48
214
 
49
215
  def logger
50
- @processor.config.logger
216
+ @config.logger
51
217
  end
52
218
 
53
219
  # Validate content-length header doesn't exceed max size.
@@ -66,7 +232,7 @@ module PatientHttp
66
232
  # Read body chunks while checking size.
67
233
  #
68
234
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
69
- # @return [String] the response body in ASCII-8BIT encoding
235
+ # @return [Array<String>] the raw body chunks
70
236
  # @raise [ResponseTooLargeError] if body size exceeds max_response_size during read
71
237
  # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
72
238
  def read_body_chunks(async_response)
@@ -80,7 +246,7 @@ module PatientHttp
80
246
  # Reads are allowed to finish while the processor is merely stopping
81
247
  # (the graceful shutdown window) so in-flight responses can still be
82
248
  # delivered.
83
- if @processor.stopped?
249
+ if @processor&.stopped?
84
250
  raise ReadAbortedError.new("Processor stopped while reading response body")
85
251
  end
86
252
 
@@ -97,8 +263,7 @@ module PatientHttp
97
263
 
98
264
  finished = true
99
265
 
100
- # Join chunks and force to binary encoding to preserve raw bytes
101
- chunks.join.force_encoding(Encoding::ASCII_8BIT)
266
+ chunks
102
267
  ensure
103
268
  # Always close the body if we were interrupted or if an error occurred
104
269
  # This ensures the connection is properly released back to the pool
@@ -106,6 +271,49 @@ module PatientHttp
106
271
  end
107
272
  end
108
273
 
274
+ # Inflate compressed body chunks with streaming size enforcement, so a
275
+ # small compressed body cannot expand past max_response_size.
276
+ #
277
+ # @param chunks [Array<String>] the raw compressed chunks
278
+ # @param window_bits [Integer] Zlib window bits for the content encoding
279
+ # @return [String] the inflated body
280
+ # @raise [ResponseTooLargeError] if the inflated size exceeds max_response_size
281
+ def inflate_chunks(chunks, window_bits)
282
+ # A response can declare a content encoding and still carry no body.
283
+ # There is nothing to inflate, and finishing an empty stream would
284
+ # raise a buffer error.
285
+ return +"" if chunks.all?(&:empty?)
286
+
287
+ inflater = Zlib::Inflate.new(window_bits)
288
+ body = +""
289
+
290
+ begin
291
+ # The block form yields the inflated output in buffer-sized pieces, so
292
+ # the size is checked before the whole expansion is materialized. A
293
+ # single small compressed chunk can otherwise inflate to gigabytes
294
+ # before any check runs.
295
+ appender = ->(output) do
296
+ body << output
297
+ validate_inflated_size(body)
298
+ end
299
+
300
+ chunks.each { |chunk| inflater.inflate(chunk, &appender) }
301
+ inflater.finish(&appender) unless inflater.finished?
302
+ ensure
303
+ inflater.close
304
+ end
305
+
306
+ body
307
+ end
308
+
309
+ def validate_inflated_size(body)
310
+ if body.bytesize > max_response_size
311
+ raise ResponseTooLargeError.new(
312
+ "Response body size exceeded maximum allowed size (#{max_response_size} bytes)"
313
+ )
314
+ end
315
+ end
316
+
109
317
  # Extract charset from Content-Type header.
110
318
  #
111
319
  # @param headers_hash [Hash] the response headers
@@ -20,6 +20,7 @@ module PatientHttp
20
20
  @on_error = on_error
21
21
  @proxy_client = nil
22
22
  @request_preparer = RequestPreparer.new(config)
23
+ @response_reader = ResponseReader.new(nil, config: config)
23
24
  end
24
25
 
25
26
  # Execute the request synchronously.
@@ -62,11 +63,12 @@ module PatientHttp
62
63
  # flattened to a single joined string value.
63
64
  headers_hash = async_response.headers.to_h.transform_values(&:to_s)
64
65
 
65
- body_content = read_response_body(async_response, headers_hash)
66
+ chunks = read_response_body(async_response, headers_hash)
67
+ body_content = @response_reader.decode_body(chunks, headers_hash)
66
68
 
67
69
  {
68
70
  status: async_response.status,
69
- headers: headers_hash,
71
+ headers: ResponseReader.rewrite_content_encoding(headers_hash),
70
72
  body: body_content
71
73
  }
72
74
  end
@@ -133,19 +135,22 @@ module PatientHttp
133
135
 
134
136
  # Create HTTP client with config settings (retries, proxy, connection timeout).
135
137
  #
138
+ # The client is not wrapped in a Protocol::HTTP::AcceptEncoding middleware.
139
+ # That wrapper overwrites the request's accept-encoding header, which would
140
+ # ignore a caller opting out of compression, so response bodies are decoded
141
+ # by ResponseReader here exactly as they are on the async path.
142
+ #
136
143
  # @param url [String] the resolved request URL
137
- # @return [Protocol::HTTP::AcceptEncoding] wrapped HTTP client
144
+ # @return [Async::HTTP::Client] the HTTP client
138
145
  def create_http_client(url)
139
146
  endpoint = Async::HTTP::Endpoint.parse(url)
140
147
  endpoint = configure_endpoint(endpoint) if @config.connection_timeout
141
148
 
142
- client = if @config.proxy_url
149
+ if @config.proxy_url
143
150
  create_proxied_client(endpoint)
144
151
  else
145
152
  Async::HTTP::Client.new(endpoint, retries: @config.retries)
146
153
  end
147
-
148
- Protocol::HTTP::AcceptEncoding.new(client)
149
154
  end
150
155
 
151
156
  # Create a proxied HTTP client.
@@ -174,11 +179,13 @@ module PatientHttp
174
179
  )
175
180
  end
176
181
 
177
- # Read the response body with size validation.
182
+ # Read the raw response body chunks with size validation. The chunks are
183
+ # the wire bytes; ResponseReader#decode_body inflates and applies the
184
+ # charset, enforcing the same limit on the inflated bytes.
178
185
  #
179
186
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
180
187
  # @param headers_hash [Hash] the response headers
181
- # @return [String, nil] the response body
188
+ # @return [Array<String>, nil] the raw body chunks or nil if no body present
182
189
  def read_response_body(async_response, headers_hash)
183
190
  return nil unless async_response.body
184
191
 
@@ -210,31 +217,7 @@ module PatientHttp
210
217
  async_response.body.close unless finished
211
218
  end
212
219
 
213
- body = chunks.join.force_encoding(Encoding::ASCII_8BIT)
214
-
215
- charset = extract_charset(headers_hash)
216
- if charset
217
- begin
218
- encoding = Encoding.find(charset)
219
- body.force_encoding(encoding)
220
- rescue ArgumentError
221
- # Invalid charset, keep binary
222
- end
223
- end
224
-
225
- body
226
- end
227
-
228
- # Extract charset from Content-Type header.
229
- def extract_charset(headers_hash)
230
- content_type = headers_hash["content-type"]
231
- return nil unless content_type
232
-
233
- match = content_type.match(/;\s*charset\s*=\s*([^;\s]+)/i)
234
- return nil unless match
235
-
236
- charset = match[1].strip
237
- charset.gsub(/\A["']|["']\z/, "")
220
+ chunks
238
221
  end
239
222
 
240
223
  # Invoke callback synchronously.
data/lib/patient_http.rb CHANGED
@@ -30,6 +30,12 @@ module PatientHttp
30
30
 
31
31
  class ResponseTooLargeError < StandardError; end
32
32
 
33
+ # Raised when a request names a processor that is not configured. Handlers
34
+ # that support named processors raise this at enqueue time; the executing
35
+ # side raises it for a job that names an unconfigured processor so the job
36
+ # lands in the job system's retry mechanism instead of being dropped.
37
+ class UnknownProcessorError < StandardError; end
38
+
33
39
  # HTTP redirect status codes that should be followed
34
40
  FOLLOWABLE_REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
35
41
 
@@ -45,6 +51,7 @@ module PatientHttp
45
51
  autoload :Client, File.join(__dir__, "patient_http/client")
46
52
  autoload :ClientError, File.join(__dir__, "patient_http/http_error")
47
53
  autoload :ClientPool, File.join(__dir__, "patient_http/client_pool")
54
+ autoload :CompletionExecutor, File.join(__dir__, "patient_http/completion_executor")
48
55
  autoload :Configuration, File.join(__dir__, "patient_http/configuration")
49
56
  autoload :Encryptor, File.join(__dir__, "patient_http/encryptor")
50
57
  autoload :Error, File.join(__dir__, "patient_http/error")
@@ -323,6 +330,8 @@ module PatientHttp
323
330
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
324
331
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
325
332
  # registered on the configuration to apply to the request when it is sent
333
+ # @param processor [String, Symbol, nil] name of the processor that should execute
334
+ # the request; handlers that support named processors route on this value
326
335
  # @return [Object] return value from the registered request handler
327
336
  def request(
328
337
  method,
@@ -335,7 +344,8 @@ module PatientHttp
335
344
  timeout: nil,
336
345
  raise_error_responses: nil,
337
346
  callback_args: nil,
338
- preprocessors: nil
347
+ preprocessors: nil,
348
+ processor: nil
339
349
  )
340
350
  request = Request.new(
341
351
  method,
@@ -345,7 +355,8 @@ module PatientHttp
345
355
  headers: headers,
346
356
  params: params,
347
357
  timeout: timeout,
348
- preprocessors: preprocessors
358
+ preprocessors: preprocessors,
359
+ processor: processor
349
360
  )
350
361
  execute(
351
362
  request: request,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -89,6 +89,7 @@ files:
89
89
  - lib/patient_http/class_helper.rb
90
90
  - lib/patient_http/client.rb
91
91
  - lib/patient_http/client_pool.rb
92
+ - lib/patient_http/completion_executor.rb
92
93
  - lib/patient_http/configuration.rb
93
94
  - lib/patient_http/encryptor.rb
94
95
  - lib/patient_http/error.rb