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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # Mixin that provides a compact API for scheduling async HTTP requests.
5
+ #
6
+ # Include this module in your class to get instance-level and class-level helpers for building
7
+ # requests and dispatching them through a registered handler.
8
+ #
9
+ # This module allows you to use the same interface for making HTTP requests while swapping out
10
+ # the underlying queueing mechanism for handling responses asynchronously. By registering a
11
+ # custom handler, you can integrate with any job queue system (Sidekiq, Solid Queue, etc.)
12
+ # without changing your application code that makes HTTP requests. This decouples your request
13
+ # interface from your async processing infrastructure.
14
+ #
15
+ # The common workflow is:
16
+ # 1. Register a global request handler with {PatientHttp.register_handler}.
17
+ # 2. Include this module in a class.
18
+ # 3. Optionally configure defaults with {.request_template}.
19
+ # 4. Call `async_get`, `async_post`, `async_put`, `async_patch`, `async_delete`, or
20
+ # `async_request`.
21
+ #
22
+ # @example Register a handler
23
+ # PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
24
+ # # Dispatch the request through your app-specific task/enqueue operation
25
+ # # and return the request id
26
+ # end
27
+ #
28
+ # @example Include in a class and enqueue requests
29
+ # class ApiClient
30
+ # include PatientHttp::RequestHelper
31
+ #
32
+ # request_template base_url: "https://api.example.com", headers: {"Authorization" => "Bearer token"}
33
+ #
34
+ # def fetch_user(user_id)
35
+ # async_get("/users/#{user_id}", callback: UserCallback, callback_args: {"user_id" => user_id})
36
+ # end
37
+ # end
38
+ module RequestHelper
39
+ extend self
40
+
41
+ class << self
42
+ # Hooks helper behavior into the including class.
43
+ #
44
+ # Extends the class with {.ClassMethods} and initializes template storage.
45
+ #
46
+ # @param base [Class] class including this module
47
+ # @return [void]
48
+ def included(base)
49
+ base.extend(ClassMethods)
50
+ base.instance_variable_set(:@patient_http_request_template, nil)
51
+ end
52
+ end
53
+
54
+ module HttpMethodHelpers
55
+ # Enqueues an asynchronous HTTP GET request.
56
+ #
57
+ # @param uri [String] absolute URL or path (when using a request template)
58
+ # @param callback [Class, String] callback class to handle the response
59
+ # @param kwargs [Hash] forwarded to `async_request`
60
+ # @return [Object] return value from the registered request handler
61
+ def async_get(uri, callback:, **kwargs)
62
+ async_request(:get, uri, callback: callback, **kwargs)
63
+ end
64
+
65
+ # Enqueues an asynchronous HTTP POST request.
66
+ #
67
+ # @param uri [String] absolute URL or path (when using a request template)
68
+ # @param callback [Class, String] callback class to handle the response
69
+ # @param kwargs [Hash] forwarded to `async_request`
70
+ # @return [Object] return value from the registered request handler
71
+ def async_post(uri, callback:, **kwargs)
72
+ async_request(:post, uri, callback: callback, **kwargs)
73
+ end
74
+
75
+ # Enqueues an asynchronous HTTP PUT request.
76
+ #
77
+ # @param uri [String] absolute URL or path (when using a request template)
78
+ # @param callback [Class, String] callback class to handle the response
79
+ # @param kwargs [Hash] forwarded to `async_request`
80
+ # @return [Object] return value from the registered request handler
81
+ def async_put(uri, callback:, **kwargs)
82
+ async_request(:put, uri, callback: callback, **kwargs)
83
+ end
84
+
85
+ # Enqueues an asynchronous HTTP PATCH request.
86
+ #
87
+ # @param uri [String] absolute URL or path (when using a request template)
88
+ # @param callback [Class, String] callback class to handle the response
89
+ # @param kwargs [Hash] forwarded to `async_request`
90
+ # @return [Object] return value from the registered request handler
91
+ def async_patch(uri, callback:, **kwargs)
92
+ async_request(:patch, uri, callback: callback, **kwargs)
93
+ end
94
+
95
+ # Enqueues an asynchronous HTTP DELETE request.
96
+ #
97
+ # @param uri [String] absolute URL or path (when using a request template)
98
+ # @param callback [Class, String] callback class to handle the response
99
+ # @param kwargs [Hash] forwarded to `async_request`
100
+ # @return [Object] return value from the registered request handler
101
+ def async_delete(uri, callback:, **kwargs)
102
+ async_request(:delete, uri, callback: callback, **kwargs)
103
+ end
104
+ end
105
+
106
+ module ClassMethods
107
+ include HttpMethodHelpers
108
+
109
+ # Defines a default request template for this class.
110
+ #
111
+ # Requests created with the helper methods merge these defaults unless explicitly overridden.
112
+ #
113
+ # @param base_url [String, nil] optional base URL used to resolve relative request URLs
114
+ # @param headers [Hash] default headers for requests
115
+ # @param params [Hash, nil] default query parameters for requests
116
+ # @param timeout [Float] default timeout in seconds
117
+ # @return [void]
118
+ def request_template(base_url: nil, headers: {}, params: nil, timeout: 30)
119
+ @patient_http_request_template = RequestTemplate.new(
120
+ base_url: base_url,
121
+ headers: headers,
122
+ params: params,
123
+ timeout: timeout
124
+ )
125
+ end
126
+
127
+ # Builds and dispatches an asynchronous HTTP request.
128
+ #
129
+ # When a request template is configured, the request is built from the template. Otherwise,
130
+ # it is built directly from the provided arguments.
131
+ #
132
+ # @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
133
+ # @param url [String] absolute URL or path (when using a request template)
134
+ # @param callback [Class, String] callback class to handle the response
135
+ # @param headers [Hash, nil] request headers
136
+ # @param body [String, nil] raw request body
137
+ # @param json [Hash, Array, nil] JSON payload encoded by the request layer
138
+ # @param params [Hash, nil] query parameters
139
+ # @param timeout [Numeric, nil] timeout in seconds for this request
140
+ # @param raise_error_responses [Boolean, nil] when true, non-success responses are
141
+ # reported as errors
142
+ # @param callback_args [Hash, nil] JSON-compatible callback arguments
143
+ # @return [Object] return value from the registered request handler
144
+ def async_request(
145
+ method,
146
+ url,
147
+ callback:,
148
+ headers: nil,
149
+ body: nil,
150
+ json: nil,
151
+ params: nil,
152
+ timeout: nil,
153
+ raise_error_responses: nil,
154
+ callback_args: nil
155
+ )
156
+ template = async_request_template
157
+ kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout}
158
+ request = if template
159
+ template.request(method, url, **kwargs)
160
+ else
161
+ Request.new(method, url, **kwargs)
162
+ end
163
+
164
+ PatientHttp.execute(
165
+ request: request,
166
+ callback: callback,
167
+ callback_args: callback_args,
168
+ raise_error_responses: raise_error_responses
169
+ )
170
+ end
171
+
172
+ # Returns the RequestTemplate defined for this class or its ancestors, or nil if none
173
+ # is defined. This allows subclasses to inherit the request template from their parent
174
+ # class if they don't define their own.
175
+ #
176
+ # @return [RequestTemplate, nil] the request template for this class or its ancestors
177
+ # @api private
178
+ def async_request_template
179
+ return @patient_http_request_template if @patient_http_request_template
180
+ return superclass.async_request_template if superclass.include?(PatientHttp::RequestHelper)
181
+
182
+ nil
183
+ end
184
+ end
185
+
186
+ # Dispatches an asynchronous HTTP request from an instance context.
187
+ #
188
+ # This delegates to {.ClassMethods#async_request} on the including class.
189
+ #
190
+ # @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
191
+ # @param url [String] absolute URL or path (when using a request template)
192
+ # @param callback [Class, String] callback class to handle the response
193
+ # @param headers [Hash, nil] request headers
194
+ # @param body [String, nil] raw request body
195
+ # @param json [Hash, Array, nil] JSON payload encoded by the request layer
196
+ # @param params [Hash, nil] query parameters
197
+ # @param timeout [Numeric, nil] timeout in seconds for this request
198
+ # @param raise_error_responses [Boolean, nil] when true, non-success responses are
199
+ # reported as errors
200
+ # @param callback_args [Hash, nil] JSON-compatible callback arguments
201
+ # @return [Object] return value from the registered request handler
202
+ def async_request(
203
+ method,
204
+ url,
205
+ callback:,
206
+ headers: nil,
207
+ body: nil,
208
+ json: nil,
209
+ params: nil,
210
+ timeout: nil,
211
+ raise_error_responses: nil,
212
+ callback_args: nil
213
+ )
214
+ self.class.async_request(
215
+ method,
216
+ url,
217
+ callback: callback,
218
+ headers: headers,
219
+ body: body,
220
+ json: json,
221
+ params: params,
222
+ timeout: timeout,
223
+ raise_error_responses: raise_error_responses,
224
+ callback_args: callback_args
225
+ )
226
+ end
227
+
228
+ include HttpMethodHelpers
229
+ end
230
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # A wrapper around {Request} that includes callback and job context for the Processor.
5
+ # This class allows HTTP requests to be enqueued and processed asynchronously,
6
+ # tracking their lifecycle and providing methods to handle success and error callbacks.
7
+ class RequestTask
8
+ include TimeHelper
9
+
10
+ # Headers that are sensitive to origin and should be stripped on cross-origin redirects
11
+ SENSITIVE_HEADERS = %w[authorization cookie].freeze
12
+
13
+ # @return [String] Unique UUID for tracking the task
14
+ attr_reader :id
15
+
16
+ # @return [Request] The HTTP request details
17
+ attr_reader :request
18
+
19
+ # @return [TaskHandler] The handler for job lifecycle operations
20
+ attr_reader :task_handler
21
+
22
+ # @return [String] Class name for the callback service
23
+ attr_reader :callback
24
+
25
+ # @return [Hash] Callback arguments to include in Response/Error objects (never nil, defaults to empty hash)
26
+ attr_reader :callback_args
27
+
28
+ # @return [Boolean] Whether to raise HttpError for non-2xx responses
29
+ attr_reader :raise_error_responses
30
+
31
+ # @return [Array<String>] URLs visited during redirect chain
32
+ attr_reader :redirects
33
+
34
+ # @return [Response, nil] The HTTP response, set on success
35
+ attr_reader :response
36
+
37
+ # @return [Exception, nil] The error, set on failure
38
+ attr_reader :error
39
+
40
+ # Initializes a new RequestTask.
41
+ #
42
+ # @param request [Request] The HTTP request to wrap.
43
+ # @param task_handler [TaskHandler] The handler for job lifecycle operations.
44
+ # @param callback [String, Class] Class name or class for the callback service.
45
+ # @param callback_args [Hash] Callback arguments (with string keys) to include
46
+ # in Response/Error objects. These will be accessible via response.callback_args
47
+ # or error.callback_args.
48
+ # @param raise_error_responses [Boolean] Whether to raise HttpError for non-2xx responses.
49
+ # @param redirects [Array<String>] URLs visited during redirect chain.
50
+ # @param id [String, nil] Unique UUID for tracking the task. If nil, a new UUID will be generated.
51
+ # @param default_max_redirects [Integer] Fallback max_redirects when request doesn't specify one.
52
+ def initialize(
53
+ request:,
54
+ task_handler:,
55
+ callback:,
56
+ callback_args: {},
57
+ raise_error_responses: false,
58
+ redirects: [],
59
+ id: nil,
60
+ default_max_redirects: 5
61
+ )
62
+ @id = id&.to_s || SecureRandom.uuid
63
+ @request = request
64
+ @task_handler = task_handler
65
+ @callback = callback.is_a?(Class) ? callback.name : callback.to_s
66
+ @callback_args = CallbackValidator.validate_callback_args(callback_args) || {}
67
+ @raise_error_responses = raise_error_responses
68
+ @redirects = redirects || []
69
+ @default_max_redirects = default_max_redirects
70
+
71
+ @enqueued_at = nil
72
+ @started_at = nil
73
+ @completed_at = nil
74
+ @response = nil
75
+ @error = nil
76
+
77
+ raise ArgumentError, "request is required" unless @request
78
+ raise ArgumentError, "task_handler is required" unless @task_handler
79
+ raise ArgumentError, "callback is required" if @callback.nil? || @callback.empty?
80
+ CallbackValidator.validate!(@callback)
81
+ end
82
+
83
+ # Mark task as enqueued
84
+ # @return [void]
85
+ def enqueued!
86
+ @enqueued_at = monotonic_time
87
+ end
88
+
89
+ # Mark task as started
90
+ # @return [void]
91
+ def started!
92
+ @started_at = monotonic_time
93
+ end
94
+
95
+ # Returns the wall clock time when the task was enqueued.
96
+ #
97
+ # @return [Time, nil] The enqueued time or nil if not enqueued.
98
+ def enqueued_at
99
+ wall_clock_time(@enqueued_at) if @enqueued_at
100
+ end
101
+
102
+ # Returns the wall clock time when the task was started.
103
+ #
104
+ # @return [Time, nil] The started time or nil if not started.
105
+ def started_at
106
+ wall_clock_time(@started_at) if @started_at
107
+ end
108
+
109
+ # Returns the wall clock time when the task was completed.
110
+ #
111
+ # @return [Time, nil] The completed time or nil if not completed.
112
+ def completed_at
113
+ wall_clock_time(@completed_at) if @completed_at
114
+ end
115
+
116
+ # Enqueued duration in seconds.
117
+ # @return [Float, nil] duration or nil if not enqueued yet.
118
+ def enqueued_duration
119
+ return nil unless @enqueued_at
120
+
121
+ (@started_at || monotonic_time) - @enqueued_at
122
+ end
123
+
124
+ # Execution duration in seconds.
125
+ # @return [Float, nil] duration or nil if not started yet.
126
+ def duration
127
+ return nil unless @started_at
128
+
129
+ ((@completed_at || monotonic_time) - @started_at).round(9)
130
+ end
131
+
132
+ # Re-enqueue the original job via the task handler.
133
+ # @return [String] job ID
134
+ def retry
135
+ @task_handler.retry
136
+ end
137
+
138
+ # Called with the HTTP response on a completed request. Note that
139
+ # the response may represent an HTTP error (4xx or 5xx status).
140
+ #
141
+ # @param response [Response] the HTTP response
142
+ # @return [void]
143
+ def completed!(response)
144
+ @completed_at = monotonic_time
145
+ @response = response
146
+
147
+ @task_handler.on_complete(response, @callback)
148
+ end
149
+
150
+ # Called with the HTTP error on a failed request.
151
+ #
152
+ # @param exception [Exception] the error that occurred
153
+ # @return [void]
154
+ def error!(exception)
155
+ @completed_at = monotonic_time
156
+ @error = exception
157
+
158
+ wrapped_error = exception
159
+ unless wrapped_error.is_a?(Error)
160
+ wrapped_error = RequestError.from_exception(
161
+ exception,
162
+ request_id: @id,
163
+ duration: duration,
164
+ url: request.url,
165
+ http_method: request.http_method,
166
+ callback_args: @callback_args
167
+ )
168
+ end
169
+
170
+ @task_handler.on_error(wrapped_error, @callback)
171
+ end
172
+
173
+ # Return true if the task successfully received a response from the server.
174
+ # Note that the response may represent an HTTP error (4xx or 5xx status).
175
+ #
176
+ # @return [Boolean]
177
+ def success?
178
+ !@response.nil?
179
+ end
180
+
181
+ # Return true if an error was raised during the request.
182
+ #
183
+ # @return [Boolean]
184
+ def error?
185
+ !@error.nil?
186
+ end
187
+
188
+ # Returns the maximum number of redirects to follow.
189
+ # Uses the request's max_redirects if set, otherwise falls back to the default.
190
+ #
191
+ # @return [Integer] maximum number of redirects
192
+ def max_redirects
193
+ request.max_redirects || @default_max_redirects
194
+ end
195
+
196
+ # Create a new RequestTask for following a redirect.
197
+ #
198
+ # @param location [String] The redirect URL from the Location header
199
+ # @param status [Integer] The HTTP status code of the redirect response
200
+ # @return [RequestTask] A new task configured for the redirect
201
+ def redirect_task(location:, status:)
202
+ # Determine the HTTP method and body for the redirect
203
+ # 301, 302, 303: Convert to GET (no body) - standard browser behavior
204
+ # 307, 308: Preserve original method and body
205
+ if [301, 302, 303].include?(status)
206
+ redirect_method = :get
207
+ redirect_body = nil
208
+ else
209
+ redirect_method = request.http_method
210
+ redirect_body = request.body
211
+ end
212
+
213
+ # Resolve the redirect URL (handle relative URLs)
214
+ redirect_url = resolve_redirect_url(location)
215
+
216
+ # Strip sensitive headers on cross-origin redirects to prevent credential leakage
217
+ redirect_headers = if cross_origin?(request.url, redirect_url)
218
+ request.headers.except(*SENSITIVE_HEADERS)
219
+ else
220
+ request.headers
221
+ end
222
+
223
+ # Create a new request for the redirect
224
+ redirect_request = Request.new(
225
+ redirect_method,
226
+ redirect_url,
227
+ headers: redirect_headers,
228
+ body: redirect_body,
229
+ timeout: request.timeout,
230
+ max_redirects: request.max_redirects
231
+ )
232
+
233
+ redirect_task_id = "#{id.split("/").first}/#{@redirects.size + 2}"
234
+
235
+ # Create the new task with updated redirects chain
236
+ self.class.new(
237
+ request: redirect_request,
238
+ task_handler: @task_handler,
239
+ callback: @callback,
240
+ callback_args: @callback_args,
241
+ raise_error_responses: @raise_error_responses,
242
+ redirects: @redirects + [request.url],
243
+ id: redirect_task_id,
244
+ default_max_redirects: @default_max_redirects
245
+ )
246
+ end
247
+
248
+ # Build a Response object from async response data.
249
+ #
250
+ # @param status [Integer] HTTP status code
251
+ # @param headers [Hash] HTTP response headers
252
+ # @param body [String, nil] HTTP response body
253
+ # @return [Response] the response object
254
+ # @api private
255
+ def build_response(status:, headers:, body:)
256
+ original_id = id.split("/").first
257
+
258
+ Response.new(
259
+ status: status,
260
+ headers: headers,
261
+ body: body,
262
+ duration: duration,
263
+ request_id: original_id,
264
+ url: request.url,
265
+ http_method: request.http_method,
266
+ callback_args: @callback_args,
267
+ redirects: @redirects
268
+ )
269
+ end
270
+
271
+ # Get the id of the first request task before any redirects. This is useful for tracking
272
+ # the overall request across multiple redirect tasks.
273
+ #
274
+ # @return [String] the original request id
275
+ def original_id
276
+ id.split("/").first
277
+ end
278
+
279
+ private
280
+
281
+ # Check if two URLs have different origins (scheme + host + port).
282
+ #
283
+ # @param original_url [String] The original request URL
284
+ # @param target_url [String] The redirect target URL
285
+ # @return [Boolean] true if the origins differ
286
+ def cross_origin?(original_url, target_url)
287
+ original = URI.parse(original_url)
288
+ target = URI.parse(target_url)
289
+
290
+ original.scheme != target.scheme ||
291
+ original.host != target.host ||
292
+ original.port != target.port
293
+ end
294
+
295
+ # Resolve a redirect URL, handling relative URLs.
296
+ #
297
+ # @param location [String] The Location header value
298
+ # @return [String] The resolved absolute URL
299
+ def resolve_redirect_url(location)
300
+ base_uri = URI.parse(request.url)
301
+ redirect_uri = URI.parse(location)
302
+
303
+ return location if redirect_uri.absolute?
304
+
305
+ base_uri.merge(redirect_uri).to_s
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # The RequestTemplate is used to build HTTP requests with shared configuration.
5
+ #
6
+ # Use RequestTemplate when you need to make multiple requests to the same API with shared
7
+ # configuration (base URL, headers, timeout).
8
+ #
9
+ # @example Basic usage
10
+ # template = PatientHttp::RequestTemplate.new(
11
+ # base_url: "https://api.example.com",
12
+ # headers: {"Authorization" => "Bearer token"},
13
+ # timeout: 60
14
+ # )
15
+ # request = template.get("/users/123")
16
+ #
17
+ # The RequestTemplate handles building HTTP requests with proper URL joining, header merging,
18
+ # and parameter encoding.
19
+ class RequestTemplate
20
+ # @return [String, URI::HTTP, nil] Base URL for relative URIs
21
+ attr_accessor :base_url
22
+
23
+ # @return [HttpHeaders] Default headers for all requests
24
+ attr_accessor :headers
25
+
26
+ # @return [Float] Default request timeout in seconds
27
+ attr_accessor :timeout
28
+
29
+ # Initializes a new RequestTemplate.
30
+ #
31
+ # @param base_url [String, URI::HTTP, nil] Base URL for relative URIs
32
+ # @param headers [Hash] Default headers for all requests
33
+ # @param params [Hash, nil] Default query parameters to add to all requests
34
+ # @param timeout [Float] Default request timeout in seconds
35
+ def initialize(base_url: nil, headers: {}, params: nil, timeout: 30)
36
+ @base_url = base_url
37
+ @headers = HttpHeaders.new(headers)
38
+ @params = params
39
+ @timeout = timeout
40
+ end
41
+
42
+ # Build an async HTTP request. Returns a Request object.
43
+ #
44
+ # @param method [Symbol] HTTP method (:get, :post, :put, :patch, :delete)
45
+ # @param uri [String, URI::HTTP] URI path to request (joined with base_url if relative)
46
+ # @param body [String, nil] request body
47
+ # @param json [Object, nil] JSON object to serialize (cannot use with body)
48
+ # @param headers [Hash] additional headers to merge with client headers
49
+ # @param params [Hash, nil] query parameters to add to URL
50
+ # @return [Request] request object
51
+ def request(method, uri, body: nil, json: nil, headers: nil, params: nil, timeout: nil)
52
+ full_uri = @base_url ? URI.join(@base_url, uri.to_s) : URI(uri)
53
+
54
+ merged_headers = headers&.any? ? @headers.merge(headers) : @headers
55
+ merged_params = @params ? (@params.merge(params || {})) : params
56
+
57
+ # Create request with all parameters
58
+ Request.new(
59
+ method,
60
+ full_uri.to_s,
61
+ headers: merged_headers.to_h,
62
+ body: body,
63
+ json: json,
64
+ params: merged_params,
65
+ timeout: timeout || @timeout
66
+ )
67
+ end
68
+
69
+ # Convenience method for GET requests.
70
+ #
71
+ # @param uri [String, URI::HTTP] URI path to request
72
+ # @param kwargs [Hash] additional options (see #request)
73
+ # @return [Request] request object
74
+ def get(uri, **kwargs)
75
+ request(:get, uri, **kwargs)
76
+ end
77
+
78
+ # Convenience method for POST requests.
79
+ #
80
+ # @param uri [String, URI::HTTP] URI path to request
81
+ # @param kwargs [Hash] additional options (see #request)
82
+ # @return [Request] request object
83
+ def post(uri, **kwargs)
84
+ request(:post, uri, **kwargs)
85
+ end
86
+
87
+ # Convenience method for PUT requests.
88
+ #
89
+ # @param uri [String, URI::HTTP] URI path to request
90
+ # @param kwargs [Hash] additional options (see #request)
91
+ # @return [Request] request object
92
+ def put(uri, **kwargs)
93
+ request(:put, uri, **kwargs)
94
+ end
95
+
96
+ # Convenience method for PATCH requests.
97
+ #
98
+ # @param uri [String, URI::HTTP] URI path to request
99
+ # @param kwargs [Hash] additional options (see #request)
100
+ # @return [Request] request object
101
+ def patch(uri, **kwargs)
102
+ request(:patch, uri, **kwargs)
103
+ end
104
+
105
+ # Convenience method for DELETE requests.
106
+ #
107
+ # @param uri [String, URI::HTTP] URI path to request
108
+ # @param kwargs [Hash] additional options (see #request)
109
+ # @return [Request] request object
110
+ def delete(uri, **kwargs)
111
+ request(:delete, uri, **kwargs)
112
+ end
113
+ end
114
+ end