patient_http 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e193efd74abdf58c93ef6d65796c881bf08042264da0da0db34ee030ec52d08a
4
- data.tar.gz: cb61f5268f51d6959b04e096420419f3454852096b5e027d67f79fa2f9789be8
3
+ metadata.gz: 54ede397ae79574e7dd597ea642b4d039877d5d642ba49c68b76219b8b1c4a32
4
+ data.tar.gz: 83bb067b78912753791e6002abb3470ca70936f5fe862fc65ee65d7786e1ac53
5
5
  SHA512:
6
- metadata.gz: 56896f881827163fffaebfc8c745e47d5abdbf3c11da1727d6801164b1ed1e4ac276ac4a9e288d8d8d9cc544aab2e1a67d28c8cd5cff312429d243b49658107f
7
- data.tar.gz: 4e2a4c555e44ed3c0ee6997d74ca0dfa444e7ad4567a9b58386e3bbd442ce8a82d94558e7606aa8bab2749a9930221e0c2609ffced97922a47cf7f1c4261023a
6
+ metadata.gz: 0302b8cdf7042fa545e49ab1ce565d5f7d1fd388a8413bc8a19249477d46d2fc9f732142d75afbb6cc52d0dd7a228e01c6f0c1cbaea2f7b151568472ccfaecb4
7
+ data.tar.gz: f418c2d7b5b48ba81ac73de35533143d7e99c75fb108a619f9cf9a55ca204f56f42ffec86ee4fc6d2796730703aad488014955603f87fc94ccf3e66d6b7b8593
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.6.0
8
+
9
+ ### Added
10
+
11
+ - `Configuration#follow_method_changing_redirects` and `Request#follow_method_changing_redirects` (default true): when false, a redirect that would change the HTTP method (such as POST to GET on a 301, 302, or 303) is not followed and the redirect response is delivered to the callback instead. Redirects that preserve the method are still followed. The request setting overrides the configuration.
12
+ - `Configuration#redirect_strip_headers` and `Request#redirect_strip_headers`: header names (matched case insensitively) that are always removed from redirected requests, so sensitive headers are never sent to a redirect target. Request-level names are applied in addition to the configured ones and survive serialization.
13
+ - `PatientHttp.request`, `RequestHelper#async_request`, and `RequestTemplate#request` (and their `get`, `post`, and other method helpers) accept `follow_method_changing_redirects:` and `redirect_strip_headers:` and pass them to the `Request`.
14
+ - `HEAD` and `QUERY` HTTP methods are supported by `Request`, `RequestTemplate`, `RequestHelper` (`async_head`, `async_query`), and `PatientHttp.head` / `PatientHttp.query`. `HEAD` requests cannot carry a body. The response size limit is not applied to the `Content-Length` of a `HEAD` response because no body is transferred.
15
+
16
+ ### Changed
17
+
18
+ - The HTTP method used when following a redirect now follows RFC 9110. On 301 and 302 responses only `POST` is changed to `GET`; `HEAD`, `PUT`, `PATCH`, `DELETE`, and `QUERY` are re-sent unchanged with their body. On 303 responses `GET` and `HEAD` are preserved and every other method becomes `GET`. Previously every method was changed to `GET` on 301, 302, and 303.
19
+ - When a redirect changes the method and drops the body, the `Content-Type`, `Content-Length`, `Content-Encoding`, `Content-Language`, and `Content-Location` headers are removed from the redirected request as well.
20
+ - A 300 Multiple Choices response with a `Location` header is now followed, preserving the method and body. A 300 response without `Location` is delivered as a response, as before.
21
+ - `RequestTask#redirect_task` accepts a `strip_headers:` option with the configured header names to remove.
22
+
7
23
  ## 1.5.0
8
24
 
9
25
  ### Added
data/README.md CHANGED
@@ -163,7 +163,7 @@ get_request = template.get("/users/123")
163
163
  post_request = template.post("/users", json: {name: "John"})
