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,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