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