patient_http 1.0.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 +7 -0
- data/ARCHITECTURE.md +322 -0
- data/CHANGELOG.md +30 -0
- data/MIT-LICENSE +20 -0
- data/README.md +653 -0
- data/VERSION +1 -0
- data/db/migrate/20250101000000_create_patient_http_payloads.rb +15 -0
- data/lib/patient_http/callback_args.rb +176 -0
- data/lib/patient_http/callback_validator.rb +52 -0
- data/lib/patient_http/class_helper.rb +26 -0
- data/lib/patient_http/client.rb +80 -0
- data/lib/patient_http/client_pool.rb +178 -0
- data/lib/patient_http/configuration.rb +365 -0
- data/lib/patient_http/encryptor.rb +69 -0
- data/lib/patient_http/error.rb +76 -0
- data/lib/patient_http/external_storage.rb +134 -0
- data/lib/patient_http/http_error.rb +106 -0
- data/lib/patient_http/http_headers.rb +99 -0
- data/lib/patient_http/lifecycle_manager.rb +174 -0
- data/lib/patient_http/payload.rb +160 -0
- data/lib/patient_http/payload_store/active_record_store.rb +102 -0
- data/lib/patient_http/payload_store/base.rb +150 -0
- data/lib/patient_http/payload_store/file_store.rb +92 -0
- data/lib/patient_http/payload_store/redis_store.rb +98 -0
- data/lib/patient_http/payload_store/s3_store.rb +94 -0
- data/lib/patient_http/payload_store.rb +11 -0
- data/lib/patient_http/processor.rb +538 -0
- data/lib/patient_http/processor_observer.rb +48 -0
- data/lib/patient_http/rails/engine.rb +21 -0
- data/lib/patient_http/redirect_error.rb +136 -0
- data/lib/patient_http/redirect_helper.rb +90 -0
- data/lib/patient_http/request.rb +158 -0
- data/lib/patient_http/request_error.rb +150 -0
- data/lib/patient_http/request_helper.rb +230 -0
- data/lib/patient_http/request_task.rb +308 -0
- data/lib/patient_http/request_template.rb +114 -0
- data/lib/patient_http/response.rb +183 -0
- data/lib/patient_http/response_reader.rb +135 -0
- data/lib/patient_http/synchronous_executor.rb +241 -0
- data/lib/patient_http/task_handler.rb +55 -0
- data/lib/patient_http/time_helper.rb +32 -0
- data/lib/patient_http.rb +313 -0
- data/patient_http.gemspec +48 -0
- metadata +161 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Configuration for the PatientHttp processor.
|
|
5
|
+
#
|
|
6
|
+
# This class holds all configuration options for the HTTP connection pool,
|
|
7
|
+
# including connection limits, timeouts, and other HTTP client settings.
|
|
8
|
+
# It has no dependencies on any job system.
|
|
9
|
+
class Configuration
|
|
10
|
+
# Salt used for generating encryption keys. This is a fixed value to ensure
|
|
11
|
+
# consistent key generation across instances and must never be changed.
|
|
12
|
+
SALT = "patient_http_payload_encryption"
|
|
13
|
+
private_constant :SALT
|
|
14
|
+
|
|
15
|
+
# @return [Integer] Maximum number of concurrent connections
|
|
16
|
+
attr_reader :max_connections
|
|
17
|
+
|
|
18
|
+
# @return [Numeric] Default request timeout in seconds
|
|
19
|
+
attr_reader :request_timeout
|
|
20
|
+
|
|
21
|
+
# @return [Numeric] Graceful shutdown timeout in seconds
|
|
22
|
+
attr_reader :shutdown_timeout
|
|
23
|
+
|
|
24
|
+
# @return [Integer] Maximum response size in bytes
|
|
25
|
+
attr_reader :max_response_size
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] Default User-Agent header value
|
|
28
|
+
attr_accessor :user_agent
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] Whether to raise HttpError for non-2xx responses by default
|
|
31
|
+
attr_accessor :raise_error_responses
|
|
32
|
+
|
|
33
|
+
# @return [Integer] Maximum number of redirects to follow (0 disables redirects)
|
|
34
|
+
attr_reader :max_redirects
|
|
35
|
+
|
|
36
|
+
# @return [Integer] This is the maximum number of hosts for which connections
|
|
37
|
+
# will be kept alive for at one time.
|
|
38
|
+
attr_reader :connection_pool_size
|
|
39
|
+
|
|
40
|
+
# @return [Numeric, nil] Connection timeout in seconds
|
|
41
|
+
attr_reader :connection_timeout
|
|
42
|
+
|
|
43
|
+
# @return [String, nil] HTTP/HTTPS proxy URL (supports authentication)
|
|
44
|
+
attr_reader :proxy_url
|
|
45
|
+
|
|
46
|
+
# @return [Integer] Number of retries for failed requests
|
|
47
|
+
attr_reader :retries
|
|
48
|
+
|
|
49
|
+
# Initializes a new Configuration with the specified options.
|
|
50
|
+
#
|
|
51
|
+
# @param max_connections [Integer] Maximum number of concurrent connections
|
|
52
|
+
# @param request_timeout [Numeric] Default request timeout in seconds
|
|
53
|
+
# @param shutdown_timeout [Numeric] Graceful shutdown timeout in seconds
|
|
54
|
+
# @param logger [Logger, nil] Logger instance to use (defaults to stdout)
|
|
55
|
+
# @param max_response_size [Integer] Maximum response size in bytes
|
|
56
|
+
# @param user_agent [String, nil] Default User-Agent header value
|
|
57
|
+
# @param raise_error_responses [Boolean] Whether to raise HttpError for non-2xx responses by default
|
|
58
|
+
# @param max_redirects [Integer] Maximum number of redirects to follow (0 disables redirects)
|
|
59
|
+
# @param connection_pool_size [Integer] Maximum number of host clients to pool
|
|
60
|
+
# @param connection_timeout [Numeric, nil] Connection timeout in seconds
|
|
61
|
+
# @param proxy_url [String, nil] HTTP/HTTPS proxy URL (supports authentication)
|
|
62
|
+
# @param retries [Integer] Number of retries for failed requests
|
|
63
|
+
def initialize(
|
|
64
|
+
max_connections: 256,
|
|
65
|
+
request_timeout: 60,
|
|
66
|
+
shutdown_timeout: 30,
|
|
67
|
+
logger: nil,
|
|
68
|
+
max_response_size: 1024 * 1024,
|
|
69
|
+
user_agent: "PatientHttp",
|
|
70
|
+
raise_error_responses: false,
|
|
71
|
+
max_redirects: 5,
|
|
72
|
+
connection_pool_size: 100,
|
|
73
|
+
connection_timeout: nil,
|
|
74
|
+
proxy_url: nil,
|
|
75
|
+
retries: 3,
|
|
76
|
+
encryption_key: nil
|
|
77
|
+
)
|
|
78
|
+
# Initialize payload store configuration
|
|
79
|
+
@payload_stores = {}
|
|
80
|
+
@default_payload_store_name = nil
|
|
81
|
+
@payload_store_mutex = Mutex.new
|
|
82
|
+
|
|
83
|
+
@encryptor = nil
|
|
84
|
+
|
|
85
|
+
self.max_connections = max_connections
|
|
86
|
+
self.request_timeout = request_timeout
|
|
87
|
+
self.shutdown_timeout = shutdown_timeout
|
|
88
|
+
self.logger = logger || Logger.new($stderr, level: Logger::ERROR)
|
|
89
|
+
self.max_response_size = max_response_size
|
|
90
|
+
self.user_agent = user_agent
|
|
91
|
+
self.raise_error_responses = raise_error_responses
|
|
92
|
+
self.max_redirects = max_redirects
|
|
93
|
+
self.connection_pool_size = connection_pool_size
|
|
94
|
+
self.connection_timeout = connection_timeout
|
|
95
|
+
self.proxy_url = proxy_url
|
|
96
|
+
self.retries = retries
|
|
97
|
+
self.encryption_key = encryption_key
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get the logger to use to report pool events. Default is to log errors to STDERR.
|
|
101
|
+
# @return [Logger] the logger instance
|
|
102
|
+
attr_accessor :logger
|
|
103
|
+
|
|
104
|
+
def max_connections=(value)
|
|
105
|
+
validate_positive(:max_connections, value)
|
|
106
|
+
@max_connections = value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def request_timeout=(value)
|
|
110
|
+
validate_positive(:request_timeout, value)
|
|
111
|
+
@request_timeout = value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def shutdown_timeout=(value)
|
|
115
|
+
validate_positive(:shutdown_timeout, value)
|
|
116
|
+
@shutdown_timeout = value
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def max_response_size=(value)
|
|
120
|
+
validate_positive(:max_response_size, value)
|
|
121
|
+
@max_response_size = value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def max_redirects=(value)
|
|
125
|
+
validate_non_negative_integer(:max_redirects, value)
|
|
126
|
+
@max_redirects = value
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def connection_pool_size=(value)
|
|
130
|
+
validate_positive_integer(:connection_pool_size, value)
|
|
131
|
+
@connection_pool_size = value
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def connection_timeout=(value)
|
|
135
|
+
if value.nil?
|
|
136
|
+
@connection_timeout = nil
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
validate_positive(:connection_timeout, value)
|
|
141
|
+
@connection_timeout = value
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def proxy_url=(value)
|
|
145
|
+
if value.nil?
|
|
146
|
+
@proxy_url = nil
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
validate_url(:proxy_url, value)
|
|
151
|
+
@proxy_url = value
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def retries=(value)
|
|
155
|
+
validate_non_negative_integer(:retries, value)
|
|
156
|
+
@retries = value
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Set the encryption callable for encrypting payloads before serialization.
|
|
160
|
+
#
|
|
161
|
+
# @param callable [#call, nil] An object that responds to #call, taking data and returning encrypted data
|
|
162
|
+
# @yield [data] A block that takes data and returns encrypted data
|
|
163
|
+
# @raise [ArgumentError] If both callable and block are provided, or if callable doesn't respond to #call
|
|
164
|
+
def encryption(callable = nil, &block)
|
|
165
|
+
@encryption = resolve_callable(:encryption, callable, &block)
|
|
166
|
+
@encryptor = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Set the decryption callable for decrypting payloads after deserialization.
|
|
170
|
+
#
|
|
171
|
+
# @param callable [#call, nil] An object that responds to #call, taking data and returning decrypted data
|
|
172
|
+
# @yield [data] A block that takes data and returns decrypted data
|
|
173
|
+
# @raise [ArgumentError] If both callable and block are provided, or if callable doesn't respond to #call
|
|
174
|
+
def decryption(callable = nil, &block)
|
|
175
|
+
@decryption = resolve_callable(:decryption, callable, &block)
|
|
176
|
+
@encryptor = nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def encryption_key=(keys)
|
|
180
|
+
keys = Array(keys).map(&:to_s).reject(&:empty?)
|
|
181
|
+
if keys.empty?
|
|
182
|
+
@encryption = nil
|
|
183
|
+
@decryption = nil
|
|
184
|
+
@encryptor = nil
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
unless defined?(ActiveSupport::MessageEncryptor)
|
|
189
|
+
begin
|
|
190
|
+
require "active_support/key_generator"
|
|
191
|
+
require "active_support/message_encryptor"
|
|
192
|
+
rescue LoadError
|
|
193
|
+
raise ArgumentError.new("ActiveSupport::MessageEncryptor is required for encryption_key")
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
key_length = ActiveSupport::MessageEncryptor.key_len
|
|
198
|
+
key_generator = lambda do |key|
|
|
199
|
+
ActiveSupport::KeyGenerator.new(key).generate_key(SALT, key_length)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
encryptor = ActiveSupport::MessageEncryptor.new(key_generator.call(keys.first), cipher: "aes-256-gcm")
|
|
203
|
+
keys[1..].each { |key| encryptor.rotate(key_generator.call(key)) }
|
|
204
|
+
|
|
205
|
+
encryption { |data| encryptor.encrypt_and_sign(data) }
|
|
206
|
+
decryption { |data| encryptor.decrypt_and_verify(data) }
|
|
207
|
+
@encryptor = nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Return an Encryptor instance. If encryption and decryption are not set, then
|
|
211
|
+
# this will be an empty Encryptor that returns data unchanged.
|
|
212
|
+
#
|
|
213
|
+
# @return [Encryptor] the encryptor instance
|
|
214
|
+
def encryptor
|
|
215
|
+
@encryptor ||= Encryptor.new(encryption: @encryption, decryption: @decryption)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Register a payload store for external storage of large payloads.
|
|
219
|
+
#
|
|
220
|
+
# The name is included in the serialized references to the stored data.
|
|
221
|
+
# Changing it will cause any existing reference to become invalid.
|
|
222
|
+
#
|
|
223
|
+
# Multiple stores can be registered for migration purposes. The last
|
|
224
|
+
# store registered becomes the default used for new writes. References
|
|
225
|
+
# to other registered stores remain valid for reading.
|
|
226
|
+
#
|
|
227
|
+
# @param name [Symbol, String] Unique name for this store registration
|
|
228
|
+
# @param adapter [Symbol, String] The adapter type (:file, :redis, :s3, etc.)
|
|
229
|
+
# @param options [Hash] Options passed to the adapter constructor
|
|
230
|
+
# @return [void]
|
|
231
|
+
# @raise [ArgumentError] If the adapter is not registered
|
|
232
|
+
def register_payload_store(name, adapter:, **options)
|
|
233
|
+
name = name.to_sym
|
|
234
|
+
adapter = adapter.to_sym
|
|
235
|
+
|
|
236
|
+
# Trigger autoload for common adapters
|
|
237
|
+
ensure_adapter_loaded(adapter)
|
|
238
|
+
|
|
239
|
+
unless PayloadStore::Base.lookup(adapter)
|
|
240
|
+
raise ArgumentError, "Unknown payload store adapter: #{adapter.inspect}. " \
|
|
241
|
+
"Available adapters: #{PayloadStore::Base.registered_adapters.inspect}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
store = PayloadStore::Base.create(adapter, **options)
|
|
245
|
+
|
|
246
|
+
@payload_store_mutex.synchronize do
|
|
247
|
+
@payload_stores[name] = store
|
|
248
|
+
@default_payload_store_name = name
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Get a registered payload store by name.
|
|
253
|
+
#
|
|
254
|
+
# @param name [Symbol, String, nil] Store name. If nil, returns the default store.
|
|
255
|
+
# @return [PayloadStore::Base, nil] The store instance or nil if not found
|
|
256
|
+
def payload_store(name = nil)
|
|
257
|
+
@payload_store_mutex.synchronize do
|
|
258
|
+
if name.nil?
|
|
259
|
+
return nil unless @default_payload_store_name
|
|
260
|
+
|
|
261
|
+
@payload_stores[@default_payload_store_name]
|
|
262
|
+
else
|
|
263
|
+
@payload_stores[name.to_sym]
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Get the name of the default payload store.
|
|
269
|
+
#
|
|
270
|
+
# @return [Symbol, nil] The default store name or nil if none registered
|
|
271
|
+
def default_payload_store_name
|
|
272
|
+
@payload_store_mutex.synchronize do
|
|
273
|
+
@default_payload_store_name
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Get all registered payload stores.
|
|
278
|
+
#
|
|
279
|
+
# @return [Hash{Symbol => PayloadStore::Base}] Copy of registered stores
|
|
280
|
+
def payload_stores
|
|
281
|
+
@payload_store_mutex.synchronize do
|
|
282
|
+
@payload_stores.dup
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Convert to hash for inspection
|
|
287
|
+
# @return [Hash] hash representation with string keys
|
|
288
|
+
def to_h
|
|
289
|
+
{
|
|
290
|
+
"max_connections" => max_connections,
|
|
291
|
+
"request_timeout" => request_timeout,
|
|
292
|
+
"shutdown_timeout" => shutdown_timeout,
|
|
293
|
+
"logger" => logger,
|
|
294
|
+
"max_response_size" => max_response_size,
|
|
295
|
+
"user_agent" => user_agent,
|
|
296
|
+
"raise_error_responses" => raise_error_responses,
|
|
297
|
+
"max_redirects" => max_redirects,
|
|
298
|
+
"connection_pool_size" => connection_pool_size,
|
|
299
|
+
"connection_timeout" => connection_timeout,
|
|
300
|
+
"proxy_url" => proxy_url,
|
|
301
|
+
"retries" => retries,
|
|
302
|
+
"payload_stores" => payload_stores.keys,
|
|
303
|
+
"default_payload_store" => default_payload_store_name
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
def resolve_callable(name, callable = nil, &block)
|
|
310
|
+
if callable && block
|
|
311
|
+
raise ArgumentError, "#{name} accepts either a callable argument or a block, not both"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
if callable && !callable.respond_to?(:call)
|
|
315
|
+
raise ArgumentError, "#{name} callable must respond to #call"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
callable || block
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def validate_positive(attribute, value)
|
|
322
|
+
return if value.is_a?(Numeric) && value > 0
|
|
323
|
+
|
|
324
|
+
raise ArgumentError.new("#{attribute} must be a positive number, got: #{value.inspect}")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def validate_non_negative_integer(attribute, value)
|
|
328
|
+
return if value.is_a?(Integer) && value >= 0
|
|
329
|
+
|
|
330
|
+
raise ArgumentError.new("#{attribute} must be a non-negative integer, got: #{value.inspect}")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def validate_positive_integer(attribute, value)
|
|
334
|
+
return if value.is_a?(Integer) && value > 0
|
|
335
|
+
|
|
336
|
+
raise ArgumentError.new("#{attribute} must be a positive integer, got: #{value.inspect}")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def validate_url(attribute, value)
|
|
340
|
+
uri = URI.parse(value)
|
|
341
|
+
return if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
342
|
+
|
|
343
|
+
raise ArgumentError.new("#{attribute} must be an HTTP or HTTPS URL, got: #{value.inspect}")
|
|
344
|
+
rescue URI::InvalidURIError
|
|
345
|
+
raise ArgumentError.new("#{attribute} must be a valid URL, got: #{value.inspect}")
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Ensure adapter class is loaded (triggers autoload).
|
|
349
|
+
#
|
|
350
|
+
# @param adapter [Symbol] The adapter name
|
|
351
|
+
# @return [void]
|
|
352
|
+
def ensure_adapter_loaded(adapter)
|
|
353
|
+
case adapter
|
|
354
|
+
when :file
|
|
355
|
+
PayloadStore::FileStore
|
|
356
|
+
when :redis
|
|
357
|
+
PayloadStore::RedisStore
|
|
358
|
+
when :s3
|
|
359
|
+
PayloadStore::S3Store
|
|
360
|
+
when :active_record
|
|
361
|
+
PayloadStore::ActiveRecordStore
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Handles encryption and decryption of payloads for secure storage.
|
|
5
|
+
#
|
|
6
|
+
# This class provides a simple interface for encrypting data before storage and
|
|
7
|
+
# decrypting it after retrieval. It supports pluggable encryption and decryption
|
|
8
|
+
# logic, allowing you to use any encryption library or method that fits your needs.
|
|
9
|
+
class Encryptor
|
|
10
|
+
# Initialize a new Encryptor with optional encryption and decryption callables.
|
|
11
|
+
#
|
|
12
|
+
# @param encryption [#call, nil] A callable object that takes data and returns encrypted data
|
|
13
|
+
# @param decryption [#call, nil] A callable object that takes encrypted data and returns decrypted data
|
|
14
|
+
def initialize(encryption: nil, decryption: nil)
|
|
15
|
+
@encryption = encryption
|
|
16
|
+
@decryption = decryption
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Encrypt data using the provided encryption callable. If no encryption callable is set,
|
|
20
|
+
# returns the original data.
|
|
21
|
+
#
|
|
22
|
+
# @param data [Hash] The data to be encrypted
|
|
23
|
+
# @return [Hash, nil] The encrypted data as a hash or the original data if no encryption callable is set
|
|
24
|
+
# @raise [JSON::GeneratorError] If the data cannot be serialized to JSON
|
|
25
|
+
def encrypt(data)
|
|
26
|
+
return nil if data.nil?
|
|
27
|
+
|
|
28
|
+
raise ArgumentError.new("Data is not a Hash") unless data.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
return data unless @encryption
|
|
31
|
+
|
|
32
|
+
json = JSON.generate(data)
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
"__encrypted__" => true,
|
|
36
|
+
"value" => base64_encode(@encryption.call(json))
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Decrypt data using the provided decryption callable. If no decryption callable is set,
|
|
41
|
+
# or if the data is not marked as encrypted, returns the original data.
|
|
42
|
+
#
|
|
43
|
+
# @param data [Hash] The data to be decrypted
|
|
44
|
+
# @return [Hash, nil] The decrypted data as a hash or the original data if no decryption callable
|
|
45
|
+
# is set or if data is not encrypted
|
|
46
|
+
# @raise [JSON::ParserError] If the decrypted data cannot be parsed as JSON
|
|
47
|
+
def decrypt(data)
|
|
48
|
+
return nil if data.nil?
|
|
49
|
+
|
|
50
|
+
raise ArgumentError.new("Data is not a Hash") unless data.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
return data unless @decryption && data["__encrypted__"]
|
|
53
|
+
return nil if data["value"].nil?
|
|
54
|
+
|
|
55
|
+
decrypted = @decryption.call(base64_decode(data["value"]))
|
|
56
|
+
JSON.parse(decrypted)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def base64_encode(data)
|
|
62
|
+
[data].pack("m0")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def base64_decode(data)
|
|
66
|
+
data.unpack1("m")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Base error class for async HTTP errors. This is an abstract class that
|
|
5
|
+
# defines the common error interface.
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
class << self
|
|
8
|
+
# Load an error from a hash, dispatching to the appropriate subclass.
|
|
9
|
+
#
|
|
10
|
+
# @param hash [Hash] hash representation of the error
|
|
11
|
+
# @return [Error] the reconstructed error
|
|
12
|
+
def load(hash)
|
|
13
|
+
# Dispatch based on hash structure
|
|
14
|
+
if hash.key?("response")
|
|
15
|
+
HttpError.load(hash)
|
|
16
|
+
elsif hash.key?("redirects")
|
|
17
|
+
RedirectError.load(hash)
|
|
18
|
+
else
|
|
19
|
+
RequestError.load(hash)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the error type symbol. Provided for compatibility with RequestError.
|
|
25
|
+
#
|
|
26
|
+
# @return [Symbol] the error type
|
|
27
|
+
def error_type
|
|
28
|
+
:unknown
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] Request URL
|
|
32
|
+
def url
|
|
33
|
+
raise NotImplementedError, "Subclasses must implement #url"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Symbol] HTTP method
|
|
37
|
+
def http_method
|
|
38
|
+
raise NotImplementedError, "Subclasses must implement #http_method"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Float] Request duration in seconds
|
|
42
|
+
def duration
|
|
43
|
+
raise NotImplementedError, "Subclasses must implement #duration"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String] Unique request identifier
|
|
47
|
+
def request_id
|
|
48
|
+
raise NotImplementedError, "Subclasses must implement #request_id"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Class] the class of the exception that caused the error
|
|
52
|
+
def error_class
|
|
53
|
+
raise NotImplementedError, "Subclasses must implement #error_class"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [CallbackArgs] the callback arguments
|
|
57
|
+
def callback_args
|
|
58
|
+
raise NotImplementedError, "Subclasses must implement #callback_args"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Serialize to a hash for JSON encoding. Subclasses must implement this.
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash] hash representation of the error
|
|
64
|
+
def as_json
|
|
65
|
+
raise NotImplementedError, "Subclasses must implement #as_json"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Serialize to JSON string.
|
|
69
|
+
#
|
|
70
|
+
# @param options [Hash] options to pass to JSON.generate (for ActiveSupport compatibility)
|
|
71
|
+
# @return [String] JSON representation
|
|
72
|
+
def to_json(options = nil)
|
|
73
|
+
JSON.generate(as_json, options)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Handles external storage of large payloads.
|
|
5
|
+
#
|
|
6
|
+
# This class provides methods for storing, fetching, and deleting
|
|
7
|
+
# payloads from external storage. It is decoupled from the models
|
|
8
|
+
# being stored (Request, Response, Error).
|
|
9
|
+
#
|
|
10
|
+
# @example Storing a large payload
|
|
11
|
+
# external_storage = ExternalStorage.new(config)
|
|
12
|
+
# data = response.as_json
|
|
13
|
+
# stored_data = external_storage.store(data)
|
|
14
|
+
#
|
|
15
|
+
# @example Fetching and deleting
|
|
16
|
+
# if ExternalStorage.storage_ref?(data)
|
|
17
|
+
# external_storage = ExternalStorage.new(config)
|
|
18
|
+
# original_data = external_storage.fetch(data)
|
|
19
|
+
# response = Response.load(original_data)
|
|
20
|
+
# external_storage.delete(data)
|
|
21
|
+
# end
|
|
22
|
+
class ExternalStorage
|
|
23
|
+
# Key used in serialized JSON to indicate an external storage reference
|
|
24
|
+
REFERENCE_KEY = "$ref"
|
|
25
|
+
|
|
26
|
+
class PayloadStoreNotFoundError < StandardError; end
|
|
27
|
+
class PayloadNotFoundError < StandardError; end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Check if a hash is a storage reference.
|
|
31
|
+
#
|
|
32
|
+
# @param data [Hash, Object] Data to check
|
|
33
|
+
# @return [Boolean] true if this is a reference to external storage
|
|
34
|
+
def storage_ref?(data)
|
|
35
|
+
data.is_a?(Hash) && data.key?(REFERENCE_KEY)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Configuration] the pool configuration
|
|
40
|
+
attr_reader :config
|
|
41
|
+
|
|
42
|
+
# Create a new ExternalStorage instance.
|
|
43
|
+
#
|
|
44
|
+
# @param config [Configuration] the pool configuration
|
|
45
|
+
def initialize(config)
|
|
46
|
+
@config = config
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if a hash is a storage reference.
|
|
50
|
+
#
|
|
51
|
+
# @param data [Hash, Object] Data to check
|
|
52
|
+
# @return [Boolean] true if this is a reference to external storage
|
|
53
|
+
def storage_ref?(data)
|
|
54
|
+
self.class.storage_ref?(data)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if external storage is enabled (i.e. a payload store is configured).
|
|
58
|
+
#
|
|
59
|
+
# @return [Boolean] true if external storage is configured
|
|
60
|
+
def enabled?
|
|
61
|
+
!!config.payload_store
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Store a hash externally if it exceeds the configured threshold.
|
|
65
|
+
#
|
|
66
|
+
# If no payload store is configured, or if the hash is below the
|
|
67
|
+
# threshold, the original hash is returned unchanged.
|
|
68
|
+
#
|
|
69
|
+
# @param data [Hash] Hash to potentially store
|
|
70
|
+
# @param max_size [Integer, nil] Optional payload size threshold in bytes.
|
|
71
|
+
# The JSON payload will only be stored externally if it exceeds this size.
|
|
72
|
+
# If the JSON payload does not exceed the threshold, the original hash is returned.
|
|
73
|
+
# When nil (the default), the payload is always stored externally.
|
|
74
|
+
# @return [Hash] Reference hash if stored, original hash if not
|
|
75
|
+
# @raise [PayloadStoreNotFoundError] If no payload store is configured
|
|
76
|
+
def store(data, max_size: nil)
|
|
77
|
+
store = config.payload_store
|
|
78
|
+
raise PayloadStoreNotFoundError.new("No payload store configured") unless store
|
|
79
|
+
|
|
80
|
+
json = JSON.generate(data)
|
|
81
|
+
return data if max_size && json.bytesize <= max_size
|
|
82
|
+
|
|
83
|
+
key = store.generate_key
|
|
84
|
+
store.store_json(key, json)
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
REFERENCE_KEY => {
|
|
88
|
+
"store" => config.default_payload_store_name.to_s,
|
|
89
|
+
"key" => key
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Fetch a hash from external storage.
|
|
95
|
+
#
|
|
96
|
+
# @param data [Hash] Reference hash containing storage location
|
|
97
|
+
# @return [Hash] Original hash from storage
|
|
98
|
+
# @raise [PayloadStoreNotFoundError] If the store is not registered
|
|
99
|
+
# @raise [PayloadNotFoundError] If the stored payload is not found
|
|
100
|
+
def fetch(data)
|
|
101
|
+
raise ArgumentError.new("Not a storage reference") unless self.class.storage_ref?(data)
|
|
102
|
+
|
|
103
|
+
ref = data[REFERENCE_KEY]
|
|
104
|
+
store_name = ref["store"].to_sym
|
|
105
|
+
key = ref["key"]
|
|
106
|
+
|
|
107
|
+
store = config.payload_store(store_name)
|
|
108
|
+
raise PayloadStoreNotFoundError.new("Payload store '#{store_name}' not registered") unless store
|
|
109
|
+
|
|
110
|
+
stored_data = store.fetch(key)
|
|
111
|
+
raise PayloadNotFoundError.new("Stored payload not found: #{store_name}/#{key}") unless stored_data
|
|
112
|
+
|
|
113
|
+
stored_data
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Delete payload from external storage.
|
|
117
|
+
#
|
|
118
|
+
# This method is idempotent - it's safe to call on non-reference hashes,
|
|
119
|
+
# already-deleted payloads, or nil values.
|
|
120
|
+
#
|
|
121
|
+
# @param data [Hash, nil] Reference hash (or regular hash, which is ignored)
|
|
122
|
+
# @return [void]
|
|
123
|
+
def delete(data)
|
|
124
|
+
return unless data && self.class.storage_ref?(data)
|
|
125
|
+
|
|
126
|
+
ref = data[REFERENCE_KEY]
|
|
127
|
+
store_name = ref["store"].to_sym
|
|
128
|
+
key = ref["key"]
|
|
129
|
+
|
|
130
|
+
store = config.payload_store(store_name)
|
|
131
|
+
store&.delete(key)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|