patient_http 1.6.1 → 1.8.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +18 -8
  3. data/CHANGELOG.md +43 -0
  4. data/README.md +564 -508
  5. data/VERSION +1 -1
  6. data/lib/patient_http/callback_args.rb +53 -48
  7. data/lib/patient_http/callback_validator.rb +11 -7
  8. data/lib/patient_http/class_helper.rb +6 -7
  9. data/lib/patient_http/client.rb +53 -26
  10. data/lib/patient_http/client_pool.rb +134 -39
  11. data/lib/patient_http/completion_executor.rb +20 -20
  12. data/lib/patient_http/configuration.rb +405 -119
  13. data/lib/patient_http/connection_endpoint.rb +150 -0
  14. data/lib/patient_http/encryptor.rb +28 -18
  15. data/lib/patient_http/error.rb +24 -18
  16. data/lib/patient_http/external_storage.rb +42 -38
  17. data/lib/patient_http/http_error.rb +30 -26
  18. data/lib/patient_http/http_headers.rb +35 -29
  19. data/lib/patient_http/immediate_retries.rb +98 -0
  20. data/lib/patient_http/inline_task_handler.rb +15 -10
  21. data/lib/patient_http/lifecycle_manager.rb +39 -40
  22. data/lib/patient_http/outgoing_request.rb +25 -23
  23. data/lib/patient_http/payload.rb +28 -26
  24. data/lib/patient_http/payload_store/active_record_store.rb +31 -34
  25. data/lib/patient_http/payload_store/base.rb +42 -46
  26. data/lib/patient_http/payload_store/file_store.rb +22 -26
  27. data/lib/patient_http/payload_store/redis_store.rb +28 -34
  28. data/lib/patient_http/payload_store/s3_store.rb +25 -28
  29. data/lib/patient_http/payload_store.rb +2 -0
  30. data/lib/patient_http/processor.rb +111 -79
  31. data/lib/patient_http/processor_observer.rb +65 -59
  32. data/lib/patient_http/rails/engine.rb +13 -8
  33. data/lib/patient_http/redirect_error.rb +50 -41
  34. data/lib/patient_http/redirect_helper.rb +38 -38
  35. data/lib/patient_http/request.rb +117 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +178 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +115 -84
  40. data/lib/patient_http/request_template.rb +119 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +166 -118
  43. data/lib/patient_http/secret_manager.rb +34 -30
  44. data/lib/patient_http/secret_reference.rb +33 -26
  45. data/lib/patient_http/synchronous_executor.rb +84 -95
  46. data/lib/patient_http/task_handler.rb +23 -19
  47. data/lib/patient_http/time_helper.rb +8 -8
  48. data/lib/patient_http.rb +327 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
@@ -3,11 +3,12 @@
3
3
  module PatientHttp
4
4
  # Reads and decodes HTTP response bodies.
5
5
  #
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.
6
+ # The reactor thread reads the raw body chunks, which can be compressed, and
7
+ # checks their size. Decoding is a separate step that runs on a completion
8
+ # worker thread, so it doesn't block the reactor. Decoding joins the chunks,
9
+ # decompresses them, and applies the charset.
10
+ #
11
+ # @api private
11
12
  class ResponseReader
12
13
  # Raised when a body read is aborted because the processor was stopped
13
14
  # past its shutdown deadline. The shutdown sequence re-enqueues the task,
@@ -34,7 +35,7 @@ module PatientHttp
34
35
  IDENTITY_ENCODING = "identity"
35
36
 
36
37
  class << self
37
- # Split the encodings named in the content-encoding header into the ones
38
+ # Splits the encodings named in the content-encoding header into the ones
38
39
  # that stay applied to the body and the ones that can be decoded.
39
40
  #
40
41
  # A body can carry more than one encoding. They are listed in the order
@@ -42,9 +43,9 @@ module PatientHttp
42
43
  # stops at the first name it does not recognize. Everything before that
43
44
  # point stays applied to the body.
44
45
  #
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
46
+ # @param headers_hash [Hash] The response headers.
47
+ # @return [Array(Array<String>, Array<String>)] The encodings that remain
48
+ # applied and the encodings that can be decoded, both in applied order.
48
49
  def split_encodings(headers_hash)