164
164
  ```
165
165
 
166
- Templates support all HTTP methods (`get`, `post`, `put`, `patch`, `delete`) and handle URL joining, header merging, and query parameter encoding.
166
+ Templates support all HTTP methods (`get`, `head`, `post`, `put`, `patch`, `delete`, `query`) and handle URL joining, header merging, and query parameter encoding.
167
167
 
168
168
  ## Standard Interface
169
169
 
@@ -188,7 +188,7 @@ PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_
188
188
  end
189
189
 
190
190
  # Now you can make requests directly through the PatientHttp interface with the .request,
191
- # .get, .post, .patch, .put, and .delete class methods:
191
+ # .get, .head, .post, .patch, .put, .delete, and .query class methods:
192
192
  PatientHttp.get(
193
193
  "https://api.example.com/users/123",
194
194
  callback: FetchUserCallback,
@@ -229,7 +229,7 @@ Use `PatientHttp::RequestHelper` when you want a simple API for creating and dis
229
229
  1. Register a request handler with `PatientHttp.register_handler` that defines how requests are dispatched to your job queue or background processing system.
230
230
  2. Include `PatientHttp::RequestHelper` in your class.
231
231
  3. Optionally define a `request_template` for shared `base_url`, headers, and timeout.
232
- 4. Call `async_get`, `async_post`, `async_put`, `async_patch`, `async_delete`, or `async_request`.
232
+ 4. Call `async_get`, `async_head`, `async_post`, `async_put`, `async_patch`, `async_delete`, `async_query`, or `async_request`.
233
233
 
234
234
  ```ruby
235
235
  class ApiClient
