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