49
50
  encodings = content_encodings(headers_hash)
50
51
  boundary = encodings.rindex { |name| !decodable?(name) }
@@ -53,10 +54,10 @@ module PatientHttp
53
54
  [encodings[0..boundary], encodings[(boundary + 1)..]]
54
55
  end
55
56
 
56
- # Parse the content-encoding header into encoding names.
57
+ # Parses the content-encoding header into encoding names.
57
58
  #
58
- # @param headers_hash [Hash] the response headers
59
- # @return [Array<String>] the lowercased encoding names in applied order
59
+ # @param headers_hash [Hash] The response headers.
60
+ # @return [Array<String>] The lowercased encoding names in applied order.
60
61
  def content_encodings(headers_hash)
61
62
  headers_hash["content-encoding"].to_s.split(",").filter_map do |name|
62
63
  name = name.strip.downcase
@@ -64,19 +65,19 @@ module PatientHttp
64
65
  end
65
66
  end
66
67
 
67
- # @param name [String] a lowercased content encoding name
68
- # @return [Boolean] true if the reader can remove this encoding
68
+ # @param name [String] A lowercased content encoding name.
69
+ # @return [Boolean] `true` if the reader can remove this encoding.
69
70
  def decodable?(name)
70
71
  name == IDENTITY_ENCODING || INFLATE_WINDOW_BITS.key?(name)
71
72
  end
72
73
 
73
- # Restate the content-encoding header for a decoded body. The header is
74
+ # Restates the content-encoding header for a decoded body. The header is
74
75
  # removed when nothing is left applied, and narrowed to the encodings the
75
76
  # reader could not remove otherwise, so the header always describes the
76
77
  # body delivered with it.
77
78
  #
78
- # @param headers_hash [Hash] the response headers
79
- # @return [Hash] the headers with content-encoding updated or removed
79
+ # @param headers_hash [Hash] The response headers.
80
+ # @return [Hash] The headers with content-encoding updated or removed.
80
81
  def rewrite_content_encoding(headers_hash)
81
82
  return headers_hash unless headers_hash.key?("content-encoding")
82
83
 
@@ -88,23 +89,45 @@ module PatientHttp
88
89
  headers_hash.merge("content-encoding" => remaining.join(", "))
89
90
  end
90
91
  end
92
+
93
+ # Runs a block that waits for data from the server, and stops it if no
94
+ # data arrives in time. Each call starts a new timer, so call it once for
95
+ # each wait: once for the response headers and once for each body chunk.
96
+ #
97
+ # The block must run in an Async task.
98
+ #
99
+ # @param read_timeout [Numeric, nil] The maximum number of seconds to wait.
100
+ # If `nil`, the block runs without a limit.
101
+ # @yield Waits for data from the server.
102
+ # @return [Object] The value of the block.
103
+ # @raise [ReadTimeoutError] If the block doesn't finish in time.
104
+ def with_read_timeout(read_timeout, &block)
105
+ return yield if read_timeout.nil?
106
+
107
+ Async::Task.current.with_timeout(
108
+ read_timeout,
109
+ ReadTimeoutError,
110
+ "No data received from the server for #{read_timeout} seconds",
111
+ &block
112
+ )
113
+ end
91
114
  end
92
115
 
93
- # Initialize the reader.
116
+ # Creates the reader.
94
117
  #
95
118
  # Reading needs a processor so it can abort once the processor is past its
96
119
  # shutdown deadline. Decoding needs only the configuration, so a caller
97
120
  # that does its own reading can supply the configuration on its own.
98
121
  #
99
- # @param processor [Processor, nil] the processor object
100
- # @param config [Configuration, nil] the configuration; defaults to the
101
- # processor's configuration
122
+ # @param processor [Processor, nil] The processor object.
123
+ # @param config [Configuration, nil] The configuration; defaults to the
124
+ # processor's configuration.
102
125
  def initialize(processor, config: nil)
103
126
  @processor = processor
104
127
  @config = config || processor.config
105
128
  end
106
129
 
107
- # Read the raw response body chunks with size validation.
130
+ # Reads the raw response body chunks with size validation.
108
131
  #