@@ -618,6 +618,49 @@ If a request references a preprocessor name that is not registered, a `PatientHt
618
618
 
619
619
  When redirects are followed, preprocessors are re-run against each redirect URL so signatures stay valid. On cross-origin redirects they are dropped entirely, consistent with the stripping of `Authorization` and `Cookie` headers, so signed credentials are never sent to an unexpected origin.
620
620
 
621
+ ## Redirects
622
+
623
+ Redirect responses (300, 301, 302, 303, 307, and 308) with a `Location` header are followed automatically, up to `max_redirects` hops. A 300 response is followed only when the server names a preferred choice in `Location`. Redirect loops raise `RecursiveRedirectError` and exceeding the limit raises `TooManyRedirectsError`. Any redirect that is not followed is delivered to the callback as a normal response.
624
+
625
+ The HTTP method of the redirected request follows RFC 9110:
626
+
627
+ | Status | Method |
628
+ | --- | --- |
629
+ | 301, 302 | `POST` becomes `GET` and the body is dropped. Other methods (including `HEAD`, `PUT`, `DELETE`, and `QUERY`) are preserved with their body. |
630
+ | 303 | `GET` and `HEAD` are preserved. Every other method becomes `GET` and the body is dropped. |
631
+ | 300, 307, 308 | The method and body are preserved. |
632
+
633
+ The QUERY specification states that the POST-to-GET exception on 301 and 302 does not apply to `QUERY`, so a redirected `QUERY` is re-sent as a `QUERY` with its body, and a 303 turns it into a `GET`.
634
+
635
+ ### Preventing method changes
636
+
637
+ Set `follow_method_changing_redirects: false` to stop following redirects that would change the HTTP method. A `POST` that receives a 302 then completes with the 302 response instead of being retried as a `GET`. Redirects that preserve the method (a `PUT` on a 301, or any method on a 307) are still followed. The option can be set on the `Configuration` or on a single `Request`; the request value wins when both are set.
638
+
639
+ ```ruby
640
+ config = PatientHttp::Configuration.new(follow_method_changing_redirects: false)
641
+
642
+ # Or per request
643
+ request = PatientHttp::Request.new(:post, "https://api.example.com/submit", body: payload, follow_method_changing_redirects: false)
644
+ ```
645
+
646
+ ### Stripping headers on redirects
647
+
648
+ `Authorization` and `Cookie` headers are always removed on cross-origin redirects. To make sure other sensitive headers are never sent to a redirect target, list them in `redirect_strip_headers`. Header names are matched case insensitively. Listed headers are removed from every redirected request, same-origin or not.
649
+
650
+ ```ruby
651
+ config = PatientHttp::Configuration.new(redirect_strip_headers: ["X-Api-Key", "X-Internal-Token"])
652
+
653
+ # Or per request; these are stripped in addition to the configured headers
654
+ request = PatientHttp::Request.new(:get, "https://api.example.com/data", headers: headers, redirect_strip_headers: "X-Signature")
655
+
656
+ # The same options are accepted by PatientHttp.request, the async_* helpers, and RequestTemplate
657
+ PatientHttp.get("https://api.example.com/data", callback: FetchCallback, redirect_strip_headers: "X-Signature")
658
+ ```
659
+
660
+ Per-request header names survive serialization into the job queue, so they apply no matter which process follows the redirect.
661
+
662
+ Stripping applies to the headers set on the request. Preprocessors run again on each same-origin redirect and can add headers after the strip, so a header that a preprocessor sets is sent to the redirect target. When a redirect changes the method and drops the body, the headers that describe the body (`Content-Type`, `Content-Length`, `Content-Encoding`, `Content-Language`, and `Content-Location`) are removed as well.
663
+
621
664
  ## Troubleshooting
622
665
 
623
666
  ### Warning: `ThreadError: Attempt to unlock a mutex which is not locked`
@@ -660,6 +703,15 @@ config = PatientHttp::Configuration.new(
660
703
  # Maximum redirects to follow (default: 5, 0 disables)
661
704
  max_redirects: 5,
662
705
 
706
+ # Follow redirects that must change the HTTP method, such as POST to GET on
707
+ # a 302 (default: true). When false, those requests receive the redirect response.
708
+ follow_method_changing_redirects: true,
709
+
710
+ # Header names (case insensitive) always stripped from redirected requests
711
+ # (default: []). Authorization and Cookie are always stripped on cross-origin
712
+ # redirects.
713
+ redirect_strip_headers: ["X-Api-Key", "X-Internal-Token"],
714
+
663
715
  # Maximum number of hosts to maintain persistent connections for (default: 100)
664
716
  connection_pool_size: 100,
665
717
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.5.0
1
+ 1.6.0
@@ -43,6 +43,15 @@ module PatientHttp
43
43
  # @return [Integer] Maximum number of redirects to follow (0 disables redirects)
44
44
  attr_reader :max_redirects
45
45
 
46
+ # @return [Boolean] Whether a redirect that requires changing the HTTP method
47
+ # (for example POST to GET on a 302) may be followed. When false, such a
48
+ # redirect response is returned as the result instead of being followed.
49
+ attr_reader :follow_method_changing_redirects
50
+
51
+ # @return [Array<String>] Lowercase header names that are always stripped from
52
+ # redirected requests
53
+ attr_reader :redirect_strip_headers
54
+
46
55
  # @return [Integer] This is the maximum number of hosts for which connections
47
56
  # will be kept alive for at one time.
48
57
  attr_reader :connection_pool_size
@@ -73,6 +82,12 @@ module PatientHttp
73
82
  # @param user_agent [String, nil] Default User-Agent header value
74
83
  # @param raise_error_responses [Boolean] Whether to raise HttpError for non-2xx responses by default
75
84
  # @param max_redirects [Integer] Maximum number of redirects to follow (0 disables redirects)
85
+ # @param follow_method_changing_redirects [Boolean] Whether to follow a redirect that requires changing the
86
+ # HTTP method, such as POST to GET on a 301, 302, or 303 response. When false, requests
87
+ # whose method would change do not follow the redirect and receive the redirect response.
88
+ # @param redirect_strip_headers [String, Array<String>] Header names (case insensitive)
89
+ # that are always stripped from redirected requests, so sensitive headers are never
90
+ # sent to a redirect target
76
91
  # @param connection_pool_size [Integer] Maximum number of host clients to pool
77
92
  # @param connection_timeout [Numeric, nil] Connection timeout in seconds
78
93
  # @param proxy_url [String, nil] HTTP/HTTPS proxy URL (supports authentication)
@@ -93,6 +108,8 @@ module PatientHttp
93
108
  user_agent: "PatientHttp",
94
109
  raise_error_responses: false,
95
110
  max_redirects: 5,
111
+ follow_method_changing_redirects: true,
112
+ redirect_strip_headers: [],
96
113
  connection_pool_size: 100,
97
114
  connection_timeout: nil,
98
115
  proxy_url: nil,
@@ -126,6 +143,8 @@ module PatientHttp
126
143
  self.user_agent = user_agent
127
144
  self.raise_error_responses = raise_error_responses
128
145
  self.max_redirects = max_redirects
146
+ self.follow_method_changing_redirects = follow_method_changing_redirects
147
+ self.redirect_strip_headers = redirect_strip_headers
129
148
  self.connection_pool_size = connection_pool_size
130
149
  self.connection_timeout = connection_timeout
131
150
  self.proxy_url = proxy_url
@@ -186,6 +205,18 @@ module PatientHttp
186
205
  @max_redirects = value
187
206
  end
188
207
 
208
+ def follow_method_changing_redirects=(value)
209
+ unless value == true || value == false
210
+ raise ArgumentError.new("follow_method_changing_redirects must be true or false, got: #{value.inspect}")
211
+ end
212
+
213
+ @follow_method_changing_redirects = value
214
+ end
215
+
216
+ def redirect_strip_headers=(value)
217
+ @redirect_strip_headers = RedirectHelper.normalize_header_names(value)
218
+ end
219
+
189
220
  def connection_pool_size=(value)
190
221
  validate_positive_integer(:connection_pool_size, value)
191
222
  @connection_pool_size = value
@@ -425,6 +456,8 @@ module PatientHttp
425
456
  "user_agent" => user_agent,
426
457
  "raise_error_responses" => raise_error_responses,
427
458
  "max_redirects" => max_redirects,
459
+ "follow_method_changing_redirects" => follow_method_changing_redirects,
460
+ "redirect_strip_headers" => redirect_strip_headers,
428
461
  "connection_pool_size" => connection_pool_size,
429
462
  "connection_timeout" => connection_timeout,
430
463
  "proxy_url" => proxy_url,
@@ -12,7 +12,7 @@ module PatientHttp
12
12
  #
13
13
  # @see Configuration#register_preprocessor
14
14
  class OutgoingRequest
15
- # @return [Symbol] HTTP method (:get, :post, :put, :patch, :delete)
15
+ # @return [Symbol] HTTP method (:get, :head, :post, :put, :patch, :delete, :query)
16
16
  attr_reader :http_method
17
17
 
18
18
  # @return [String] the request URL with any secret query params already resolved
@@ -809,7 +809,7 @@ module PatientHttp
809
809
  # Create the redirect task, then atomically claim the original (remove it
810
810
  # from in-flight) and enqueue the redirect. If the claim fails the
811
811
  # shutdown sequence already re-enqueued the original, so drop the redirect.
812
- redirect_task = task.redirect_task(location: location, status: status)
812
+ redirect_task = build_redirect_task(task, response_data)
813
813
 
814
814
  begin
815
815
  claimed = announce_and_enqueue(redirect_task) do
@@ -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
@@ -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
- # @return [Symbol] HTTP method (:get, :post, :put, :patch, :delete)
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
@@ -61,6 +72,8 @@ module PatientHttp
61
72
  params: load_secret_params(hash["secret_params"]),
62
73
  timeout: hash["timeout"],
63
74
  max_redirects: hash["max_redirects"],
75
+ follow_method_changing_redirects: hash["follow_method_changing_redirects"],
76
+ redirect_strip_headers: hash["redirect_strip_headers"],
64
77
  preprocessors: hash["preprocessors"],
65
78
  processor: hash["processor"]
66
79
  )
@@ -87,7 +100,7 @@ module PatientHttp
87
100
 
88
101
  # Initializes a new Request.
89
102
  #
90
- # @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).
91
104
  # @param url [String, URI::Generic] The request URL.
92
105
  # @param headers [Hash, HttpHeaders] Request headers.
93
106
  # @param body [String, nil] Request body.
@@ -95,6 +108,12 @@ module PatientHttp
95
108
  # @param params [Hash, nil] Query parameters to append to the URL.
96
109
  # @param timeout [Numeric, nil] Overall timeout in seconds.
97
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}.
98
117
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Names of preprocessors
99
118
  # registered on the configuration to apply to the request when it is sent.
100
119
  # @param processor [String, Symbol, nil] Name of the processor that should execute the
@@ -108,6 +127,8 @@ module PatientHttp
108
127
  params: nil,
109
128
  timeout: nil,
110
129
  max_redirects: nil,
130
+ follow_method_changing_redirects: nil,
131
+ redirect_strip_headers: nil,
111
132
  preprocessors: nil,
112
133
  processor: nil
113
134
  )
@@ -125,6 +146,8 @@ module PatientHttp
125
146
  @body = (body == "") ? nil : body
126
147
  @timeout = timeout
127
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)
128
151
  @preprocessors = normalized_preprocessors(preprocessors)
129
152
  @processor = normalized_processor(processor)
130
153
 
@@ -167,6 +190,12 @@ module PatientHttp
167
190
  hash["secret_params"] = @secret_params.transform_values(&:as_json)
168
191
  end
169
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
+
170
199
  hash["preprocessors"] = @preprocessors if @preprocessors.any?
171
200
  hash["processor"] = @processor if @processor
172
201
 
@@ -182,6 +211,14 @@ module PatientHttp
182
211
  end
183
212
  end
184
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
+
185
222
  # Normalize the processor name to a frozen string or nil.
186
223
  def normalized_processor(processor)
187
224
  return nil if processor.nil?
@@ -238,7 +275,7 @@ module PatientHttp
238
275
  raise ArgumentError.new("url must be a String or URI, got: #{@url.class}")
239
276
  end
240
277
 
241
- if %i[get delete].include?(@http_method) && !@body.nil?
278
+ if BODYLESS_METHODS.include?(@http_method) && !@body.nil?
242
279
  raise ArgumentError.new("body is not allowed for #{@http_method.upcase} requests")
243
280
  end
244
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`, `async_delete`, or
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
@@ -134,7 +154,7 @@ module PatientHttp
134
154
  # When a request template is configured, the request is built from the template. Otherwise,
