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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Error raised when an HTTP request receives a non-2xx response status code
|
|
5
|
+
# and the raise_error_responses option is enabled.
|
|
6
|
+
#
|
|
7
|
+
# This error includes the full Response object so you can access the status code,
|
|
8
|
+
# headers, body, and other response data.
|
|
9
|
+
class HttpError < Error
|
|
10
|
+
# @return [Response] The HTTP response that triggered the error
|
|
11
|
+
attr_reader :response
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Create a new HttpError (or subclass) from a response.
|
|
15
|
+
#
|
|
16
|
+
# Returns ClientError for 4xx responses, ServerError for 5xx responses,
|
|
17
|
+
# or HttpError for other non-2xx responses.
|
|
18
|
+
#
|
|
19
|
+
# @param response [Response] The HTTP response with non-2xx status code
|
|
20
|
+
# @return [HttpError, ClientError, ServerError] The appropriate error instance
|
|
21
|
+
def new(response)
|
|
22
|
+
if response.client_error?
|
|
23
|
+
ClientError.allocate.tap { |error| error.send(:initialize, response) }
|
|
24
|
+
elsif response.server_error?
|
|
25
|
+
ServerError.allocate.tap { |error| error.send(:initialize, response) }
|
|
26
|
+
else
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Reconstruct an HttpError from a hash
|
|
32
|
+
#
|
|
33
|
+
# @param hash [Hash] hash representation
|
|
34
|
+
# @return [HttpError] reconstructed error
|
|
35
|
+
def load(hash)
|
|
36
|
+
response = Response.load(hash["response"])
|
|
37
|
+
new(response)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Initializes a new HttpError.
|
|
42
|
+
#
|
|
43
|
+
# @param response [Response] The HTTP response with non-2xx status code
|
|
44
|
+
def initialize(response)
|
|
45
|
+
super("HTTP #{response.status} response from #{response.http_method.to_s.upcase} #{response.url}")
|
|
46
|
+
@response = response
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Delegate common response methods for convenience.
|
|
50
|
+
#
|
|
51
|
+
# @return [Integer] HTTP status code
|
|
52
|
+
def status
|
|
53
|
+
@response.status
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the error type symbol. Provided for compatibility with RequestError.
|
|
57
|
+
#
|
|
58
|
+
# @return [Symbol] the error type
|
|
59
|
+
def error_type
|
|
60
|
+
:http_error
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def url
|
|
64
|
+
response.url
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def http_method
|
|
68
|
+
response.http_method
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def duration
|
|
72
|
+
response.duration
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def request_id
|
|
76
|
+
response.request_id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def error_class
|
|
80
|
+
self.class
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def callback_args
|
|
84
|
+
response.callback_args
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Convert to hash with string keys for serialization
|
|
88
|
+
#
|
|
89
|
+
# @return [Hash] hash representation
|
|
90
|
+
def as_json
|
|
91
|
+
{
|
|
92
|
+
"response" => @response.as_json
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Error raised when an HTTP request receives a 4xx (client error) response status code
|
|
98
|
+
# and the raise_error_responses option is enabled.
|
|
99
|
+
class ClientError < HttpError
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Error raised when an HTTP request receives a 5xx (server error) response status code
|
|
103
|
+
# and the raise_error_responses option is enabled.
|
|
104
|
+
class ServerError < HttpError
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Case insensitive HTTP headers.
|
|
5
|
+
#
|
|
6
|
+
# This class provides a hash-like interface for HTTP headers with case-insensitive
|
|
7
|
+
# key access. Header names are normalized to lowercase for storage and lookup.
|
|
8
|
+
class HttpHeaders
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
# Initializes a new HttpHeaders instance.
|
|
12
|
+
#
|
|
13
|
+
# @param headers [Hash] initial headers to set
|
|
14
|
+
def initialize(headers = {})
|
|
15
|
+
@headers = {}
|
|
16
|
+
headers&.each do |key, value|
|
|
17
|
+
@headers[key.to_s.downcase] = value
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Retrieves the value for a header (case insensitive).
|
|
22
|
+
#
|
|
23
|
+
# @param key [String, Symbol] header name
|
|
24
|
+
# @return [String, nil] header value or nil if not found
|
|
25
|
+
def [](key)
|
|
26
|
+
@headers[key.to_s.downcase]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Sets the value for a header (case insensitive).
|
|
30
|
+
#
|
|
31
|
+
# @param key [String, Symbol] header name
|
|
32
|
+
# @param value [String] header value
|
|
33
|
+
def []=(key, value)
|
|
34
|
+
@headers[key.to_s.downcase] = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Fetches the value for a header with an optional default.
|
|
38
|
+
#
|
|
39
|
+
# @param key [String, Symbol] header name
|
|
40
|
+
# @param default [Object] default value if header not found
|
|
41
|
+
# @return [String, Object] header value or default
|
|
42
|
+
def fetch(key, default = nil)
|
|
43
|
+
@headers.fetch(key.to_s.downcase, default)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Merges another set of headers into a new HttpHeaders instance.
|
|
47
|
+
#
|
|
48
|
+
# @param other_headers [Hash, HttpHeaders] headers to merge
|
|
49
|
+
# @return [HttpHeaders] new instance with merged headers
|
|
50
|
+
def merge(other_headers)
|
|
51
|
+
new_headers = dup
|
|
52
|
+
other_headers.each do |key, value|
|
|
53
|
+
new_headers[key] = value
|
|
54
|
+
end
|
|
55
|
+
new_headers
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns a new HttpHeaders without the specified keys (case-insensitive).
|
|
59
|
+
#
|
|
60
|
+
# @param keys [Array<String, Symbol>] header names to exclude
|
|
61
|
+
# @return [HttpHeaders] new instance without the specified headers
|
|
62
|
+
def except(*keys)
|
|
63
|
+
normalized = keys.map { |k| k.to_s.downcase }
|
|
64
|
+
filtered_headers = @headers.reject { |key, _value| normalized.include?(key) } # rubocop:disable Style/HashExcept
|
|
65
|
+
self.class.new(filtered_headers)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Converts to a regular hash with lowercase keys.
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash] hash representation
|
|
71
|
+
def to_h
|
|
72
|
+
@headers.dup
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Iterates over each header.
|
|
76
|
+
#
|
|
77
|
+
# @yield [key, value] yields each header key-value pair
|
|
78
|
+
# @return [Enumerator] if no block given
|
|
79
|
+
def each(&block)
|
|
80
|
+
@headers.each(&block)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Checks if a header exists (case insensitive).
|
|
84
|
+
#
|
|
85
|
+
# @param name [String, Symbol] header name
|
|
86
|
+
# @return [Boolean] true if header exists
|
|
87
|
+
def include?(name)
|
|
88
|
+
@headers.include?(name.to_s.downcase)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def eql?(other)
|
|
92
|
+
other.is_a?(HttpHeaders) && @headers.eql?(other.to_h)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def hash
|
|
96
|
+
@headers.hash
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Manages the lifecycle state of the Processor.
|
|
5
|
+
#
|
|
6
|
+
# Handles state transitions and provides predicates for checking the current state.
|
|
7
|
+
# Thread-safe state management using Concurrent::AtomicReference.
|
|
8
|
+
class LifecycleManager
|
|
9
|
+
include TimeHelper
|
|
10
|
+
|
|
11
|
+
# Valid processor states
|
|
12
|
+
STATES = %i[stopped starting running draining stopping].freeze
|
|
13
|
+
|
|
14
|
+
# Polling interval during wait operations
|
|
15
|
+
POLL_INTERVAL = 0.001
|
|
16
|
+
|
|
17
|
+
# Initialize the lifecycle manager.
|
|
18
|
+
#
|
|
19
|
+
# @return [void]
|
|
20
|
+
def initialize
|
|
21
|
+
@state = Concurrent::AtomicReference.new(:stopped)
|
|
22
|
+
@shutdown_barrier = Concurrent::Event.new
|
|
23
|
+
@reactor_ready = Concurrent::Event.new
|
|
24
|
+
@lock = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get the current state.
|
|
28
|
+
#
|
|
29
|
+
# @return [Symbol] the current state
|
|
30
|
+
def state
|
|
31
|
+
@state.get
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if processor is starting.
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean] true if starting
|
|
37
|
+
def starting?
|
|
38
|
+
state == :starting
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if processor is running.
|
|
42
|
+
#
|
|
43
|
+
# @return [Boolean] true if running
|
|
44
|
+
def running?
|
|
45
|
+
state == :running
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if processor is stopped.
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean] true if stopped
|
|
51
|
+
def stopped?
|
|
52
|
+
state == :stopped
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if processor is draining.
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean] true if draining
|
|
58
|
+
def draining?
|
|
59
|
+
state == :draining
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if processor is stopping.
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean] true if stopping
|
|
65
|
+
def stopping?
|
|
66
|
+
state == :stopping
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Transition to starting state.
|
|
70
|
+
#
|
|
71
|
+
# @return [Boolean] true if transition was successful
|
|
72
|
+
def start!
|
|
73
|
+
@lock.synchronize do
|
|
74
|
+
return false if starting? || running? || stopping?
|
|
75
|
+
|
|
76
|
+
@state.set(:starting)
|
|
77
|
+
@shutdown_barrier.reset
|
|
78
|
+
@reactor_ready.reset
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Transition to running state.
|
|
85
|
+
#
|
|
86
|
+
# @return [void]
|
|
87
|
+
def running!
|
|
88
|
+
@state.set(:running)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Transition to draining state.
|
|
92
|
+
#
|
|
93
|
+
# @return [Boolean] true if transition was successful
|
|
94
|
+
def drain!
|
|
95
|
+
@lock.synchronize do
|
|
96
|
+
return false unless running?
|
|
97
|
+
|
|
98
|
+
@state.set(:draining)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Transition to stopping state.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] true if transition was successful
|
|
107
|
+
def stop!
|
|
108
|
+
@lock.synchronize do
|
|
109
|
+
return false if stopped? || stopping? || starting?
|
|
110
|
+
|
|
111
|
+
@state.set(:stopping)
|
|
112
|
+
@shutdown_barrier.set
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Transition to stopped state.
|
|
119
|
+
#
|
|
120
|
+
# Also signals the reactor_ready event to unblock any thread
|
|
121
|
+
# waiting in {#wait_for_reactor} in case the reactor failed
|
|
122
|
+
# before it could signal readiness.
|
|
123
|
+
#
|
|
124
|
+
# @return [void]
|
|
125
|
+
def stopped!
|
|
126
|
+
@state.set(:stopped)
|
|
127
|
+
@reactor_ready.set
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Signal that the reactor is ready.
|
|
131
|
+
#
|
|
132
|
+
# @return [void]
|
|
133
|
+
def reactor_ready!
|
|
134
|
+
@reactor_ready.set
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Wait for the reactor to be ready.
|
|
138
|
+
#
|
|
139
|
+
# @return [void]
|
|
140
|
+
def wait_for_reactor
|
|
141
|
+
@reactor_ready.wait
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check if shutdown has been signaled.
|
|
145
|
+
#
|
|
146
|
+
# @return [Boolean] true if shutdown is signaled
|
|
147
|
+
def shutdown_signaled?
|
|
148
|
+
@shutdown_barrier.set?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Wait for running state.
|
|
152
|
+
#
|
|
153
|
+
# @param timeout [Numeric] maximum time to wait in seconds
|
|
154
|
+
# @return [Boolean] true if running, false if timeout reached
|
|
155
|
+
def wait_for_running(timeout: 5)
|
|
156
|
+
wait_for_condition(timeout: timeout) { running? }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Wait for a condition to be met.
|
|
160
|
+
#
|
|
161
|
+
# @param timeout [Numeric] maximum time to wait in seconds
|
|
162
|
+
# @yield Block that checks the condition.
|
|
163
|
+
# @return [Boolean] true if the condition is met, false if timeout reached
|
|
164
|
+
def wait_for_condition(timeout: 1)
|
|
165
|
+
deadline = monotonic_time + timeout
|
|
166
|
+
while monotonic_time <= deadline
|
|
167
|
+
return true if yield
|
|
168
|
+
|
|
169
|
+
sleep(POLL_INTERVAL)
|
|
170
|
+
end
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Handles encoding and decoding of HTTP response bodies for storage.
|
|
5
|
+
#
|
|
6
|
+
# This class provides compression and encoding strategies for different content types
|
|
7
|
+
# to optimize storage and transmission of response data.
|
|
8
|
+
class Payload
|
|
9
|
+
# @return [Symbol] the encoding type
|
|
10
|
+
attr_reader :encoding
|
|
11
|
+
|
|
12
|
+
# @return [String] the encoded data
|
|
13
|
+
attr_reader :encoded_value
|
|
14
|
+
|
|
15
|
+
# @return [String, nil] the character set (if applicable)
|
|
16
|
+
attr_reader :charset
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Reconstructs a Payload from a hash representation.
|
|
20
|
+
#
|
|
21
|
+
# @param hash [Hash, nil] hash with "encoding" and "value" keys
|
|
22
|
+
# @return [Payload, nil] reconstructed payload or nil if hash is invalid
|
|
23
|
+
def load(hash)
|
|
24
|
+
return nil if hash.nil? || hash["value"].nil?
|
|
25
|
+
|
|
26
|
+
new(hash["encoding"].to_sym, hash["value"], hash["charset"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Encodes a value based on its MIME type.
|
|
30
|
+
#
|
|
31
|
+
# For text-based content types, applies gzip compression if beneficial.
|
|
32
|
+
# For binary content, uses Base64 encoding.
|
|
33
|
+
#
|
|
34
|
+
# @param value [String] the value to encode
|
|
35
|
+
# @param mimetype [String, nil] the MIME type of the content
|
|
36
|
+
# @return [Array(Symbol, String, String), nil] [encoding, encoded_value, charset] or nil if value is nil
|
|
37
|
+
def encode(value, mimetype)
|
|
38
|
+
return nil if value.nil?
|
|
39
|
+
|
|
40
|
+
if is_text_mimetype?(mimetype)
|
|
41
|
+
value = text_value(value, charset(mimetype))
|
|
42
|
+
|
|
43
|
+
if value.bytesize < 4096
|
|
44
|
+
[:text, value, value.encoding.name]
|
|
45
|
+
else
|
|
46
|
+
gzipped = Zlib.gzip(value)
|
|
47
|
+
if gzipped.bytesize < value.bytesize
|
|
48
|
+
[:gzipped, [gzipped].pack("m0"), value.encoding.name]
|
|
49
|
+
else
|
|
50
|
+
[:text, value, value.encoding.name]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
[:binary, [value].pack("m0"), Encoding::BINARY.name]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Decodes an encoded value based on its encoding type.
|
|
59
|
+
#
|
|
60
|
+
# @param encoded_value [String] the encoded data
|
|
61
|
+
# @param encoding [Symbol] the encoding type (:text, :binary, :gzipped)
|
|
62
|
+
# @param charset [String, nil] the character set (if applicable)
|
|
63
|
+
# @return [String, nil] the decoded value or nil if encoded_value is nil
|
|
64
|
+
def decode(encoded_value, encoding, charset)
|
|
65
|
+
return nil if encoded_value.nil?
|
|
66
|
+
|
|
67
|
+
decoded_value = case encoding
|
|
68
|
+
when :text
|
|
69
|
+
encoded_value
|
|
70
|
+
when :binary
|
|
71
|
+
encoded_value.unpack1("m")
|
|
72
|
+
when :gzipped
|
|
73
|
+
Zlib.gunzip(encoded_value.unpack1("m"))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
force_encoding(decoded_value, charset)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def is_text_mimetype?(mimetype)
|
|
82
|
+
mimetype&.match?(/\Atext\/|application\/(?:json|xml|javascript)/)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def charset(mimetype)
|
|
86
|
+
return Encoding::ASCII_8BIT.name if mimetype.nil?
|
|
87
|
+
|
|
88
|
+
match = mimetype.match(/charset=([\w-]+)/)
|
|
89
|
+
return Encoding::ASCII_8BIT.name unless match
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
Encoding.find(match[1])
|
|
93
|
+
rescue
|
|
94
|
+
Encoding::ASCII_8BIT.name
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Return the value as a UTF-8 encoded string if possible. If the value cannot
|
|
99
|
+
# be converted to UTF-8, return it in the response charset or ASCII-8BIT.
|
|
100
|
+
#
|
|
101
|
+
# Encoding strategy:
|
|
102
|
+
# 1. Force-encode to the response charset
|
|
103
|
+
# 2. Try to transcode to UTF-8 for storage efficiency
|
|
104
|
+
# 3. If transcoding fails, keep the charset encoding
|
|
105
|
+
# 4. If force-encoding itself fails, fall back to ASCII-8BIT
|
|
106
|
+
def text_value(value, charset)
|
|
107
|
+
text = force_encoding(value, charset)
|
|
108
|
+
unless text.encoding == Encoding::UTF_8
|
|
109
|
+
begin
|
|
110
|
+
text = text.encode(Encoding::UTF_8)
|
|
111
|
+
rescue
|
|
112
|
+
# Ignore if cannot convert to UTF-8
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
text
|
|
116
|
+
rescue
|
|
117
|
+
force_encoding(value, Encoding::ASCII_8BIT.name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def force_encoding(value, charset)
|
|
121
|
+
return value if value.nil? || value.encoding.names.include?(charset)
|
|
122
|
+
|
|
123
|
+
charset ||= Encoding::ASCII_8BIT.name
|
|
124
|
+
value = value.dup if value.frozen?
|
|
125
|
+
value.force_encoding(charset)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Initializes a new Payload.
|
|
130
|
+
#
|
|
131
|
+
# @param encoding [Symbol] the encoding type
|
|
132
|
+
# @param encoded_value [String] the encoded data
|
|
133
|
+
# @param charset [String, nil] the character set (if applicable)
|
|
134
|
+
def initialize(encoding, encoded_value, charset)
|
|
135
|
+
@encoded_value = encoded_value
|
|
136
|
+
@encoding = encoding
|
|
137
|
+
@charset = charset
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Returns the decoded value.
|
|
141
|
+
#
|
|
142
|
+
# @return [String, nil] the decoded data
|
|
143
|
+
def value
|
|
144
|
+
self.class.decode(encoded_value, encoding, charset)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Converts to a hash representation for serialization.
|
|
148
|
+
#
|
|
149
|
+
# @return [Hash] hash with "encoding" and "value" keys
|
|
150
|
+
def as_json
|
|
151
|
+
{
|
|
152
|
+
"encoding" => encoding.to_s,
|
|
153
|
+
"value" => encoded_value,
|
|
154
|
+
"charset" => charset
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
alias_method :dump, :as_json
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module PatientHttp
|
|
6
|
+
module PayloadStore
|
|
7
|
+
# ActiveRecord-based payload store for production deployments.
|
|
8
|
+
#
|
|
9
|
+
# Stores payloads as JSON in a database table. This store is recommended
|
|
10
|
+
# when you need database-backed storage with transactional guarantees.
|
|
11
|
+
#
|
|
12
|
+
# Thread-safe: ActiveRecord handles connection pooling and thread safety.
|
|
13
|
+
#
|
|
14
|
+
# @example Configuration
|
|
15
|
+
# require "patient_http/payload_store/active_record_store"
|
|
16
|
+
# config.register_payload_store(:database, adapter: :active_record)
|
|
17
|
+
#
|
|
18
|
+
# @example With custom model
|
|
19
|
+
# config.register_payload_store(:database, adapter: :active_record,
|
|
20
|
+
# model: MyApp::PayloadRecord
|
|
21
|
+
# )
|
|
22
|
+
class ActiveRecordStore < Base
|
|
23
|
+
Base.register :active_record, self
|
|
24
|
+
|
|
25
|
+
# ActiveRecord model for payload storage.
|
|
26
|
+
#
|
|
27
|
+
# Defined in this file to avoid loading ActiveRecord until explicitly required.
|
|
28
|
+
# The table must be created using the migration provided by this gem.
|
|
29
|
+
#
|
|
30
|
+
# @example Install migrations in a Rails app
|
|
31
|
+
# rails patient_http:install:migrations
|
|
32
|
+
# rails db:migrate
|
|
33
|
+
class Payload < ::ActiveRecord::Base
|
|
34
|
+
self.table_name = "patient_http_payloads"
|
|
35
|
+
self.primary_key = "key"
|
|
36
|
+
|
|
37
|
+
scope :older_than, ->(time) { where(created_at: nil...time) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Class] The ActiveRecord model class used for storage
|
|
41
|
+
attr_reader :model
|
|
42
|
+
|
|
43
|
+
# Initialize a new ActiveRecord store.
|
|
44
|
+
#
|
|
45
|
+
# @param model [Class] ActiveRecord model class to use for storage.
|
|
46
|
+
# Defaults to PatientHttp::PayloadStore::ActiveRecordStore::Payload.
|
|
47
|
+
# Custom models must have: key (string PK), data (text), timestamps
|
|
48
|
+
def initialize(model: nil)
|
|
49
|
+
@model = model || Payload
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Store pre-serialized JSON string directly in the database.
|
|
53
|
+
#
|
|
54
|
+
# @param key [String] Unique key (used as primary key)
|
|
55
|
+
# @param json [String] Pre-serialized JSON string
|
|
56
|
+
# @return [String] The key
|
|
57
|
+
def store_json(key, json)
|
|
58
|
+
now = Time.current
|
|
59
|
+
|
|
60
|
+
@model.with_connection do
|
|
61
|
+
@model.upsert(
|
|
62
|
+
{key: key, data: json, created_at: now, updated_at: now},
|
|
63
|
+
unique_by: :key,
|
|
64
|
+
update_only: [:data, :updated_at]
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
key
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Fetch data from the database.
|
|
72
|
+
#
|
|
73
|
+
# @param key [String] The key to fetch
|
|
74
|
+
# @return [Hash, nil] The stored data or nil if not found
|
|
75
|
+
def fetch(key)
|
|
76
|
+
record = @model.find_by(key: key)
|
|
77
|
+
return nil unless record
|
|
78
|
+
|
|
79
|
+
JSON.parse(record.data)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Delete a payload from the database.
|
|
83
|
+
#
|
|
84
|
+
# Idempotent - does not raise if record doesn't exist.
|
|
85
|
+
#
|
|
86
|
+
# @param key [String] The key to delete
|
|
87
|
+
# @return [Boolean] true
|
|
88
|
+
def delete(key)
|
|
89
|
+
@model.where(key: key).delete_all
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Check if a payload exists.
|
|
94
|
+
#
|
|
95
|
+
# @param key [String] The key to check
|
|
96
|
+
# @return [Boolean] true if the payload exists
|
|
97
|
+
def exists?(key)
|
|
98
|
+
@model.exists?(key: key)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|