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,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module PatientHttp
|
|
6
|
+
module PayloadStore
|
|
7
|
+
# Abstract base class for payload stores.
|
|
8
|
+
#
|
|
9
|
+
# Payload stores provide external storage for Request and Response objects
|
|
10
|
+
# that exceed the configured size threshold. This keeps job arguments
|
|
11
|
+
# small while allowing large payloads to be processed.
|
|
12
|
+
#
|
|
13
|
+
# Subclasses must implement the abstract methods: store, fetch, and delete.
|
|
14
|
+
#
|
|
15
|
+
# @example Creating a custom store
|
|
16
|
+
# class MyStore < PatientHttp::PayloadStore::Base
|
|
17
|
+
# register :my_store, self
|
|
18
|
+
#
|
|
19
|
+
# def initialize(connection:)
|
|
20
|
+
# @connection = connection
|
|
21
|
+
# @mutex = Mutex.new
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# def store(key, data)
|
|
25
|
+
# @mutex.synchronize { @connection.set(key, JSON.generate(data)) }
|
|
26
|
+
# key
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# def fetch(key)
|
|
30
|
+
# @mutex.synchronize { JSON.parse(@connection.get(key)) }
|
|
31
|
+
# rescue KeyNotFoundError
|
|
32
|
+
# nil
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# def delete(key)
|
|
36
|
+
# @mutex.synchronize { @connection.delete(key) }
|
|
37
|
+
# true
|
|
38
|
+
# rescue KeyNotFoundError
|
|
39
|
+
# true
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
class Base
|
|
43
|
+
class << self
|
|
44
|
+
# Register a payload store adapter.
|
|
45
|
+
#
|
|
46
|
+
# @param name [Symbol] Unique identifier for this adapter
|
|
47
|
+
# @param klass [Class] The adapter class
|
|
48
|
+
# @return [void]
|
|
49
|
+
def register(name, klass)
|
|
50
|
+
registry_mutex.synchronize do
|
|
51
|
+
registry[name.to_sym] = klass
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Look up a registered adapter by name.
|
|
56
|
+
#
|
|
57
|
+
# @param name [Symbol, String] The adapter name
|
|
58
|
+
# @return [Class, nil] The adapter class or nil if not found
|
|
59
|
+
def lookup(name)
|
|
60
|
+
registry_mutex.synchronize do
|
|
61
|
+
registry[name.to_sym]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Create a new store instance from a registered adapter.
|
|
66
|
+
#
|
|
67
|
+
# @param name [Symbol, String] The adapter name
|
|
68
|
+
# @param options [Hash] Options to pass to the adapter constructor
|
|
69
|
+
# @return [Base] A new store instance
|
|
70
|
+
# @raise [ArgumentError] If the adapter is not registered
|
|
71
|
+
def create(name, **options)
|
|
72
|
+
klass = lookup(name)
|
|
73
|
+
raise ArgumentError, "Unknown payload store adapter: #{name.inspect}" unless klass
|
|
74
|
+
|
|
75
|
+
klass.new(**options)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# List all registered adapter names.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Symbol>] Registered adapter names
|
|
81
|
+
def registered_adapters
|
|
82
|
+
registry_mutex.synchronize do
|
|
83
|
+
registry.keys
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def registry
|
|
90
|
+
@registry ||= {}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def registry_mutex
|
|
94
|
+
@registry_mutex ||= Mutex.new
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Store data with the given key.
|
|
99
|
+
#
|
|
100
|
+
# @param key [String] Unique key for this data
|
|
101
|
+
# @param data [Hash] The data to store (will be serialized as JSON)
|
|
102
|
+
# @return [String] The key
|
|
103
|
+
def store(key, data)
|
|
104
|
+
json = JSON.generate(data)
|
|
105
|
+
store_json(key, json)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Store pre-serialized JSON data with the given key.
|
|
109
|
+
#
|
|
110
|
+
# Subclasses that serialize in #store should override this to write
|
|
111
|
+
# the string directly, avoiding double serialization.
|
|
112
|
+
#
|
|
113
|
+
# @param key [String] Unique key for this data
|
|
114
|
+
# @param json [String] Pre-serialized JSON string
|
|
115
|
+
# @return [String] The key
|
|
116
|
+
# @raise [NotImplementedError] Subclasses must implement this method
|
|
117
|
+
def store_json(key, json)
|
|
118
|
+
raise NotImplementedError, "#{self.class.name} must implement #store_json"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Fetch data by key.
|
|
122
|
+
#
|
|
123
|
+
# @param key [String] The key to fetch
|
|
124
|
+
# @return [Hash, nil] The stored data or nil if not found
|
|
125
|
+
# @raise [NotImplementedError] Subclasses must implement this method
|
|
126
|
+
def fetch(key)
|
|
127
|
+
raise NotImplementedError, "#{self.class.name} must implement #fetch"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Delete data by key.
|
|
131
|
+
#
|
|
132
|
+
# This method should be idempotent - deleting a non-existent key
|
|
133
|
+
# should not raise an error.
|
|
134
|
+
#
|
|
135
|
+
# @param key [String] The key to delete
|
|
136
|
+
# @return [Boolean] true
|
|
137
|
+
# @raise [NotImplementedError] Subclasses must implement this method
|
|
138
|
+
def delete(key)
|
|
139
|
+
raise NotImplementedError, "#{self.class.name} must implement #delete"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Generate a unique key for storing data.
|
|
143
|
+
#
|
|
144
|
+
# @return [String] A UUID key
|
|
145
|
+
def generate_key
|
|
146
|
+
SecureRandom.uuid
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module PatientHttp
|
|
6
|
+
module PayloadStore
|
|
7
|
+
# File-based payload store for testing and development.
|
|
8
|
+
#
|
|
9
|
+
# Stores payloads as JSON files in a directory. This store is intended
|
|
10
|
+
# for local development and testing only - use Redis or S3 stores for
|
|
11
|
+
# production deployments.
|
|
12
|
+
#
|
|
13
|
+
# Thread-safe through mutex synchronization.
|
|
14
|
+
#
|
|
15
|
+
# @example Configuration
|
|
16
|
+
# config.register_payload_store(:files, adapter: :file, directory: "/tmp/payloads")
|
|
17
|
+
class FileStore < Base
|
|
18
|
+
Base.register :file, self
|
|
19
|
+
|
|
20
|
+
# @return [String] The directory where payload files are stored
|
|
21
|
+
attr_reader :directory
|
|
22
|
+
|
|
23
|
+
# Initialize a new file store.
|
|
24
|
+
#
|
|
25
|
+
# @param directory [String] Directory for storing payload files.
|
|
26
|
+
# Defaults to Dir.tmpdir. Will be created if it doesn't exist.
|
|
27
|
+
def initialize(directory: nil)
|
|
28
|
+
@directory = directory || Dir.tmpdir
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
FileUtils.mkdir_p(@directory)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Store pre-serialized JSON string directly to a file.
|
|
34
|
+
#
|
|
35
|
+
# @param key [String] Unique key (used as filename)
|
|
36
|
+
# @param json [String] Pre-serialized JSON string
|
|
37
|
+
# @return [String] The key
|
|
38
|
+
def store_json(key, json)
|
|
39
|
+
path = file_path(key)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
File.write(path, json)
|
|
42
|
+
end
|
|
43
|
+
key
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fetch data from a JSON file.
|
|
47
|
+
#
|
|
48
|
+
# @param key [String] The key to fetch
|
|
49
|
+
# @return [Hash, nil] The stored data or nil if not found
|
|
50
|
+
def fetch(key)
|
|
51
|
+
path = file_path(key)
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
return nil unless File.exist?(path)
|
|
54
|
+
|
|
55
|
+
JSON.parse(File.read(path))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Delete a payload file.
|
|
60
|
+
#
|
|
61
|
+
# Idempotent - does not raise if file doesn't exist.
|
|
62
|
+
#
|
|
63
|
+
# @param key [String] The key to delete
|
|
64
|
+
# @return [Boolean] true
|
|
65
|
+
def delete(key)
|
|
66
|
+
path = file_path(key)
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
File.delete(path) if File.exist?(path)
|
|
69
|
+
end
|
|
70
|
+
true
|
|
71
|
+
rescue Errno::ENOENT
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if a payload exists.
|
|
76
|
+
#
|
|
77
|
+
# @param key [String] The key to check
|
|
78
|
+
# @return [Boolean] true if the payload exists
|
|
79
|
+
def exists?(key)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
File.exist?(file_path(key))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def file_path(key)
|
|
88
|
+
File.join(@directory, "#{key}.json")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module PayloadStore
|
|
5
|
+
# Redis-based payload store for production deployments.
|
|
6
|
+
#
|
|
7
|
+
# Stores payloads as JSON strings in Redis. This store is recommended
|
|
8
|
+
# for production environments where multiple processes need to share
|
|
9
|
+
# payload data.
|
|
10
|
+
#
|
|
11
|
+
# Thread-safe: Redis clients handle their own thread safety.
|
|
12
|
+
#
|
|
13
|
+
# @example Configuration with direct Redis client
|
|
14
|
+
# redis = RedisClient.new(url: ENV["REDIS_URL"])
|
|
15
|
+
# config.register_payload_store(:redis, adapter: :redis, redis: redis, ttl: 86400)
|
|
16
|
+
class RedisStore < Base
|
|
17
|
+
Base.register :redis, self
|
|
18
|
+
|
|
19
|
+
# @return [String] The key prefix used for all stored payloads
|
|
20
|
+
attr_reader :key_prefix
|
|
21
|
+
|
|
22
|
+
# @return [Float, nil] TTL in seconds for stored payloads
|
|
23
|
+
attr_reader :ttl
|
|
24
|
+
|
|
25
|
+
# Initialize a new Redis store.
|
|
26
|
+
#
|
|
27
|
+
# @param redis [Object] Redis client instance. Required.
|
|
28
|
+
# @param ttl [Float, nil] Time-to-live in seconds for stored payloads.
|
|
29
|
+
# Supports fractional seconds (e.g., 0.5 for 500ms). If nil, payloads do not expire.
|
|
30
|
+
# @param key_prefix [String] Prefix for all Redis keys.
|
|
31
|
+
# Defaults to "patient_http:payloads:"
|
|
32
|
+
# @raise [ArgumentError] If redis client is not provided
|
|
33
|
+
def initialize(redis:, ttl: nil, key_prefix: nil)
|
|
34
|
+
raise ArgumentError, "redis client is required" unless redis
|
|
35
|
+
|
|
36
|
+
@redis = redis
|
|
37
|
+
@ttl = ttl
|
|
38
|
+
@key_prefix = key_prefix || "patient_http:payloads:"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Store pre-serialized JSON string directly in Redis.
|
|
42
|
+
#
|
|
43
|
+
# @param key [String] Unique key (appended to key_prefix)
|
|
44
|
+
# @param json [String] Pre-serialized JSON string
|
|
45
|
+
# @return [String] The key
|
|
46
|
+
def store_json(key, json)
|
|
47
|
+
full_key = key_with_prefix(key)
|
|
48
|
+
|
|
49
|
+
if @ttl
|
|
50
|
+
ttl_ms = (@ttl * 1000).round
|
|
51
|
+
@redis.set(full_key, json, px: ttl_ms)
|
|
52
|
+
else
|
|
53
|
+
@redis.set(full_key, json)
|
|
54
|
+
end
|
|
55
|
+
key
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Fetch data from Redis.
|
|
59
|
+
#
|
|
60
|
+
# @param key [String] The key to fetch
|
|
61
|
+
# @return [Hash, nil] The stored data or nil if not found
|
|
62
|
+
def fetch(key)
|
|
63
|
+
full_key = key_with_prefix(key)
|
|
64
|
+
json = @redis.get(full_key)
|
|
65
|
+
return nil if json.nil?
|
|
66
|
+
|
|
67
|
+
JSON.parse(json)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Delete a payload from Redis.
|
|
71
|
+
#
|
|
72
|
+
# Idempotent - does not raise if key doesn't exist.
|
|
73
|
+
#
|
|
74
|
+
# @param key [String] The key to delete
|
|
75
|
+
# @return [Boolean] true
|
|
76
|
+
def delete(key)
|
|
77
|
+
full_key = key_with_prefix(key)
|
|
78
|
+
@redis.del(full_key)
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if a payload exists.
|
|
83
|
+
#
|
|
84
|
+
# @param key [String] The key to check
|
|
85
|
+
# @return [Boolean] true if the payload exists
|
|
86
|
+
def exists?(key)
|
|
87
|
+
full_key = key_with_prefix(key)
|
|
88
|
+
@redis.exists(full_key) > 0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def key_with_prefix(key)
|
|
94
|
+
"#{@key_prefix}#{key}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "aws-sdk-s3"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
raise LoadError, "The aws-sdk-s3 gem is required to use S3Store. Add it to your Gemfile: gem 'aws-sdk-s3'"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module PatientHttp
|
|
10
|
+
module PayloadStore
|
|
11
|
+
# S3-based payload store for production deployments.
|
|
12
|
+
#
|
|
13
|
+
# Stores payloads as JSON objects in S3. This store is recommended
|
|
14
|
+
# for production environments where payloads need durable storage
|
|
15
|
+
# and can be shared across multiple processes/instances.
|
|
16
|
+
#
|
|
17
|
+
# Thread-safe: S3 clients handle their own thread safety.
|
|
18
|
+
#
|
|
19
|
+
# @example Configuration with S3 bucket
|
|
20
|
+
# s3 = Aws::S3::Resource.new
|
|
21
|
+
# bucket = s3.bucket("my-payloads-bucket")
|
|
22
|
+
# config.register_payload_store(:s3, adapter: :s3, bucket: bucket)
|
|
23
|
+
class S3Store < Base
|
|
24
|
+
Base.register :s3, self
|
|
25
|
+
|
|
26
|
+
# @return [String] The key prefix used for all stored payloads
|
|
27
|
+
attr_reader :key_prefix
|
|
28
|
+
|
|
29
|
+
# Initialize a new S3 store.
|
|
30
|
+
#
|
|
31
|
+
# @param bucket [Aws::S3::Bucket] S3 Bucket object. Required.
|
|
32
|
+
# @param key_prefix [String] Prefix for all S3 object keys.
|
|
33
|
+
# Defaults to "patient_http/payloads/"
|
|
34
|
+
# @raise [ArgumentError] If bucket is not provided
|
|
35
|
+
def initialize(bucket:, key_prefix: nil)
|
|
36
|
+
raise ArgumentError, "S3 bucket is required" unless bucket
|
|
37
|
+
|
|
38
|
+
@bucket = bucket
|
|
39
|
+
@key_prefix = key_prefix || "patient_http/payloads/"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Store pre-serialized JSON string directly in S3.
|
|
43
|
+
#
|
|
44
|
+
# @param key [String] Unique key (appended to key_prefix)
|
|
45
|
+
# @param json [String] Pre-serialized JSON string
|
|
46
|
+
# @return [String] The key
|
|
47
|
+
def store_json(key, json)
|
|
48
|
+
full_key = key_with_prefix(key)
|
|
49
|
+
@bucket.object(full_key).put(body: json, content_type: "application/json")
|
|
50
|
+
key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Fetch data from S3.
|
|
54
|
+
#
|
|
55
|
+
# @param key [String] The key to fetch
|
|
56
|
+
# @return [Hash, nil] The stored data or nil if not found
|
|
57
|
+
def fetch(key)
|
|
58
|
+
full_key = key_with_prefix(key)
|
|
59
|
+
response = @bucket.object(full_key).get
|
|
60
|
+
|
|
61
|
+
JSON.parse(response.body.read)
|
|
62
|
+
rescue Aws::S3::Errors::NoSuchKey
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Delete a payload from S3.
|
|
67
|
+
#
|
|
68
|
+
# Idempotent - does not raise if object doesn't exist.
|
|
69
|
+
#
|
|
70
|
+
# @param key [String] The key to delete
|
|
71
|
+
# @return [Boolean] true
|
|
72
|
+
def delete(key)
|
|
73
|
+
full_key = key_with_prefix(key)
|
|
74
|
+
@bucket.object(full_key).delete
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if a payload exists.
|
|
79
|
+
#
|
|
80
|
+
# @param key [String] The key to check
|
|
81
|
+
# @return [Boolean] true if the payload exists
|
|
82
|
+
def exists?(key)
|
|
83
|
+
full_key = key_with_prefix(key)
|
|
84
|
+
@bucket.object(full_key).exists?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def key_with_prefix(key)
|
|
90
|
+
"#{@key_prefix}#{key}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module PayloadStore
|
|
5
|
+
autoload :Base, File.join(__dir__, "payload_store/base")
|
|
6
|
+
autoload :ActiveRecordStore, File.join(__dir__, "payload_store/active_record_store")
|
|
7
|
+
autoload :FileStore, File.join(__dir__, "payload_store/file_store")
|
|
8
|
+
autoload :RedisStore, File.join(__dir__, "payload_store/redis_store")
|
|
9
|
+
autoload :S3Store, File.join(__dir__, "payload_store/s3_store")
|
|
10
|
+
end
|
|
11
|
+
end
|