135
155
  # it is built directly from the provided arguments.
136
156
  #
137
- # @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
157
+ # @param method [Symbol] HTTP method (`:get`, `:head`, `:post`, `:put`, `:patch`, `:delete`, `:query`)
138
158
  # @param url [String] absolute URL or path (when using a request template)
139
159
  # @param callback [Class, String] callback class to handle the response
140
160
  # @param headers [Hash, nil] request headers
@@ -145,6 +165,10 @@ module PatientHttp
145
165
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
146
166
  # reported as errors
147
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
148
172
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
149
173
  # registered on the configuration to apply to the request when it is sent
150
174
  # @param processor [String, Symbol, nil] name of the processor that should execute
@@ -161,11 +185,23 @@ module PatientHttp
161
185
  timeout: nil,
162
186
  raise_error_responses: nil,
163
187
  callback_args: nil,
188
+ follow_method_changing_redirects: nil,
189
+ redirect_strip_headers: nil,
164
190
  preprocessors: nil,
165
191
  processor: nil
166
192
  )
167
193
  template = async_request_template
168
- kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout, preprocessors: preprocessors, processor: processor}
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
+ }
169
205
  request = if template
170
206
  template.request(method, url, **kwargs)
171
207
  else
@@ -198,7 +234,7 @@ module PatientHttp
198
234
  #