109
132
  # Reads the async HTTP response body asynchronously to completion, which allows
110
133
  # the connection to be reused. The async-http client handles connection pooling
@@ -114,24 +137,47 @@ module PatientHttp
114
137
  # bytes and {#decode_body} applies the same limit to the inflated bytes.
115
138
  #
116
139
  # The Content-Length header is checked against the size limit before the
117
- # read starts. A response to a HEAD request has an empty body but reports
118
- # the Content-Length of the resource, so the header check is skipped when
119
- # the body reports itself as empty.
140
+ # read starts. The HTTP/1 parser moves that header into the body, so the
141
+ # length of the body is used when the header is not present. A response to
142
+ # a HEAD request has an empty body but reports the Content-Length of the
143
+ # resource, so the check is skipped when the body reports itself as empty.
144
+ #
145
+ # A read timeout limits the wait for each chunk. The timer starts again
146
+ # each time a chunk arrives, so a body that arrives slowly but steadily is
147
+ # read to the end.
120
148
  #
121
- # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
122
- # @param headers_hash [Hash] the response headers
123
- # @return [Array<String>, nil] the raw body chunks or nil if no body present
124
- # @raise [ResponseTooLargeError] if the body exceeds max_response_size
125
- # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
126
- def read_raw_body(async_response, headers_hash)
149
+ # @param async_response [Async::HTTP::Protocol::Response] The async HTTP response.
150
+ # @param headers_hash [Hash] The response headers.
151
+ # @param max_response_size [Integer, nil] The size limit in bytes. If `nil`,
152
+ # the configuration value applies.
153
+ # @param read_timeout [Numeric, nil] The maximum number of seconds to wait for
154
+ # each chunk. If `nil`, there is no limit.
155
+ # @return [Array<String>, nil] The raw body chunks or nil if no body present.
156
+ # @raise [ResponseTooLargeError] If the body exceeds max_response_size.
157
+ # @raise [ReadTimeoutError] If no chunk arrives within the read timeout.
158
+ # @raise [ReadAbortedError] If the processor stopped past its shutdown deadline mid-read.
159
+ def read_raw_body(async_response, headers_hash, max_response_size: nil, read_timeout: nil)
127
160
  body = async_response.body
128
161
  return nil unless body
129
162
 
130
- validate_content_length(headers_hash) unless body.empty?
131
- read_body_chunks(async_response)
163
+ limit = max_response_size || @config.max_response_size
164
+ error = nil
165
+
166
+ begin
167
+ validate_content_length(headers_hash, body, limit) unless body.empty?
168
+ read_body_chunks(body, limit, read_timeout)
169
+ rescue => e
170
+ error = e
171
+ raise
172
+ ensure
173
+ # Always close the body. A body that is closed with an error discards
174
+ # its connection, so a connection that is not fully read is not
175
+ # returned to the pool.
176
+ body.close(error)
177
+ end
132
178
  end
133
179
 
134
- # Decode raw body chunks into the final body string.
180
+ # Decodes raw body chunks into the final body string.
135
181
  #
136
182
  # Joins the chunks, inflates gzip/deflate content (enforcing
137
183
  # max_response_size on the inflated bytes), and applies the charset from
@@ -144,17 +190,20 @@ module PatientHttp
144
190
  # so the content-encoding header delivered with the response describes the
145
191
  # body it carries.
146
192
  #
147
- # @param chunks [Array<String>, nil] the raw body chunks
148
- # @param headers_hash [Hash] the response headers
149
- # @return [String, nil] the decoded body or nil if there was no body
150
- # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
151
- def decode_body(chunks, headers_hash)
193
+ # @param chunks [Array<String>, nil] The raw body chunks.
194
+ # @param headers_hash [Hash] The response headers.
195
+ # @param max_response_size [Integer, nil] The size limit in bytes for the
196
+ # inflated body. If `nil`, the configuration value applies.
197
+ # @return [String, nil] The decoded body or nil if there was no body.
198
+ # @raise [ResponseTooLargeError] If the inflated body exceeds max_response_size.
199
+ def decode_body(chunks, headers_hash, max_response_size: nil)
152
200
  return nil if chunks.nil?
153
201
 
