patient_http 1.4.0 → 1.6.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 +4 -4
- data/ARCHITECTURE.md +19 -7
- data/CHANGELOG.md +43 -0
- data/README.md +68 -7
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +27 -2
- data/lib/patient_http/client_pool.rb +14 -7
- data/lib/patient_http/completion_executor.rb +139 -0
- data/lib/patient_http/configuration.rb +79 -1
- data/lib/patient_http/outgoing_request.rb +1 -1
- data/lib/patient_http/payload.rb +37 -15
- data/lib/patient_http/processor.rb +285 -95
- data/lib/patient_http/processor_observer.rb +36 -3
- data/lib/patient_http/redirect_helper.rb +84 -1
- data/lib/patient_http/request.rb +64 -6
- data/lib/patient_http/request_helper.rb +63 -10
- data/lib/patient_http/request_preparer.rb +7 -0
- data/lib/patient_http/request_task.rb +26 -12
- data/lib/patient_http/request_template.rb +46 -4
- data/lib/patient_http/response_reader.rb +233 -19
- data/lib/patient_http/synchronous_executor.rb +12 -71
- data/lib/patient_http.rb +49 -5
- metadata +3 -2
|
@@ -2,10 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
module PatientHttp
|
|
4
4
|
# Shared redirect-checking logic used by both the async Processor
|
|
5
|
-
# and the SynchronousExecutor.
|
|
5
|
+
# and the SynchronousExecutor. Including classes must expose the
|
|
6
|
+
# active {Configuration} as `@config`.
|
|
6
7
|
#
|
|
7
8
|
# @api private
|
|
8
9
|
module RedirectHelper
|
|
10
|
+
class << self
|
|
11
|
+
# Determine the HTTP method to use when following a redirect.
|
|
12
|
+
#
|
|
13
|
+
# The rules follow RFC 9110 and the WHATWG Fetch standard:
|
|
14
|
+
#
|
|
15
|
+
# - 301 and 302 change POST to GET; every other method is preserved.
|
|
16
|
+
# The QUERY specification states this POST exception does not apply
|
|
17
|
+
# to QUERY, so a QUERY is re-sent as a QUERY.
|
|
18
|
+
# - 303 preserves GET and HEAD; every other method becomes GET.
|
|
19
|
+
# - 300, 307, and 308 preserve the method.
|
|
20
|
+
#
|
|
21
|
+
# @param http_method [Symbol] the current request method
|
|
22
|
+
# @param status [Integer] the redirect status code
|
|
23
|
+
# @return [Symbol] the method for the redirected request
|
|
24
|
+
def redirect_method(http_method, status)
|
|
25
|
+
case status
|
|
26
|
+
when 301, 302
|
|
27
|
+
(http_method == :post) ? :get : http_method
|
|
28
|
+
when 303
|
|
29
|
+
%i[get head].include?(http_method) ? http_method : :get
|
|
30
|
+
else
|
|
31
|
+
http_method
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if following a redirect requires changing the request method.
|
|
36
|
+
#
|
|
37
|
+
# @param http_method [Symbol] the current request method
|
|
38
|
+
# @param status [Integer] the redirect status code
|
|
39
|
+
# @return [Boolean] true if the method must change to follow the redirect
|
|
40
|
+
def method_change_required?(http_method, status)
|
|
41
|
+
redirect_method(http_method, status) != http_method
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Normalize header names used to strip headers from redirected requests.
|
|
45
|
+
# Names are downcased so they match header names case insensitively.
|
|
46
|
+
#
|
|
47
|
+
# @param names [String, Symbol, Array<String, Symbol>, nil] header names
|
|
48
|
+
# @return [Array<String>] frozen lowercase header names
|
|
49
|
+
# @raise [ArgumentError] if a name is not a string or symbol, or is empty
|
|
50
|
+
def normalize_header_names(names)
|
|
51
|
+
Array(names).map do |name|
|
|
52
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
53
|
+
raise ArgumentError.new("header names must be strings, got: #{name.inspect}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
name = name.to_s.downcase
|
|
57
|
+
raise ArgumentError.new("header names cannot be empty") if name.empty?
|
|
58
|
+
name.freeze
|
|
59
|
+
end.freeze
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
9
63
|
private
|
|
10
64
|
|
|
11
65
|
# Check if a redirect response should be followed.
|
|
@@ -21,9 +75,38 @@ module PatientHttp
|
|
|
21
75
|
location = response_data[:headers]["location"]
|
|
22
76
|
return false if location.nil? || location.empty?
|
|
23
77
|
|
|
78
|
+
if RedirectHelper.method_change_required?(task.request.http_method, status)
|
|
79
|
+
return false unless follow_method_changing_redirect?(task)
|
|
80
|
+
end
|
|
81
|
+
|
|
24
82
|
true
|
|
25
83
|
end
|
|
26
84
|
|
|
85
|
+
# Check if the request may change its method to follow a redirect.
|
|
86
|
+
# The request setting takes precedence over the configuration.
|
|
87
|
+
#
|
|
88
|
+
# @param task [RequestTask] the request task
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def follow_method_changing_redirect?(task)
|
|
91
|
+
value = task.request.follow_method_changing_redirects
|
|
92
|
+
value = @config.follow_method_changing_redirects if value.nil?
|
|
93
|
+
value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Build the task for following a redirect, applying the configured
|
|
97
|
+
# header stripping rules.
|
|
98
|
+
#
|
|
99
|
+
# @param task [RequestTask] the request task
|
|
100
|
+
# @param response_data [Hash] the response data with status, headers, body
|
|
101
|
+
# @return [RequestTask] the redirect task
|
|
102
|
+
def build_redirect_task(task, response_data)
|
|
103
|
+
task.redirect_task(
|
|
104
|
+
location: response_data[:headers]["location"],
|
|
105
|
+
status: response_data[:status],
|
|
106
|
+
strip_headers: @config.redirect_strip_headers
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
27
110
|
# Check for either too-many-redirects or recursive redirect.
|
|
28
111
|
#
|
|
29
112
|
# @param task [RequestTask] the request task
|
data/lib/patient_http/request.rb
CHANGED
|
@@ -17,9 +17,12 @@ module PatientHttp
|
|
|
17
17
|
private_constant :UNDEFINED
|
|
18
18
|
|
|
19
19
|
# Valid HTTP methods
|
|
20
|
-
VALID_METHODS = %i[get post put patch delete].freeze
|
|
20
|
+
VALID_METHODS = %i[get head post put patch delete query].freeze
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# HTTP methods that must not carry a request body
|
|
23
|
+
BODYLESS_METHODS = %i[get head delete].freeze
|
|
24
|
+
|
|
25
|
+
# @return [Symbol] HTTP method (:get, :head, :post, :put, :patch, :delete, :query)
|
|
23
26
|
attr_reader :http_method
|
|
24
27
|
|
|
25
28
|
# @return [String] The request URL
|
|
@@ -34,6 +37,14 @@ module PatientHttp
|
|
|
34
37
|
# @return [Integer, nil] Maximum number of redirects to follow (nil uses config default, 0 disables)
|
|
35
38
|
attr_reader :max_redirects
|
|
36
39
|
|
|
40
|
+
# @return [Boolean, nil] Whether a redirect that requires changing the HTTP method
|
|
41
|
+
# (for example POST to GET on a 302) may be followed (nil uses config default)
|
|
42
|
+
attr_reader :follow_method_changing_redirects
|
|
43
|
+
|
|
44
|
+
# @return [Array<String>] Lowercase header names stripped from redirected requests,
|
|
45
|
+
# in addition to those configured on the {Configuration}
|
|
46
|
+
attr_reader :redirect_strip_headers
|
|
47
|
+
|
|
37
48
|
# @return [Hash{String, Symbol => SecretReference}] Query parameters whose values are
|
|
38
49
|
# secret references, kept out of the serialized URL and resolved at send time
|
|
39
50
|
attr_reader :secret_params
|
|
@@ -42,6 +53,11 @@ module PatientHttp
|
|
|
42
53
|
# to apply to the request when it is sent
|
|
43
54
|
attr_reader :preprocessors
|
|
44
55
|
|
|
56
|
+
# @return [String, nil] Name of the processor that should execute the request.
|
|
57
|
+
# Integrations use this to route the request to a named processor; nil
|
|
58
|
+
# uses the default processor.
|
|
59
|
+
attr_reader :processor
|
|
60
|
+
|
|
45
61
|
class << self
|
|
46
62
|
# Reconstruct a Request from a hash
|
|
47
63
|
#
|
|
@@ -56,7 +72,10 @@ module PatientHttp
|
|
|
56
72
|
params: load_secret_params(hash["secret_params"]),
|
|
57
73
|
timeout: hash["timeout"],
|
|
58
74
|
max_redirects: hash["max_redirects"],
|
|
59
|
-
|
|
75
|
+
follow_method_changing_redirects: hash["follow_method_changing_redirects"],
|
|
76
|
+
redirect_strip_headers: hash["redirect_strip_headers"],
|
|
77
|
+
preprocessors: hash["preprocessors"],
|
|
78
|
+
processor: hash["processor"]
|
|
60
79
|
)
|
|
61
80
|
end
|
|
62
81
|
|
|
@@ -81,7 +100,7 @@ module PatientHttp
|
|
|
81
100
|
|
|
82
101
|
# Initializes a new Request.
|
|
83
102
|
#
|
|
84
|
-
# @param http_method [Symbol, String] HTTP method (:get, :post, :put, :patch, :delete).
|
|
103
|
+
# @param http_method [Symbol, String] HTTP method (:get, :head, :post, :put, :patch, :delete, :query).
|
|
85
104
|
# @param url [String, URI::Generic] The request URL.
|
|
86
105
|
# @param headers [Hash, HttpHeaders] Request headers.
|
|
87
106
|
# @param body [String, nil] Request body.
|
|
@@ -89,8 +108,16 @@ module PatientHttp
|
|
|
89
108
|
# @param params [Hash, nil] Query parameters to append to the URL.
|
|
90
109
|
# @param timeout [Numeric, nil] Overall timeout in seconds.
|
|
91
110
|
# @param max_redirects [Integer, nil] Maximum redirects to follow (nil uses config, 0 disables).
|
|
111
|
+
# @param follow_method_changing_redirects [Boolean, nil] Whether to follow a redirect that requires changing
|
|
112
|
+
# the HTTP method (nil uses config). When false, such a redirect response is returned as the
|
|
113
|
+
# result instead of being followed.
|
|
114
|
+
# @param redirect_strip_headers [String, Array<String>, nil] Header names (case insensitive)
|
|
115
|
+
# to strip from redirected requests, in addition to those configured on the
|
|
116
|
+
# {Configuration}.
|
|
92
117
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Names of preprocessors
|
|
93
118
|
# registered on the configuration to apply to the request when it is sent.
|
|
119
|
+
# @param processor [String, Symbol, nil] Name of the processor that should execute the
|
|
120
|
+
# request. Integrations use this to route the request to a named processor.
|
|
94
121
|
def initialize(
|
|
95
122
|
http_method,
|
|
96
123
|
url,
|
|
@@ -100,7 +127,10 @@ module PatientHttp
|
|
|
100
127
|
params: nil,
|
|
101
128
|
timeout: nil,
|
|
102
129
|
max_redirects: nil,
|
|
103
|
-
|
|
130
|
+
follow_method_changing_redirects: nil,
|
|
131
|
+
redirect_strip_headers: nil,
|
|
132
|
+
preprocessors: nil,
|
|
133
|
+
processor: nil
|
|
104
134
|
)
|
|
105
135
|
@http_method = http_method.is_a?(String) ? http_method.downcase.to_sym : http_method
|
|
106
136
|
|
|
@@ -116,7 +146,10 @@ module PatientHttp
|
|
|
116
146
|
@body = (body == "") ? nil : body
|
|
117
147
|
@timeout = timeout
|
|
118
148
|
@max_redirects = max_redirects
|
|
149
|
+
@follow_method_changing_redirects = normalized_follow_method_changing_redirects(follow_method_changing_redirects)
|
|
150
|
+
@redirect_strip_headers = RedirectHelper.normalize_header_names(redirect_strip_headers)
|
|
119
151
|
@preprocessors = normalized_preprocessors(preprocessors)
|
|
152
|
+
@processor = normalized_processor(processor)
|
|
120
153
|
|
|
121
154
|
if json
|
|
122
155
|
raise ArgumentError.new("Cannot provide both body and json") if @body
|
|
@@ -157,7 +190,14 @@ module PatientHttp
|
|
|
157
190
|
hash["secret_params"] = @secret_params.transform_values(&:as_json)
|
|
158
191
|
end
|
|
159
192
|
|
|
193
|
+
unless @follow_method_changing_redirects.nil?
|
|
194
|
+
hash["follow_method_changing_redirects"] = @follow_method_changing_redirects
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
hash["redirect_strip_headers"] = @redirect_strip_headers if @redirect_strip_headers.any?
|
|
198
|
+
|
|
160
199
|
hash["preprocessors"] = @preprocessors if @preprocessors.any?
|
|
200
|
+
hash["processor"] = @processor if @processor
|
|
161
201
|
|
|
162
202
|
hash
|
|
163
203
|
end
|
|
@@ -171,6 +211,24 @@ module PatientHttp
|
|
|
171
211
|
end
|
|
172
212
|
end
|
|
173
213
|
|
|
214
|
+
# Normalize the method-changing redirect flag to true, false, or nil.
|
|
215
|
+
def normalized_follow_method_changing_redirects(value)
|
|
216
|
+
return nil if value.nil?
|
|
217
|
+
return value if value == true || value == false
|
|
218
|
+
|
|
219
|
+
raise ArgumentError.new("follow_method_changing_redirects must be true, false, or nil, got: #{value.inspect}")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Normalize the processor name to a frozen string or nil.
|
|
223
|
+
def normalized_processor(processor)
|
|
224
|
+
return nil if processor.nil?
|
|
225
|
+
|
|
226
|
+
name = processor.to_s
|
|
227
|
+
raise ArgumentError.new("processor name cannot be empty") if name.empty?
|
|
228
|
+
|
|
229
|
+
name.freeze
|
|
230
|
+
end
|
|
231
|
+
|
|
174
232
|
# Normalize preprocessor names to a frozen array of strings.
|
|
175
233
|
def normalized_preprocessors(preprocessors)
|
|
176
234
|
names = Array(preprocessors).map(&:to_s)
|
|
@@ -217,7 +275,7 @@ module PatientHttp
|
|
|
217
275
|
raise ArgumentError.new("url must be a String or URI, got: #{@url.class}")
|
|
218
276
|
end
|
|
219
277
|
|
|
220
|
-
if
|
|
278
|
+
if BODYLESS_METHODS.include?(@http_method) && !@body.nil?
|
|
221
279
|
raise ArgumentError.new("body is not allowed for #{@http_method.upcase} requests")
|
|
222
280
|
end
|
|
223
281
|
|
|
@@ -16,8 +16,8 @@ module PatientHttp
|
|
|
16
16
|
# 1. Register a global request handler with {PatientHttp.register_handler}.
|
|
17
17
|
# 2. Include this module in a class.
|
|
18
18
|
# 3. Optionally configure defaults with {.request_template}.
|
|
19
|
-
# 4. Call `async_get`, `async_post`, `async_put`, `async_patch`,
|
|
20
|
-
# `async_request`.
|
|
19
|
+
# 4. Call `async_get`, `async_head`, `async_post`, `async_put`, `async_patch`,
|
|
20
|
+
# `async_delete`, `async_query`, or `async_request`.
|
|
21
21
|
#
|
|
22
22
|
# @example Register a handler
|
|
23
23
|
# PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
|
|
@@ -62,6 +62,16 @@ module PatientHttp
|
|
|
62
62
|
async_request(:get, uri, callback: callback, **kwargs)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Enqueues an asynchronous HTTP HEAD 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_head(uri, callback:, **kwargs)
|
|
72
|
+
async_request(:head, uri, callback: callback, **kwargs)
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
# Enqueues an asynchronous HTTP POST request.
|
|
66
76
|
#
|
|
67
77
|
# @param uri [String] absolute URL or path (when using a request template)
|
|
@@ -101,6 +111,16 @@ module PatientHttp
|
|
|
101
111
|
def async_delete(uri, callback:, **kwargs)
|
|
102
112
|
async_request(:delete, uri, callback: callback, **kwargs)
|
|
103
113
|
end
|
|
114
|
+
|
|
115
|
+
# Enqueues an asynchronous HTTP QUERY request.
|
|
116
|
+
#
|
|
117
|
+
# @param uri [String] absolute URL or path (when using a request template)
|
|
118
|
+
# @param callback [Class, String] callback class to handle the response
|
|
119
|
+
# @param kwargs [Hash] forwarded to `async_request`
|
|
120
|
+
# @return [Object] return value from the registered request handler
|
|
121
|
+
def async_query(uri, callback:, **kwargs)
|
|
122
|
+
async_request(:query, uri, callback: callback, **kwargs)
|
|
123
|
+
end
|
|
104
124
|
end
|
|
105
125
|
|
|
106
126
|
module ClassMethods
|
|
@@ -116,14 +136,16 @@ module PatientHttp
|
|
|
116
136
|
# @param timeout [Float] default timeout in seconds
|
|
117
137
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] default names of
|
|
118
138
|
# preprocessors registered on the configuration to apply to requests
|
|
139
|
+
# @param processor [String, Symbol, nil] default processor name for requests
|
|
119
140
|
# @return [void]
|
|
120
|
-
def request_template(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil)
|
|
141
|
+
def request_template(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil, processor: nil)
|
|
121
142
|
@patient_http_request_template = RequestTemplate.new(
|
|
122
143
|
base_url: base_url,
|
|
123
144
|
headers: headers,
|
|
124
145
|
params: params,
|
|
125
146
|
timeout: timeout,
|
|
126
|
-
preprocessors: preprocessors
|
|
147
|
+
preprocessors: preprocessors,
|
|
148
|
+
processor: processor
|
|
127
149
|
)
|
|
128
150
|
end
|
|
129
151
|
|
|
@@ -132,7 +154,7 @@ module PatientHttp
|
|
|
132
154
|
# When a request template is configured, the request is built from the template. Otherwise,
|
|
133
155
|
# it is built directly from the provided arguments.
|
|
134
156
|
#
|
|
135
|
-
# @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
|
|
157
|
+
# @param method [Symbol] HTTP method (`:get`, `:head`, `:post`, `:put`, `:patch`, `:delete`, `:query`)
|
|
136
158
|
# @param url [String] absolute URL or path (when using a request template)
|
|
137
159
|
# @param callback [Class, String] callback class to handle the response
|
|
138
160
|
# @param headers [Hash, nil] request headers
|
|
@@ -143,8 +165,14 @@ module PatientHttp
|
|
|
143
165
|
# @param raise_error_responses [Boolean, nil] when true, non-success responses are
|
|
144
166
|
# reported as errors
|
|
145
167
|
# @param callback_args [Hash, nil] JSON-compatible callback arguments
|
|
168
|
+
# @param follow_method_changing_redirects [Boolean, nil] whether to follow a redirect that changes the
|
|
169
|
+
# HTTP method (nil uses the configuration default)
|
|
170
|
+
# @param redirect_strip_headers [String, Array<String>, nil] header names (case insensitive)
|
|
171
|
+
# to strip from redirected requests, in addition to the configured names
|
|
146
172
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
|
|
147
173
|
# registered on the configuration to apply to the request when it is sent
|
|
174
|
+
# @param processor [String, Symbol, nil] name of the processor that should execute
|
|
175
|
+
# the request
|
|
148
176
|
# @return [Object] return value from the registered request handler
|
|
149
177
|
def async_request(
|
|
150
178
|
method,
|
|
@@ -157,10 +185,23 @@ module PatientHttp
|
|
|
157
185
|
timeout: nil,
|
|
158
186
|
raise_error_responses: nil,
|
|
159
187
|
callback_args: nil,
|
|
160
|
-
|
|
188
|
+
follow_method_changing_redirects: nil,
|
|
189
|
+
redirect_strip_headers: nil,
|
|
190
|
+
preprocessors: nil,
|
|
191
|
+
processor: nil
|
|
161
192
|
)
|
|
162
193
|
template = async_request_template
|
|
163
|
-
kwargs = {
|
|
194
|
+
kwargs = {
|
|
195
|
+
body: body,
|
|
196
|
+
json: json,
|
|
197
|
+
headers: headers,
|
|
198
|
+
params: params,
|
|
199
|
+
timeout: timeout,
|
|
200
|
+
follow_method_changing_redirects: follow_method_changing_redirects,
|
|
201
|
+
redirect_strip_headers: redirect_strip_headers,
|
|
202
|
+
preprocessors: preprocessors,
|
|
203
|
+
processor: processor
|
|
204
|
+
}
|
|
164
205
|
request = if template
|
|
165
206
|
template.request(method, url, **kwargs)
|
|
166
207
|
else
|
|
@@ -193,7 +234,7 @@ module PatientHttp
|
|
|
193
234
|
#
|
|
194
235
|
# This delegates to {.ClassMethods#async_request} on the including class.
|
|
195
236
|
#
|
|
196
|
-
# @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
|
|
237
|
+
# @param method [Symbol] HTTP method (`:get`, `:head`, `:post`, `:put`, `:patch`, `:delete`, `:query`)
|
|
197
238
|
# @param url [String] absolute URL or path (when using a request template)
|
|
198
239
|
# @param callback [Class, String] callback class to handle the response
|
|
199
240
|
# @param headers [Hash, nil] request headers
|
|
@@ -204,8 +245,14 @@ module PatientHttp
|
|
|
204
245
|
# @param raise_error_responses [Boolean, nil] when true, non-success responses are
|
|
205
246
|
# reported as errors
|
|
206
247
|
# @param callback_args [Hash, nil] JSON-compatible callback arguments
|
|
248
|
+
# @param follow_method_changing_redirects [Boolean, nil] whether to follow a redirect that changes the
|
|
249
|
+
# HTTP method (nil uses the configuration default)
|
|
250
|
+
# @param redirect_strip_headers [String, Array<String>, nil] header names (case insensitive)
|
|
251
|
+
# to strip from redirected requests, in addition to the configured names
|
|
207
252
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
|
|
208
253
|
# registered on the configuration to apply to the request when it is sent
|
|
254
|
+
# @param processor [String, Symbol, nil] name of the processor that should execute
|
|
255
|
+
# the request
|
|
209
256
|
# @return [Object] return value from the registered request handler
|
|
210
257
|
def async_request(
|
|
211
258
|
method,
|
|
@@ -218,7 +265,10 @@ module PatientHttp
|
|
|
218
265
|
timeout: nil,
|
|
219
266
|
raise_error_responses: nil,
|
|
220
267
|
callback_args: nil,
|
|
221
|
-
|
|
268
|
+
follow_method_changing_redirects: nil,
|
|
269
|
+
redirect_strip_headers: nil,
|
|
270
|
+
preprocessors: nil,
|
|
271
|
+
processor: nil
|
|
222
272
|
)
|
|
223
273
|
self.class.async_request(
|
|
224
274
|
method,
|
|
@@ -231,7 +281,10 @@ module PatientHttp
|
|
|
231
281
|
timeout: timeout,
|
|
232
282
|
raise_error_responses: raise_error_responses,
|
|
233
283
|
callback_args: callback_args,
|
|
234
|
-
|
|
284
|
+
follow_method_changing_redirects: follow_method_changing_redirects,
|
|
285
|
+
redirect_strip_headers: redirect_strip_headers,
|
|
286
|
+
preprocessors: preprocessors,
|
|
287
|
+
processor: processor
|
|
235
288
|
)
|
|
236
289
|
end
|
|
237
290
|
|
|
@@ -28,6 +28,13 @@ module PatientHttp
|
|
|
28
28
|
headers = @config.secret_manager.resolve_headers(request.headers.to_h)
|
|
29
29
|
headers["x-request-id"] = request_id
|
|
30
30
|
headers["user-agent"] ||= @config.user_agent if @config.user_agent
|
|
31
|
+
# Compressed responses are inflated by ResponseReader during response
|
|
32
|
+
# decoding rather than by a client middleware wrapper. Requesting gzip is
|
|
33
|
+
# the default because it is what the reader can decode, but a caller that
|
|
34
|
+
# sets the header keeps its own value: "identity" opts out of compression,
|
|
35
|
+
# and any other encoding is delivered still encoded with its
|
|
36
|
+
# content-encoding header intact.
|
|
37
|
+
headers["accept-encoding"] ||= "gzip"
|
|
31
38
|
url = @config.secret_manager.resolve_url(request.url, request.secret_params)
|
|
32
39
|
|
|
33
40
|
outgoing = OutgoingRequest.new(
|
|
@@ -10,6 +10,9 @@ module PatientHttp
|
|
|
10
10
|
# Headers that are sensitive to origin and should be stripped on cross-origin redirects
|
|
11
11
|
SENSITIVE_HEADERS = %w[authorization cookie].freeze
|
|
12
12
|
|
|
13
|
+
# Headers that describe a request body. They are removed when a redirect drops the body.
|
|
14
|
+
BODY_HEADERS = %w[content-type content-length content-encoding content-language content-location].freeze
|
|
15
|
+
|
|
13
16
|
# @return [String] Unique UUID for tracking the task
|
|
14
17
|
attr_reader :id
|
|
15
18
|
|
|
@@ -204,20 +207,24 @@ module PatientHttp
|
|
|
204
207
|
|
|
205
208
|
# Create a new RequestTask for following a redirect.
|
|
206
209
|
#
|
|
210
|
+
# The HTTP method follows RFC 9110: 301 and 302 change POST to GET, 303
|
|
211
|
+
# changes everything except GET and HEAD to GET, and 300, 307, and 308
|
|
212
|
+
# preserve the method. The body and the headers that describe it are
|
|
213
|
+
# dropped whenever the method changes.
|
|
214
|
+
#
|
|
215
|
+
# Headers named in the request's own redirect_strip_headers or in the given
|
|
216
|
+
# list are removed from the redirected request. Authorization and Cookie
|
|
217
|
+
# headers and preprocessors are removed on cross-origin redirects.
|
|
218
|
+
#
|
|
207
219
|
# @param location [String] The redirect URL from the Location header
|
|
208
220
|
# @param status [Integer] The HTTP status code of the redirect response
|
|
221
|
+
# @param strip_headers [Array<String>] Additional header names to strip,
|
|
222
|
+
# typically from the {Configuration}
|
|
209
223
|
# @return [RequestTask] A new task configured for the redirect
|
|
210
|
-
def redirect_task(location:, status:)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if [301, 302, 303].include?(status)
|
|
215
|
-
redirect_method = :get
|
|
216
|
-
redirect_body = nil
|
|
217
|
-
else
|
|
218
|
-
redirect_method = request.http_method
|
|
219
|
-
redirect_body = request.body
|
|
220
|
-
end
|
|
224
|
+
def redirect_task(location:, status:, strip_headers: [])
|
|
225
|
+
redirect_method = RedirectHelper.redirect_method(request.http_method, status)
|
|
226
|
+
method_changed = (redirect_method != request.http_method)
|
|
227
|
+
redirect_body = method_changed ? nil : request.body
|
|
221
228
|
|
|
222
229
|
# Resolve the redirect URL (handle relative URLs)
|
|
223
230
|
redirect_url = resolve_redirect_url(location)
|
|
@@ -226,8 +233,12 @@ module PatientHttp
|
|
|
226
233
|
# prevent credential leakage
|
|
227
234
|
cross_origin = cross_origin?(request.url, redirect_url)
|
|
228
235
|
redirect_headers = cross_origin ? request.headers.except(*SENSITIVE_HEADERS) : request.headers
|
|
236
|
+
redirect_headers = redirect_headers.except(*BODY_HEADERS) if method_changed
|
|
229
237
|
redirect_preprocessors = cross_origin ? [] : request.preprocessors
|
|
230
238
|
|
|
239
|
+
strip_names = request.redirect_strip_headers + Array(strip_headers)
|
|
240
|
+
redirect_headers = redirect_headers.except(*strip_names) if strip_names.any?
|
|
241
|
+
|
|
231
242
|
# Create a new request for the redirect
|
|
232
243
|
redirect_request = Request.new(
|
|
233
244
|
redirect_method,
|
|
@@ -236,7 +247,10 @@ module PatientHttp
|
|
|
236
247
|
body: redirect_body,
|
|
237
248
|
timeout: request.timeout,
|
|
238
249
|
max_redirects: request.max_redirects,
|
|
239
|
-
|
|
250
|
+
follow_method_changing_redirects: request.follow_method_changing_redirects,
|
|
251
|
+
redirect_strip_headers: request.redirect_strip_headers,
|
|
252
|
+
preprocessors: redirect_preprocessors,
|
|
253
|
+
processor: request.processor
|
|
240
254
|
)
|
|
241
255
|
|
|
242
256
|
redirect_task_id = "#{id.split("/").first}/#{@redirects.size + 2}"
|
|
@@ -34,26 +34,47 @@ module PatientHttp
|
|
|
34
34
|
# @param timeout [Float] Default request timeout in seconds
|
|
35
35
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Default preprocessors
|
|
36
36
|
# to apply to all requests
|
|
37
|
-
|
|
37
|
+
# @param processor [String, Symbol, nil] Default processor name for all requests
|
|
38
|
+
def initialize(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil, processor: nil)
|
|
38
39
|
@base_url = base_url
|
|
39
40
|
@headers = HttpHeaders.new(headers)
|
|
40
41
|
@params = params
|
|
41
42
|
@timeout = timeout
|
|
42
43
|
@preprocessors = preprocessors
|
|
44
|
+
@processor = processor
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# Build an async HTTP request. Returns a Request object.
|
|
46
48
|
#
|
|
47
|
-
# @param method [Symbol] HTTP method (:get, :post, :put, :patch, :delete)
|
|
49
|
+
# @param method [Symbol] HTTP method (:get, :head, :post, :put, :patch, :delete, :query)
|
|
48
50
|
# @param uri [String, URI::HTTP] URI path to request (joined with base_url if relative)
|
|
49
51
|
# @param body [String, nil] request body
|
|
50
52
|
# @param json [Object, nil] JSON object to serialize (cannot use with body)
|
|
51
53
|
# @param headers [Hash] additional headers to merge with client headers
|
|
52
54
|
# @param params [Hash, nil] query parameters to add to URL
|
|
55
|
+
# @param timeout [Numeric, nil] request timeout in seconds (overrides the template default)
|
|
56
|
+
# @param follow_method_changing_redirects [Boolean, nil] whether to follow a redirect that changes the
|
|
57
|
+
# HTTP method (nil uses the configuration default)
|
|
58
|
+
# @param redirect_strip_headers [String, Array<String>, nil] header names (case insensitive)
|
|
59
|
+
# to strip from redirected requests, in addition to the configured names
|
|
53
60
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] preprocessors to apply
|
|
54
61
|
# to the request (overrides the template default)
|
|
62
|
+
# @param processor [String, Symbol, nil] processor name for the request (overrides the
|
|
63
|
+
# template default)
|
|
55
64
|
# @return [Request] request object
|
|
56
|
-
def request(
|
|
65
|
+
def request(
|
|
66
|
+
method,
|
|
67
|
+
uri,
|
|
68
|
+
body: nil,
|
|
69
|
+
json: nil,
|
|
70
|
+
headers: nil,
|
|
71
|
+
params: nil,
|
|
72
|
+
timeout: nil,
|
|
73
|
+
follow_method_changing_redirects: nil,
|
|
74
|
+
redirect_strip_headers: nil,
|
|
75
|
+
preprocessors: nil,
|
|
76
|
+
processor: nil
|
|
77
|
+
)
|
|
57
78
|
full_uri = @base_url ? URI.join(@base_url, uri.to_s) : URI(uri)
|
|
58
79
|
|
|
59
80
|
merged_headers = headers&.any? ? @headers.merge(headers) : @headers
|
|
@@ -68,7 +89,10 @@ module PatientHttp
|
|
|
68
89
|
json: json,
|
|
69
90
|
params: merged_params,
|
|
70
91
|
timeout: timeout || @timeout,
|
|
71
|
-
|
|
92
|
+
follow_method_changing_redirects: follow_method_changing_redirects,
|
|
93
|
+
redirect_strip_headers: redirect_strip_headers,
|
|
94
|
+
preprocessors: preprocessors || @preprocessors,
|
|
95
|
+
processor: processor || @processor
|
|
72
96
|
)
|
|
73
97
|
end
|
|
74
98
|
|
|
@@ -81,6 +105,15 @@ module PatientHttp
|
|
|
81
105
|
request(:get, uri, **kwargs)
|
|
82
106
|
end
|
|
83
107
|
|
|
108
|
+
# Convenience method for HEAD requests.
|
|
109
|
+
#
|
|
110
|
+
# @param uri [String, URI::HTTP] URI path to request
|
|
111
|
+
# @param kwargs [Hash] additional options (see #request)
|
|
112
|
+
# @return [Request] request object
|
|
113
|
+
def head(uri, **kwargs)
|
|
114
|
+
request(:head, uri, **kwargs)
|
|
115
|
+
end
|
|
116
|
+
|
|
84
117
|
# Convenience method for POST requests.
|
|
85
118
|
#
|
|
86
119
|
# @param uri [String, URI::HTTP] URI path to request
|
|
@@ -116,5 +149,14 @@ module PatientHttp
|
|
|
116
149
|
def delete(uri, **kwargs)
|
|
117
150
|
request(:delete, uri, **kwargs)
|
|
118
151
|
end
|
|
152
|
+
|
|
153
|
+
# Convenience method for QUERY requests.
|
|
154
|
+
#
|
|
155
|
+
# @param uri [String, URI::HTTP] URI path to request
|
|
156
|
+
# @param kwargs [Hash] additional options (see #request)
|
|
157
|
+
# @return [Request] request object
|
|
158
|
+
def query(uri, **kwargs)
|
|
159
|
+
request(:query, uri, **kwargs)
|
|
160
|
+
end
|
|
119
161
|
end
|
|
120
162
|
end
|