199
235
  # This delegates to {.ClassMethods#async_request} on the including class.
200
236
  #
201
- # @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
237
+ # @param method [Symbol] HTTP method (`:get`, `:head`, `:post`, `:put`, `:patch`, `:delete`, `:query`)
202
238
  # @param url [String] absolute URL or path (when using a request template)
203
239
  # @param callback [Class, String] callback class to handle the response
204
240
  # @param headers [Hash, nil] request headers
@@ -209,6 +245,10 @@ module PatientHttp
209
245
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
210
246
  # reported as errors
211
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
212
252
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
213
253
  # registered on the configuration to apply to the request when it is sent
214
254
  # @param processor [String, Symbol, nil] name of the processor that should execute
@@ -225,6 +265,8 @@ module PatientHttp
225
265
  timeout: nil,
226
266
  raise_error_responses: nil,
227
267
  callback_args: nil,
268
+ follow_method_changing_redirects: nil,
269
+ redirect_strip_headers: nil,
228
270
  preprocessors: nil,
229
271
  processor: nil
230
272
  )
@@ -239,6 +281,8 @@ module PatientHttp
239
281
  timeout: timeout,
240
282
  raise_error_responses: raise_error_responses,
241
283
  callback_args: callback_args,
284
+ follow_method_changing_redirects: follow_method_changing_redirects,
285
+ redirect_strip_headers: redirect_strip_headers,
242
286
  preprocessors: preprocessors,