154
202
  remaining, decodable = self.class.split_encodings(headers_hash)
155
203
  warn_undecodable(remaining) unless remaining.empty?
156
204
 
157
- body = inflate_encodings(chunks, decodable).join
205
+ limit = max_response_size || @config.max_response_size
206
+ body = inflate_encodings(chunks, decodable, limit).join
158
207
  body.force_encoding(Encoding::ASCII_8BIT)
159
208
  # A body that is still encoded is not text yet, so the charset does not
160
209
  # describe its bytes. Leave it binary for the caller to decode.
@@ -165,46 +214,48 @@ module PatientHttp
165
214
 
166
215
  private
167
216
 
168
- # Remove the given encodings from the body, starting with the one applied
217
+ # Removes the given encodings from the body, starting with the one applied
169
218
  # last. Identity needs no work; every other name here inflates.
170
219
  #
171
- # @param chunks [Array<String>] the encoded body chunks
172
- # @param encodings [Array<String>] decodable encoding names in applied order
173
- # @return [Array<String>] the decoded chunks
174
- # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
175
- def inflate_encodings(chunks, encodings)
220
+ # @param chunks [Array<String>] The encoded body chunks.
221
+ # @param encodings [Array<String>] Decodable encoding names in applied order.
222
+ # @param limit [Integer] The size limit in bytes for the inflated body.
223
+ # @return [Array<String>] The decoded chunks.
224
+ # @raise [ResponseTooLargeError] If the inflated body exceeds the limit.
225
+ def inflate_encodings(chunks, encodings, limit)
176
226
  encodings.reverse_each do |name|
177
- chunks = [inflate_encoding(chunks, name)] if INFLATE_WINDOW_BITS.key?(name)
227
+ chunks = [inflate_encoding(chunks, name, limit)] if INFLATE_WINDOW_BITS.key?(name)
178
228
  end
179
229
 
180
230
  chunks
181
231
  end
182
232
 
183
- # Inflate one encoding, trying each wire format the encoding can use. The
233
+ # Inflates one encoding, trying each wire format the encoding can use. The
184
234
  # chunks are all in memory, so a format that turns out to be wrong can be
185
235
  # abandoned and the next one started from the beginning of the body.
186
236
  #
187
- # @param chunks [Array<String>] the encoded body chunks
188
- # @param name [String] a lowercased content encoding name
189
- # @return [String] the inflated body
190
- # @raise [Zlib::Error] if no format could inflate the body
191
- # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
192
- def inflate_encoding(chunks, name)
237
+ # @param chunks [Array<String>] The encoded body chunks.
238
+ # @param name [String] A lowercased content encoding name.
239
+ # @param limit [Integer] The size limit in bytes for the inflated body.
240
+ # @return [String] The inflated body.
241
+ # @raise [Zlib::Error] If no format could inflate the body.
242
+ # @raise [ResponseTooLargeError] If the inflated body exceeds the limit.
243
+ def inflate_encoding(chunks, name, limit)
193
244
  formats = INFLATE_WINDOW_BITS.fetch(name)
194
245
  last_index = formats.size - 1
195
246
 
196
247
  formats.each_with_index do |window_bits, index|
197
- return inflate_chunks(chunks, window_bits)
248
+ return inflate_chunks(chunks, window_bits, limit)
198
249
  rescue Zlib::DataError, Zlib::BufError
199
250
  raise if index == last_index
200
251
  end
201
252
  end
202
253
 
203
- # Report an encoding that could not be removed. The body is still delivered
254
+ # Reports an encoding that could not be removed. The body is still delivered
204
255
  # with its content-encoding header, so the caller can decode it, but the
205
256
  # server ignored the accept-encoding header and that is worth recording.
206
257
  #
207
- # @param remaining [Array<String>] the encodings left on the body
258
+ # @param remaining [Array<String>] The encodings left on the body.
208
259
  # @return [void]
209
260
  def warn_undecodable(remaining)
