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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +322 -0
  3. data/CHANGELOG.md +30 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +653 -0
  6. data/VERSION +1 -0
  7. data/db/migrate/20250101000000_create_patient_http_payloads.rb +15 -0
  8. data/lib/patient_http/callback_args.rb +176 -0
  9. data/lib/patient_http/callback_validator.rb +52 -0
  10. data/lib/patient_http/class_helper.rb +26 -0
  11. data/lib/patient_http/client.rb +80 -0
  12. data/lib/patient_http/client_pool.rb +178 -0
  13. data/lib/patient_http/configuration.rb +365 -0
  14. data/lib/patient_http/encryptor.rb +69 -0
  15. data/lib/patient_http/error.rb +76 -0
  16. data/lib/patient_http/external_storage.rb +134 -0
  17. data/lib/patient_http/http_error.rb +106 -0
  18. data/lib/patient_http/http_headers.rb +99 -0
  19. data/lib/patient_http/lifecycle_manager.rb +174 -0
  20. data/lib/patient_http/payload.rb +160 -0
  21. data/lib/patient_http/payload_store/active_record_store.rb +102 -0
  22. data/lib/patient_http/payload_store/base.rb +150 -0
  23. data/lib/patient_http/payload_store/file_store.rb +92 -0
  24. data/lib/patient_http/payload_store/redis_store.rb +98 -0
  25. data/lib/patient_http/payload_store/s3_store.rb +94 -0
  26. data/lib/patient_http/payload_store.rb +11 -0
  27. data/lib/patient_http/processor.rb +538 -0
  28. data/lib/patient_http/processor_observer.rb +48 -0
  29. data/lib/patient_http/rails/engine.rb +21 -0
  30. data/lib/patient_http/redirect_error.rb +136 -0
  31. data/lib/patient_http/redirect_helper.rb +90 -0
  32. data/lib/patient_http/request.rb +158 -0
  33. data/lib/patient_http/request_error.rb +150 -0
  34. data/lib/patient_http/request_helper.rb +230 -0
  35. data/lib/patient_http/request_task.rb +308 -0
  36. data/lib/patient_http/request_template.rb +114 -0
  37. data/lib/patient_http/response.rb +183 -0
  38. data/lib/patient_http/response_reader.rb +135 -0
  39. data/lib/patient_http/synchronous_executor.rb +241 -0
  40. data/lib/patient_http/task_handler.rb +55 -0
  41. data/lib/patient_http/time_helper.rb +32 -0
  42. data/lib/patient_http.rb +313 -0
  43. data/patient_http.gemspec +48 -0
  44. 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