243
287
  processor: processor
244
288
  )
@@ -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
- # Determine the HTTP method and body for the redirect
212
- # 301, 302, 303: Convert to GET (no body) - standard browser behavior
213
- # 307, 308: Preserve original method and body
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,6 +247,8 @@ module PatientHttp
236
247
  body: redirect_body,
237
248
  timeout: request.timeout,
238
249
  max_redirects: request.max_redirects,
250
+ follow_method_changing_redirects: request.follow_method_changing_redirects,
251
+ redirect_strip_headers: request.redirect_strip_headers,
239
252
  preprocessors: redirect_preprocessors,
240
253
  processor: request.processor
241
254
  )
@@ -46,18 +46,35 @@ module PatientHttp
46
46
 
47
47
  # Build an async HTTP request. Returns a Request object.
48
48
  #
49
- # @param method [Symbol] HTTP method (:get, :post, :put, :patch, :delete)
49
+ # @param method [Symbol] HTTP method (:get, :head, :post, :put, :patch, :delete, :query)
50
50
  # @param uri [String, URI::HTTP] URI path to request (joined with base_url if relative)
51
51
  # @param body [String, nil] request body
52
52
  # @param json [Object, nil] JSON object to serialize (cannot use with body)
53
53
  # @param headers [Hash] additional headers to merge with client headers
54
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
55
60
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] preprocessors to apply
56
61
  # to the request (overrides the template default)
57
62
  # @param processor [String, Symbol, nil] processor name for the request (overrides the
58
63
  # template default)
59
64
  # @return [Request] request object
60
- def request(method, uri, body: nil, json: nil, headers: nil, params: nil, timeout: nil, preprocessors: nil, processor: nil)
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
+ )
61
78
  full_uri = @base_url ? URI.join(@base_url, uri.to_s) : URI(uri)
62
79
 
63
80
  merged_headers = headers&.any? ? @headers.merge(headers) : @headers
@@ -72,6 +89,8 @@ module PatientHttp
72
89
  json: json,
73
90
  params: merged_params,
74
91
  timeout: timeout || @timeout,
92
+ follow_method_changing_redirects: follow_method_changing_redirects,
93
+ redirect_strip_headers: redirect_strip_headers,
75
94
  preprocessors: preprocessors || @preprocessors,
76
95
  processor: processor || @processor
77
96
  )
@@ -86,6 +105,15 @@ module PatientHttp
86
105
  request(:get, uri, **kwargs)
87
106
  end
88
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
+
89
117
  # Convenience method for POST requests.
90
118
  #
91
119
  # @param uri [String, URI::HTTP] URI path to request
@@ -121,5 +149,14 @@ module PatientHttp
121
149
  def delete(uri, **kwargs)
