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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +18 -8
- data/CHANGELOG.md +43 -0
- data/README.md +564 -508
- data/VERSION +1 -1
- data/lib/patient_http/callback_args.rb +53 -48
- data/lib/patient_http/callback_validator.rb +11 -7
- data/lib/patient_http/class_helper.rb +6 -7
- data/lib/patient_http/client.rb +53 -26
- data/lib/patient_http/client_pool.rb +134 -39
- data/lib/patient_http/completion_executor.rb +20 -20
- data/lib/patient_http/configuration.rb +405 -119
- data/lib/patient_http/connection_endpoint.rb +150 -0
- data/lib/patient_http/encryptor.rb +28 -18
- data/lib/patient_http/error.rb +24 -18
- data/lib/patient_http/external_storage.rb +42 -38
- data/lib/patient_http/http_error.rb +30 -26
- data/lib/patient_http/http_headers.rb +35 -29
- data/lib/patient_http/immediate_retries.rb +98 -0
- data/lib/patient_http/inline_task_handler.rb +15 -10
- data/lib/patient_http/lifecycle_manager.rb +39 -40
- data/lib/patient_http/outgoing_request.rb +25 -23
- data/lib/patient_http/payload.rb +28 -26
- data/lib/patient_http/payload_store/active_record_store.rb +31 -34
- data/lib/patient_http/payload_store/base.rb +42 -46
- data/lib/patient_http/payload_store/file_store.rb +22 -26
- data/lib/patient_http/payload_store/redis_store.rb +28 -34
- data/lib/patient_http/payload_store/s3_store.rb +25 -28
- data/lib/patient_http/payload_store.rb +2 -0
- data/lib/patient_http/processor.rb +111 -79
- data/lib/patient_http/processor_observer.rb +65 -59
- data/lib/patient_http/rails/engine.rb +13 -8
- data/lib/patient_http/redirect_error.rb +50 -41
- data/lib/patient_http/redirect_helper.rb +38 -38
- data/lib/patient_http/request.rb +117 -46
- data/lib/patient_http/request_error.rb +47 -42
- data/lib/patient_http/request_helper.rb +178 -119
- data/lib/patient_http/request_preparer.rb +13 -10
- data/lib/patient_http/request_task.rb +115 -84
- data/lib/patient_http/request_template.rb +119 -64
- data/lib/patient_http/response.rb +58 -52
- data/lib/patient_http/response_reader.rb +166 -118
- data/lib/patient_http/secret_manager.rb +34 -30
- data/lib/patient_http/secret_reference.rb +33 -26
- data/lib/patient_http/synchronous_executor.rb +84 -95
- data/lib/patient_http/task_handler.rb +23 -19
- data/lib/patient_http/time_helper.rb +8 -8
- data/lib/patient_http.rb +327 -186
- data/patient_http.gemspec +3 -2
- metadata +21 -5
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
module PatientHttp
|
|
4
4
|
# Reads and decodes HTTP response bodies.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
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]
|
|
46
|
-
# @return [Array(Array<String>, Array<String>)]
|
|
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
|
-
#
|
|
57
|
+
# Parses the content-encoding header into encoding names.
|
|
57
58
|
#
|
|
58
|
-
# @param headers_hash [Hash]
|
|
59
|
-
# @return [Array<String>]
|
|
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]
|
|
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
|
-
#
|
|
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]
|
|
79
|
-
# @return [Hash]
|
|
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
|
-
#
|
|
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]
|
|
100
|
-
# @param config [Configuration, nil]
|
|
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
|
-
#
|
|
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.
|
|
118
|
-
#
|
|
119
|
-
#
|
|
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]
|
|
122
|
-
# @param headers_hash [Hash]
|
|
123
|
-
# @
|
|
124
|
-
#
|
|
125
|
-
# @
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
#
|
|
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]
|
|
148
|
-
# @param headers_hash [Hash]
|
|
149
|
-
# @
|
|
150
|
-
#
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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>]
|
|
172
|
-
# @param encodings [Array<String>]
|
|
173
|
-
# @
|
|
174
|
-
# @
|
|
175
|
-
|
|
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
|
-
#
|
|
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>]
|
|
188
|
-
# @param name [String]
|
|
189
|
-
# @
|
|
190
|
-
# @
|
|
191
|
-
# @raise [
|
|
192
|
-
|
|
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
|
-
#
|
|
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>]
|
|
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
|
-
#
|
|
272
|
+
# Validates the declared body length doesn't exceed max size.
|
|
226
273
|
#
|
|
227
|
-
# @param headers_hash [Hash]
|
|
228
|
-
# @
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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 (#{
|
|
282
|
+
"Response body size (#{content_length} bytes) exceeds maximum allowed size (#{limit} bytes)"
|
|
234
283
|
)
|
|
235
284
|
end
|
|
236
285
|
end
|
|
237
286
|
|
|
238
|
-
#
|
|
287
|
+
# Reads body chunks while checking size.
|
|
239
288
|
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
# @
|
|
244
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
313
|
+
total_size += chunk.bytesize
|
|
271
314
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
#
|
|
281
|
-
# small compressed body cannot expand past
|
|
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>]
|
|
284
|
-
# @param window_bits [Integer] Zlib window bits for the content encoding
|
|
285
|
-
# @
|
|
286
|
-
# @
|
|
287
|
-
|
|
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 >
|
|
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 (#{
|
|
366
|
+
"Response body size exceeded maximum allowed size (#{limit} bytes)"
|
|
319
367
|
)
|
|
320
368
|
end
|
|
321
369
|
end
|
|
322
370
|
|
|
323
|
-
#
|
|
371
|
+
# Extracts charset from Content-Type header.
|
|
324
372
|
#
|
|
325
|
-
# @param headers_hash [Hash]
|
|
326
|
-
# @return [String, nil]
|
|
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
|
-
#
|
|
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]
|
|
344
|
-
# @param headers_hash [Hash]
|
|
345
|
-
# @return [String]
|
|
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
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# Replaces {SecretReference} values with the secret values when the processor
|
|
5
|
+
# sends a request.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
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
|
|
11
|
+
# Raised when a referenced secret isn't registered.
|
|
12
12
|
class SecretNotFoundError < StandardError; end
|
|
13
13
|
|
|
14
|
-
#
|
|
14
|
+
# Creates a secret manager.
|
|
15
15
|
#
|
|
16
|
-
# @param secrets [Hash{String => Object}]
|
|
17
|
-
#
|
|
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
|
-
#
|
|
22
|
+
# Returns whether a secret is registered.
|
|
24
23
|
#
|
|
25
|
-
# @param name [String, Symbol]
|
|
26
|
-
# @return [Boolean] true if the
|
|
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
|
-
#
|
|
30
|
+
# Returns the value of a secret. If the registered value responds to `call`,
|
|
31
|
+
# it's called with the name.
|
|
32
32
|
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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
|
-
#
|
|
48
|
+
# Returns a copy of the headers with the secret references replaced by the
|
|
49
|
+
# secret values.
|
|
52
50
|
#
|
|
53
|
-
# @param headers [Hash, nil]
|
|
54
|
-
# @return [Hash, nil]
|
|
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
|
-
#
|
|
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]
|
|
62
|
-
# @return [Hash, nil]
|
|
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
|
-
#
|
|
68
|
+
# Adds the resolved secret query parameters to a URL.
|
|
68
69
|
#
|
|
69
|
-
# @param url [String]
|
|
70
|
-
# @param secret_params [Hash, nil]
|
|
71
|
-
#
|
|
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
|
-
#
|
|
84
|
-
# values.
|
|
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
|
|