210
261
  logger&.warn(
@@ -214,77 +265,74 @@ module PatientHttp
214
265
  nil
215
266
  end
216
267
 
217
- def max_response_size
218
- @config.max_response_size
219
- end
220
-
221
268
  def logger
222
269
  @config.logger
223
270
  end
224
271
 
225
- # Validate content-length header doesn't exceed max size.
272
+ # Validates the declared body length doesn't exceed max size.
226
273
  #
227
- # @param headers_hash [Hash] the response headers
228
- # @raise [ResponseTooLargeError] if content-length exceeds max_response_size
229
- def validate_content_length(headers_hash)
230
- content_length = headers_hash["content-length"]&.to_i
231
- if content_length && content_length > max_response_size
274
+ # @param headers_hash [Hash] The response headers.
275
+ # @param body [Protocol::HTTP::Body::Readable] The response body.
276
+ # @param limit [Integer] The size limit in bytes.
277
+ # @raise [ResponseTooLargeError] If the declared length exceeds the limit.
278
+ def validate_content_length(headers_hash, body, limit)
279
+ content_length = headers_hash["content-length"]&.to_i || body.length
280
+ if content_length && content_length > limit
232
281
  raise ResponseTooLargeError.new(
233
- "Response body size (#{content_length} bytes) exceeds maximum allowed size (#{max_response_size} bytes)"
282
+ "Response body size (#{content_length} bytes) exceeds maximum allowed size (#{limit} bytes)"
234
283
  )
235
284
  end
236
285
  end
237
286
 
238
- # Read body chunks while checking size.
287
+ # Reads body chunks while checking size.
239
288
  #
240
- # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
241
- # @return [Array<String>] the raw body chunks
242
- # @raise [ResponseTooLargeError] if body size exceeds max_response_size during read
243
- # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
244
- def read_body_chunks(async_response)
289
+ # Each chunk is read with its own read timeout, so the timer starts again
290
+ # after every chunk.
291
+ #
292
+ # @param body [Protocol::HTTP::Body::Readable] The response body.
293
+ # @param limit [Integer] The size limit in bytes.
294
+ # @param read_timeout [Numeric, nil] The maximum number of seconds to wait for
295
+ # each chunk.
296
+ # @return [Array<String>] The raw body chunks.
297
+ # @raise [ResponseTooLargeError] If body size exceeds the limit during read.
298
+ # @raise [ReadTimeoutError] If no chunk arrives within the read timeout.
299
+ # @raise [ReadAbortedError] If the processor stopped past its shutdown deadline mid-read.
300
+ def read_body_chunks(body, limit, read_timeout)
245
301
  chunks = []
246
302
  total_size = 0
247
- finished = false
248
303
 
249
- begin
250
- async_response.body.each do |chunk|
251
- # Abort the read once the processor has passed its shutdown deadline.
252
- # Reads are allowed to finish while the processor is merely stopping
253
- # (the graceful shutdown window) so in-flight responses can still be
254
- # delivered.
255
- if @processor&.stopped?
256
- raise ReadAbortedError.new("Processor stopped while reading response body")
257
- end
258
-
259
- total_size += chunk.bytesize
260
-
261
- if total_size > max_response_size
262
- raise ResponseTooLargeError.new(
263
- "Response body size exceeded maximum allowed size (#{max_response_size} bytes)"
264
- )
265
- end
266
-
267
- chunks << chunk
304
+ while (chunk = self.class.with_read_timeout(read_timeout) { body.read })
305
+ # Abort the read once the processor has passed its shutdown deadline.
306
+ # Reads are allowed to finish while the processor is merely stopping
307
+ # (the graceful shutdown window) so in-flight responses can still be
308
+ # delivered.
309
+ if @processor&.stopped?
310
+ raise ReadAbortedError.new("Processor stopped while reading response body")
268
311
  end
269
312
 
270
- finished = true
313
+ total_size += chunk.bytesize
271
314
 
272
- chunks
273
- ensure
274
- # Always close the body if we were interrupted or if an error occurred
275
- # This ensures the connection is properly released back to the pool
276
- async_response.body.close unless finished
315
+ if total_size > limit
316
+ raise ResponseTooLargeError.new(
317
+ "Response body size exceeded maximum allowed size (#{limit} bytes)"
318
+ )
319
+ end
320
+
321
+ chunks << chunk
277
322
  end
323
+
324
+ chunks
278
325
  end
279
326
 
280
- # Inflate compressed body chunks with streaming size enforcement, so a
281
- # small compressed body cannot expand past max_response_size.
327
+ # Inflates compressed body chunks with streaming size enforcement, so a
328
+ # small compressed body cannot expand past the size limit.
282
329
  #
283
- # @param chunks [Array<String>] the raw compressed chunks
284
- # @param window_bits [Integer] Zlib window bits for the content encoding
285
- # @return [String] the inflated body
286
- # @raise [ResponseTooLargeError] if the inflated size exceeds max_response_size
287
- def inflate_chunks(chunks, window_bits)
330
+ # @param chunks [Array<String>] The raw compressed chunks.
331
+ # @param window_bits [Integer] Zlib window bits for the content encoding.
332
+ # @param limit [Integer] The size limit in bytes for the inflated body.
333
+ # @return [String] The inflated body.
334
+ # @raise [ResponseTooLargeError] If the inflated size exceeds the limit.
335
+ def inflate_chunks(chunks, window_bits, limit)
288
336
  # A response can declare a content encoding and still carry no body.
289
337
  # There is nothing to inflate, and finishing an empty stream would
290
338
  # raise a buffer error.
@@ -300,7 +348,7 @@ module PatientHttp
300
348
  # before any check runs.
301
349
  appender = ->(output) do
302
350
  body << output
303
- validate_inflated_size(body)
351
+ validate_inflated_size(body, limit)
304
352
  end
305
353
 
306
354
  chunks.each { |chunk| inflater.inflate(chunk, &appender) }
@@ -312,18 +360,18 @@ module PatientHttp
312
360
  body
313
361
  end
314
362
 
315
- def validate_inflated_size(body)
316
- if body.bytesize > max_response_size
363
+ def validate_inflated_size(body, limit)
364
+ if body.bytesize > limit
317
365
  raise ResponseTooLargeError.new(
318
- "Response body size exceeded maximum allowed size (#{max_response_size} bytes)"
366
+ "Response body size exceeded maximum allowed size (#{limit} bytes)"
319
367
  )
320
368
  end
321
369
  end
322
370
 
323
- # Extract charset from Content-Type header.
371
+ # Extracts charset from Content-Type header.
324
372
  #
325
- # @param headers_hash [Hash] the response headers
326
- # @return [String, nil] the charset name or nil if not specified
373
+ # @param headers_hash [Hash] The response headers.
374
+ # @return [String, nil] The charset name or nil if not specified.
327
375
  def extract_charset(headers_hash)
328
376
  content_type = headers_hash["content-type"]
329
377
  return nil unless content_type
@@ -335,14 +383,14 @@ module PatientHttp
335
383
  charset.gsub(/\A["']|["']\z/, "")
336
384
  end
337
385
 
338
- # Apply charset encoding to response body.
386
+ # Applies charset encoding to response body.
339
387
  #
340
388
  # Sets the string encoding based on the charset specified in the Content-Type header.
341
389
  # Falls back to ASCII-8BIT if charset is invalid or not recognized.
342
390
  #
343
- # @param body [String] the response body
344
- # @param headers_hash [Hash] the response headers
345
- # @return [String] the body with proper encoding set
391
+ # @param body [String] The response body.
392
+ # @param headers_hash [Hash] The response headers.
393
+ # @return [String] The body with proper encoding set.
346
394
  def apply_charset_encoding(body, headers_hash)
347
395
  return body unless body
348
396
 
@@ -1,41 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Resolves {SecretReference} values into their actual secret values when a request
5
- # is sent by the processor.
4
+ # Replaces {SecretReference} values with the secret values when the processor
5
+ # sends a request.
6
6
  #
7
- # A SecretManager is built from the secrets registered on the {Configuration}.
7
+ # The manager holds the secrets registered in the {Configuration}.
8
8
  #
9
9
  # @see Configuration#secret_manager
10
10
  class SecretManager
11
- # Raised when a referenced secret cannot be resolved.
11
+ # Raised when a referenced secret isn't registered.
12
12
  class SecretNotFoundError < StandardError; end
13
13
 
14
- # Initialize a new SecretManager.
14
+ # Creates a secret manager.
15
15
  #
16
- # @param secrets [Hash{String => Object}] static registry mapping names to values
17
- # (a value may be a callable, which is invoked with the name to produce the value)
18
- # secret not found in the static registry
16
+ # @param secrets [Hash{String => Object}] The secret values, keyed by name. A
17
+ # value can be a callable, which is called with the name to get the value.
19
18
  def initialize(secrets: {})
20
19
  @secrets = secrets || {}
21
20
  end
22
21
 
23
- # Check if a secret name is registered in the static registry.
22
+ # Returns whether a secret is registered.
24
23
  #
25
- # @param name [String, Symbol] the secret name
26
- # @return [Boolean] true if the name is registered, false otherwise
24
+ # @param name [String, Symbol] The secret name.
25
+ # @return [Boolean] `true` if the secret is registered.
27
26
  def include?(name)
28
27
  @secrets.include?(name.to_s)
29
28
  end
30
29
 
31
- # Resolve a secret by name.
30
+ # Returns the value of a secret. If the registered value responds to `call`,
31
+ # it's called with the name.
32
32
  #
33
- # The static registry is checked first; if the registered value responds to #call
34
- # it is invoked with the name. If the name is not in the registry, an error is raised.
35
- #
36
- # @param name [String, Symbol] the secret name
37
- # @return [String] the resolved secret value
38
- # @raise [SecretNotFoundError] if the secret cannot be resolved
33
+ # @param name [String, Symbol] The secret name.
34
+ # @return [String, nil] The secret value.
35
+ # @raise [SecretNotFoundError] If the secret isn't registered.
39
36
  def resolve(name)
40
37
  name = name.to_s
41
38
 
@@ -48,27 +45,34 @@ module PatientHttp
48
45
  value&.to_s
49
46
  end
50
47
 
51
- # Resolve any secret references in a headers hash, returning a new hash.
48
+ # Returns a copy of the headers with the secret references replaced by the
49
+ # secret values.
52
50
  #
53
- # @param headers [Hash, nil] header name/value pairs
54
- # @return [Hash, nil] a new hash with secret references replaced by resolved values
51
+ # @param headers [Hash, nil] The headers.
52
+ # @return [Hash, nil] The resolved headers.
53
+ # @raise [SecretNotFoundError] If a referenced secret isn't registered.
55
54
  def resolve_headers(headers)
56
55
  resolve_values(headers)
57
56
  end
58
57
 
59
- # Resolve any secret references in a params hash, returning a new hash.
58
+ # Returns a copy of the query parameters with the secret references
59
+ # replaced by the secret values.
60
60
  #
61
- # @param params [Hash, nil] param name/value pairs
62
- # @return [Hash, nil] a new hash with secret references replaced by resolved values
61
+ # @param params [Hash, nil] The query parameters.
62
+ # @return [Hash, nil] The resolved query parameters.
63
+ # @raise [SecretNotFoundError] If a referenced secret isn't registered.
63
64
  def resolve_params(params)
64
65
  resolve_values(params)
65
66
  end
66
67
 
67
- # Append resolved secret params to a URL's query string.
68
+ # Adds the resolved secret query parameters to a URL.
68
69
  #
69
- # @param url [String] the request URL
70
- # @param secret_params [Hash, nil] secret param name/value (SecretReference) pairs
71
- # @return [String] the URL with resolved secret params appended (unchanged if none)
70
+ # @param url [String] The request URL.
71
+ # @param secret_params [Hash, nil] The query parameters whose values are
72
+ # secret references.
73
+ # @return [String] The URL with the parameters added. If there are no
74
+ # parameters, the URL is unchanged.
75
+ # @raise [SecretNotFoundError] If a referenced secret isn't registered.
72
76
  def resolve_url(url, secret_params)
73
77
  return url if secret_params.nil? || secret_params.empty?
74
78
 
@@ -80,8 +84,8 @@ module PatientHttp
80
84
 
81
85
  private
82
86
 
83
- # Return a new hash with any secret-reference values replaced by their resolved
84
- # values. Non-secret values are passed through unchanged.
87
+ # Returns a copy of a hash with the secret references replaced by the
88
+ # secret values. Other values are unchanged.
85
89
  def resolve_values(hash)
86
90
  return hash if hash.nil?
87
91