122
150
  request(:delete, uri, **kwargs)
123
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
124
161
  end
125
162
  end
@@ -113,15 +113,21 @@ module PatientHttp
113
113
  # response is compressed, the size check here applies to the compressed
114
114
  # bytes and {#decode_body} applies the same limit to the inflated bytes.
115
115
  #
116
+ # The Content-Length header is checked against the size limit before the
117
+ # read starts. A response to a HEAD request has an empty body but reports
118
+ # the Content-Length of the resource, so the header check is skipped when
119
+ # the body reports itself as empty.
120
+ #
116
121
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
117
122
  # @param headers_hash [Hash] the response headers
118
123
  # @return [Array<String>, nil] the raw body chunks or nil if no body present
119
124
  # @raise [ResponseTooLargeError] if the body exceeds max_response_size
120
125
  # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
121
126
  def read_raw_body(async_response, headers_hash)
122
- return nil unless async_response.body
127
+ body = async_response.body
128
+ return nil unless body
123
129
 
124
- validate_content_length(headers_hash)
130
+ validate_content_length(headers_hash) unless body.empty?
125
131
  read_body_chunks(async_response)
126
132
  end
127
133
 
@@ -63,7 +63,7 @@ module PatientHttp
63
63
  # flattened to a single joined string value.
64
64
  headers_hash = async_response.headers.to_h.transform_values(&:to_s)
65
65
 
66
- chunks = read_response_body(async_response, headers_hash)
66
+ chunks = @response_reader.read_raw_body(async_response, headers_hash)
67
67
  body_content = @response_reader.decode_body(chunks, headers_hash)
68
68
 
69
69
  {
@@ -81,8 +81,7 @@ module PatientHttp
81
81
  redirect_error = check_redirect_error(@task, response_data)
82
82
  break if redirect_error
83
83
 
84
- location = response_data[:headers]["location"]
85
- @task = @task.redirect_task(location: location, status: response_data[:status])
84
+ @task = build_redirect_task(@task, response_data)
86
85
  end
87
86
 
88
87
  if redirect_error
@@ -179,47 +178,6 @@ module PatientHttp
179
178
  )
180
179
  end
181
180
 
182
- # Read the raw response body chunks with size validation. The chunks are
183
- # the wire bytes; ResponseReader#decode_body inflates and applies the
184
- # charset, enforcing the same limit on the inflated bytes.
185
- #
186
- # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
187
- # @param headers_hash [Hash] the response headers
188
- # @return [Array<String>, nil] the raw body chunks or nil if no body present
189
- def read_response_body(async_response, headers_hash)
190
- return nil unless async_response.body
191
-
192
- content_length = headers_hash["content-length"]&.to_i
193
- if content_length && content_length > @config.max_response_size
194
- raise ResponseTooLargeError.new(
195
- "Response body size (#{content_length} bytes) exceeds maximum allowed size (#{@config.max_response_size} bytes)"
196
- )
197
- end
198
-
199
- chunks = []
200
- total_size = 0
201
- finished = false
202
-
203
- begin
204
- async_response.body.each do |chunk|
205
- total_size += chunk.bytesize
206
- if total_size > @config.max_response_size
207
- raise ResponseTooLargeError.new(
208
- "Response body size exceeded maximum allowed size (#{@config.max_response_size} bytes)"
209
- )
210
- end
211
- chunks << chunk
212
- end
213
-
214
- finished = true
215
- ensure
216
- # Close the body if the read was interrupted so the connection is released
217
- async_response.body.close unless finished
218
- end
219
-
220
- chunks
221
- end
222
-
223
181
  # Invoke callback synchronously.
224
182
  #
225
183
  # @param result [Response, Error] the result to pass to callback
data/lib/patient_http.rb CHANGED
@@ -36,8 +36,9 @@ module PatientHttp
36
36
  # lands in the job system's retry mechanism instead of being dropped.
37
37
  class UnknownProcessorError < StandardError; end
38
38
 
39
- # HTTP redirect status codes that should be followed
40
- FOLLOWABLE_REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
39
+ # HTTP redirect status codes that are followed when a Location header is present.
40
+ # A 300 response is followed only when the server names a preferred choice in Location.
41
+ FOLLOWABLE_REDIRECT_STATUSES = [300, 301, 302, 303, 307, 308].freeze
41
42
 
42
43
  VERSION = File.read(File.join(__dir__, "../VERSION")).strip
43
44
 
@@ -275,6 +276,16 @@ module PatientHttp
275
276
  request(:get, uri, callback: callback, **kwargs)
276
277
  end
277
278
 
279
+ # Enqueues an HTTP HEAD request.
280
+ #
281
+ # @param uri [String] absolute URL
282
+ # @param callback [Class, String] callback class to handle the response
283
+ # @param kwargs [Hash] forwarded to `request`
284
+ # @return [Object] return value from the registered request handler
285
+ def head(uri, callback:, **kwargs)
286
+ request(:head, uri, callback: callback, **kwargs)
287
+ end
288
+
278
289
  # Enqueues an HTTP POST request.
279
290
  #
280
291
  # @param uri [String] absolute URL
@@ -315,9 +326,19 @@ module PatientHttp
315
326
  request(:delete, uri, callback: callback, **kwargs)
316
327
  end
317
328
 
329
+ # Enqueues an HTTP QUERY request.
330
+ #
331
+ # @param uri [String] absolute URL
332
+ # @param callback [Class, String] callback class to handle the response
333
+ # @param kwargs [Hash] forwarded to `request`
334
+ # @return [Object] return value from the registered request handler
335
+ def query(uri, callback:, **kwargs)
336
+ request(:query, uri, callback: callback, **kwargs)
337
+ end
338
+
318
339
  # Builds and dispatches an HTTP request.
319
340
  #
320
- # @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
341
+ # @param method [Symbol] HTTP method (`:get`, `:head`, `:post`, `:put`, `:patch`, `:delete`, `:query`)
321
342
  # @param url [String] absolute URL
322
343
  # @param callback [Class, String] callback class to handle the response
323
344
  # @param headers [Hash, nil] request headers
@@ -328,6 +349,12 @@ module PatientHttp
328
349
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
329
350
  # reported as errors
330
351
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
352
+ # @param max_redirects [Integer, nil] maximum redirects to follow (nil uses the configuration
353
+ # default, 0 disables redirects)
354
+ # @param follow_method_changing_redirects [Boolean, nil] whether to follow a redirect that changes the
355
+ # HTTP method (nil uses the configuration default)
356
+ # @param redirect_strip_headers [String, Array<String>, nil] header names (case insensitive)
357
+ # to strip from redirected requests, in addition to the configured names
331
358
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
332
359
  # registered on the configuration to apply to the request when it is sent
333
360
  # @param processor [String, Symbol, nil] name of the processor that should execute
@@ -344,6 +371,9 @@ module PatientHttp
344
371
  timeout: nil,
345
372
  raise_error_responses: nil,
346
373
  callback_args: nil,
374
+ max_redirects: nil,
375
+ follow_method_changing_redirects: nil,
376
+ redirect_strip_headers: nil,
347
377
  preprocessors: nil,
348
378
  processor: nil
349
379
  )
@@ -355,6 +385,9 @@ module PatientHttp
355
385
  headers: headers,
356
386
  params: params,
357
387
  timeout: timeout,
388
+ max_redirects: max_redirects,
389
+ follow_method_changing_redirects: follow_method_changing_redirects,
390
+ redirect_strip_headers: redirect_strip_headers,
358
391
  preprocessors: preprocessors,
359
392
  processor: processor
360
393
  )
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -146,7 +146,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
146
146
  - !ruby/object:Gem::Version
147
147
  version: '0'
148
148
  requirements: []
149
- rubygems_version: 3.6.9
149
+ rubygems_version: 4.0.3
150
150
  specification_version: 4
151
151
  summary: Generic async HTTP connection pool for Ruby applications using Fiber-based
152
152
  concurrency