wreq 1.2.4 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +3 -7
  3. data/Cargo.toml +6 -1
  4. data/crates/wreq-util/.github/FUNDING.yml +15 -0
  5. data/crates/wreq-util/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  6. data/crates/wreq-util/.github/ISSUE_TEMPLATE/custom.md +10 -0
  7. data/crates/wreq-util/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  8. data/crates/wreq-util/.github/dependabot.yml +22 -0
  9. data/crates/wreq-util/.github/workflows/ci.yml +82 -0
  10. data/crates/wreq-util/.github/workflows/release-plz.yml +52 -0
  11. data/crates/wreq-util/.gitignore +24 -0
  12. data/crates/wreq-util/CHANGELOG.md +74 -0
  13. data/crates/wreq-util/Cargo.toml +94 -0
  14. data/crates/wreq-util/LICENSE +201 -0
  15. data/crates/wreq-util/README.md +62 -0
  16. data/crates/wreq-util/examples/emulate.rs +39 -0
  17. data/crates/wreq-util/examples/tower_delay.rs +191 -0
  18. data/crates/wreq-util/release-plz.toml +2 -0
  19. data/crates/wreq-util/rustfmt.toml +5 -0
  20. data/crates/wreq-util/src/emulate/compress.rs +85 -0
  21. data/crates/wreq-util/src/emulate/macros.rs +370 -0
  22. data/crates/wreq-util/src/emulate/profile/chrome/header.rs +59 -0
  23. data/crates/wreq-util/src/emulate/profile/chrome/http2.rs +65 -0
  24. data/crates/wreq-util/src/emulate/profile/chrome/tls.rs +153 -0
  25. data/crates/wreq-util/src/emulate/profile/chrome.rs +2028 -0
  26. data/crates/wreq-util/src/emulate/profile/firefox/header.rs +21 -0
  27. data/crates/wreq-util/src/emulate/profile/firefox/http2.rs +113 -0
  28. data/crates/wreq-util/src/emulate/profile/firefox/tls.rs +253 -0
  29. data/crates/wreq-util/src/emulate/profile/firefox.rs +518 -0
  30. data/crates/wreq-util/src/emulate/profile/okhttp.rs +241 -0
  31. data/crates/wreq-util/src/emulate/profile/opera/header.rs +30 -0
  32. data/crates/wreq-util/src/emulate/profile/opera/http2.rs +50 -0
  33. data/crates/wreq-util/src/emulate/profile/opera/tls.rs +97 -0
  34. data/crates/wreq-util/src/emulate/profile/opera.rs +299 -0
  35. data/crates/wreq-util/src/emulate/profile/safari/header.rs +59 -0
  36. data/crates/wreq-util/src/emulate/profile/safari/http2.rs +116 -0
  37. data/crates/wreq-util/src/emulate/profile/safari/tls.rs +177 -0
  38. data/crates/wreq-util/src/emulate/profile/safari.rs +222 -0
  39. data/crates/wreq-util/src/emulate/profile.rs +47 -0
  40. data/crates/wreq-util/src/emulate.rs +369 -0
  41. data/crates/wreq-util/src/lib.rs +14 -0
  42. data/crates/wreq-util/src/rand.rs +24 -0
  43. data/crates/wreq-util/src/tower/delay/future.rs +43 -0
  44. data/crates/wreq-util/src/tower/delay/layer.rs +201 -0
  45. data/crates/wreq-util/src/tower/delay/service.rs +190 -0
  46. data/crates/wreq-util/src/tower/delay.rs +98 -0
  47. data/crates/wreq-util/src/tower.rs +7 -0
  48. data/crates/wreq-util/tests/client.rs +51 -0
  49. data/crates/wreq-util/tests/emulate_chrome.rs +270 -0
  50. data/crates/wreq-util/tests/emulate_firefox.rs +92 -0
  51. data/crates/wreq-util/tests/emulate_okhttp.rs +46 -0
  52. data/crates/wreq-util/tests/emulate_opera.rs +113 -0
  53. data/crates/wreq-util/tests/emulate_safari.rs +151 -0
  54. data/crates/wreq-util/tests/support/mod.rs +55 -0
  55. data/crates/wreq-util/tests/support/server.rs +232 -0
  56. data/examples/emulate_request.rb +1 -1
  57. data/lib/wreq.rb +20 -18
  58. data/lib/wreq_ruby/body.rb +3 -0
  59. data/lib/wreq_ruby/client.rb +18 -18
  60. data/lib/wreq_ruby/cookie.rb +2 -2
  61. data/lib/wreq_ruby/emulate.rb +3 -1
  62. data/lib/wreq_ruby/header.rb +205 -114
  63. data/lib/wreq_ruby/response.rb +28 -3
  64. data/src/emulate.rs +1 -0
  65. data/src/error.rs +2 -6
  66. data/src/header/helper.rs +112 -0
  67. data/src/header.rs +198 -133
  68. data/test/header_test.rb +228 -192
  69. data/test/stream_test.rb +36 -0
  70. metadata +54 -1
@@ -58,6 +58,7 @@ module Wreq
58
58
  Chrome147 = nil
59
59
  Chrome148 = nil
60
60
  Chrome149 = nil
61
+ Chrome150 = nil
61
62
 
62
63
  Edge101 = nil
63
64
  Edge122 = nil
@@ -154,7 +155,7 @@ module Wreq
154
155
  Opera128 = nil
155
156
  Opera129 = nil
156
157
  Opera130 = nil
157
- Opera131 = nils
158
+ Opera131 = nil
158
159
  end
159
160
 
160
161
  unless method_defined?(:to_s)
@@ -225,6 +226,7 @@ module Wreq
225
226
  # @param platform [Wreq::Platform, nil] Operating system platform to emulate
226
227
  # @param http2 [Boolean] Whether HTTP/2 support is enabled
227
228
  # @param headers [Boolean] Whether default emulation headers are enabled
229
+ # @return [Wreq::Emulation] Configured emulation settings
228
230
  def self.new(profile: nil, platform: nil, http2: true, headers: true)
229
231
  end
230
232
  end
@@ -2,202 +2,293 @@
2
2
 
3
3
  unless defined?(Wreq)
4
4
  module Wreq
5
- # HTTP headers collection.
5
+ # A mutable, case-insensitive collection of HTTP headers.
6
6
  #
7
- # Provides efficient access to HTTP headers with case-insensitive lookups.
8
- # Headers are created by the native extension and cannot be directly instantiated.
7
+ # Header names are normalized by the native header map and lookups are
8
+ # case-insensitive. Use the `orig_headers` request option when exact wire
9
+ # casing or header order is required. Duplicate values are stored as
10
+ # separate header occurrences.
9
11
  #
10
- # All header names are case-insensitive. Multiple values for the same header
11
- # are supported through the `get_all` method.
12
+ # @example Build and query headers
13
+ # headers = Wreq::Headers.new(
14
+ # "Accept" => ["application/json", "text/plain"],
15
+ # content_type: "application/json"
16
+ # )
17
+ # headers["accept"] # => ["application/json", "text/plain"]
18
+ # headers[:content_type] # => "application/json"
12
19
  #
13
- # @example Accessing response headers
14
- # response = Wreq.get("https://example.com")
15
- # headers = response.headers
16
- # content_type = headers["Content-Type"]
17
- # content_type = headers.get("content-type") # Same, case-insensitive
18
- #
19
- # @example Getting all values for a header
20
- # accept_values = headers.get_all("Accept")
21
- # # => ["application/json", "text/html"]
22
- #
23
- # @example Modifying headers
24
- # headers.set("X-Custom-Header", "value")
25
- # headers["Authorization"] = "Bearer token"
26
- # headers.append("Accept", "application/xml")
27
- #
28
- # @example Iterating headers
20
+ # @example Iterate over every occurrence
29
21
  # headers.each do |name, value|
30
22
  # puts "#{name}: #{value}"
31
23
  # end
32
- #
33
- # @example Converting to hash
34
- # hash = headers.to_h
35
- # hash["content-type"] # => "text/html"
36
24
  class Headers
37
- # Create a new empty Headers collection.
25
+ include Enumerable
26
+
27
+ # Create an empty collection or copy header pairs from a source.
38
28
  #
39
- # @return [Wreq::Headers] New headers instance
29
+ # @param source [Hash, Wreq::Headers, Enumerable] A hash, another
30
+ # headers collection, or an enumerable that yields name-value pairs.
31
+ # Omit this argument to create an empty collection.
32
+ # @return [Wreq::Headers]
33
+ # @raise [Wreq::BuilderError] if the source does not contain valid pairs
40
34
  # @example
41
- # headers = Wreq::Headers.new
42
- # headers.set("Content-Type", "application/json")
43
- def self.new
35
+ # Wreq::Headers.new
36
+ # Wreq::Headers.new("Accept" => "application/json")
37
+ # Wreq::Headers.new([[:content_type, "application/json"]])
38
+ def self.new(*args)
44
39
  end
45
40
 
46
- # Get a header value by name (case-insensitive).
41
+ # Return the first value for a header.
47
42
  #
48
- # Returns the first value if multiple values exist for the same header.
49
- #
50
- # @param name [String] Header name (case-insensitive)
51
- # @return [String, nil] Header value, or nil if not found
43
+ # @param name [String, Symbol] Header name
44
+ # @return [String, nil] The first value, or nil when the name is missing
52
45
  # @example
53
- # headers.get("Content-Type") # => "application/json"
54
- # headers.get("content-type") # => "application/json" (same)
55
- # headers.get("X-Nonexistent") # => nil
46
+ # headers.get("content-type") # => "application/json"
47
+ # headers.get(:missing) # => nil
56
48
  def get(name)
57
49
  end
58
50
 
59
- # Get all values for a header name (case-insensitive).
51
+ # Return a header using collection-style value semantics.
52
+ #
53
+ # A missing name returns nil, one occurrence returns a String, and
54
+ # multiple occurrences return an Array<String>.
60
55
  #
61
- # Useful when a header can have multiple values (e.g., Accept, Set-Cookie).
56
+ # @param name [String, Symbol] Header name
57
+ # @return [String, Array<String>, nil]
58
+ # @example
59
+ # headers["accept"] # => "application/json"
60
+ # headers["set-cookie"] # => ["a=1", "b=2"]
61
+ def [](name)
62
+ end
63
+
64
+ # Return every value for a header.
62
65
  #
63
- # @param name [String] Header name (case-insensitive)
64
- # @return [Array<String>] All values for this header (empty array if not found)
66
+ # @param name [String, Symbol] Header name
67
+ # @return [Array<String>] Values in insertion order, or an empty array
65
68
  # @example
66
- # headers.get_all("Accept")
67
- # # => ["application/json", "text/html", "application/xml"]
68
- # headers.get_all("X-Nonexistent") # => []
69
+ # headers.get_all("set-cookie") # => ["a=1", "b=2"]
70
+ # headers.get_all(:missing) # => []
69
71
  def get_all(name)
70
72
  end
71
73
 
72
- # Set a header value, replacing any existing values.
74
+ # Set one or more values, replacing every existing occurrence.
75
+ #
76
+ # Array values are stored as separate occurrences and are not joined. An
77
+ # empty Array removes the header.
73
78
  #
74
- # @param name [String] Header name
75
- # @param value [String] Header value
79
+ # @param name [String, Symbol] Header name
80
+ # @param value [String, Array<String>] Header value or values
76
81
  # @return [void]
77
- # @raise [Wreq::BuilderError] if name or value contains invalid characters
82
+ # @raise [Wreq::BuilderError] if a name or value is invalid
78
83
  # @example
79
- # headers.set("Content-Type", "application/json")
80
- # headers.set("X-Custom-Header", "my-value")
84
+ # headers.set("Accept", ["application/json", "text/plain"])
81
85
  def set(name, value)
82
86
  end
83
87
 
84
- # Append a header value without replacing existing values.
88
+ # Set one or more values and return the assigned value.
85
89
  #
86
- # Adds a new value for the header, preserving any existing values.
87
- # Useful for headers that can have multiple values.
90
+ # @param name [String, Symbol] Header name
91
+ # @param value [String, Array<String>] Header value or values
92
+ # @return [String, Array<String>] The assigned value
93
+ # @example
94
+ # headers[:content_type] = "application/json"
95
+ def []=(name, value)
96
+ end
97
+
98
+ # Append one or more values without replacing existing occurrences.
88
99
  #
89
- # @param name [String] Header name
90
- # @param value [String] Header value to append
100
+ # @param name [String, Symbol] Header name
101
+ # @param value [String, Array<String>] Header value or values
91
102
  # @return [void]
92
- # @raise [Wreq::BuilderError] if name or value contains invalid characters
103
+ # @raise [Wreq::BuilderError] if a name or value is invalid
93
104
  # @example
94
- # headers.set("Accept", "application/json")
95
- # headers.append("Accept", "text/html")
96
- # headers.get_all("Accept") # => ["application/json", "text/html"]
105
+ # headers.append("Set-Cookie", ["a=1", "b=2"])
97
106
  def append(name, value)
98
107
  end
99
108
 
100
- # Remove all values for a header name.
109
+ # Return a header value, a fallback, or the result of a block.
101
110
  #
102
- # @param name [String] Header name (case-insensitive)
103
- # @return [String, nil] The removed value (first one if multiple), or nil
111
+ # @param name [String, Symbol] Header name
112
+ # @param default [Object] Optional fallback for a missing name
113
+ # @yieldparam name [String, Symbol] The missing name
114
+ # @return [String, Array<String>, Object]
115
+ # @raise [KeyError] if the name is missing and no fallback is provided
104
116
  # @example
105
- # headers.remove("Authorization") # => "Bearer token"
106
- # headers.remove("X-Nonexistent") # => nil
107
- def remove(name)
117
+ # headers.fetch("accept", "*/*")
118
+ # headers.fetch(:missing) { |name| "missing: #{name}" }
119
+ def fetch(name, default = nil)
108
120
  end
109
121
 
110
- # Check if a header exists (case-insensitive).
122
+ # Remove every occurrence for a header.
111
123
  #
112
- # @param name [String] Header name
113
- # @return [Boolean] true if the header exists
124
+ # @param name [String, Symbol] Header name
125
+ # @return [String, nil] The first removed value, or nil when missing
114
126
  # @example
115
- # headers.contains?("Content-Type") # => true
116
- # headers.contains?("X-Missing") # => false
127
+ # headers.remove("authorization") # => "Bearer token"
128
+ def remove(name)
129
+ end
130
+
131
+ # Remove every occurrence for a header. Alias for {#remove}.
132
+ #
133
+ # @param name [String, Symbol] Header name
134
+ # @return [String, nil] The first removed value, or nil when missing
135
+ def delete(name)
136
+ end
137
+
138
+ # Check whether a header exists.
139
+ #
140
+ # @param name [String, Symbol] Header name
141
+ # @return [Boolean]
117
142
  def contains?(name)
118
143
  end
119
144
 
120
- # Check if a header key exists (alias for {#contains?}).
145
+ # Check whether a header exists. Alias for {#contains?}.
121
146
  #
122
- # @param name [String] Header name
123
- # @return [Boolean] true if the header exists
124
- # @example
125
- # headers.key?("Accept") # => true
147
+ # @param name [String, Symbol] Header name
148
+ # @return [Boolean]
126
149
  def key?(name)
127
150
  end
128
151
 
129
- # Get the number of headers.
152
+ # Return the number of header occurrences.
130
153
  #
131
- # @return [Integer] Total number of unique header names
132
- # @example
133
- # headers.length # => 12
154
+ # This can be greater than `keys.length` when a name has multiple values.
155
+ #
156
+ # @return [Integer]
134
157
  def length
135
158
  end
136
159
 
137
- # Check if there are no headers.
160
+ # Return the number of header occurrences. Alias for {#length}.
138
161
  #
139
- # @return [Boolean] true if no headers exist
140
- # @example
141
- # headers.empty? # => false
162
+ # @return [Integer]
163
+ def size
164
+ end
165
+
166
+ # Check whether the collection has no header occurrences.
167
+ #
168
+ # @return [Boolean]
142
169
  def empty?
143
170
  end
144
171
 
145
- # Remove all headers.
172
+ # Remove every header occurrence.
146
173
  #
147
- # @return [void]
148
- # @example
149
- # headers.clear
150
- # headers.empty? # => true
174
+ # @return [Wreq::Headers] self
151
175
  def clear
152
176
  end
153
177
 
154
- # Get all header names.
178
+ # Return each unique header name.
155
179
  #
156
- # @return [Array<String>] Array of header names (lowercase)
157
- # @example
158
- # headers.keys
159
- # # => ["content-type", "accept", "user-agent", "authorization"]
180
+ # @return [Array<String>]
160
181
  def keys
161
182
  end
162
183
 
163
- # Get all header values.
164
- #
165
- # Returns one value per header (the first if multiple values exist).
184
+ # Return every header value.
166
185
  #
167
- # @return [Array<String>] Array of header values
168
- # @example
169
- # headers.values
170
- # # => ["application/json", "text/html", "Mozilla/5.0", "Bearer token"]
186
+ # @return [Array<String>]
171
187
  def values
172
188
  end
173
189
 
174
- # Iterate over headers.
175
- #
176
- # Yields each header name and value pair. If a header has multiple values,
177
- # only the first is yielded.
190
+ # Iterate over every header occurrence.
178
191
  #
179
- # @yieldparam name [String] Header name (lowercase)
192
+ # @yieldparam name [String] Normalized lowercase header name
180
193
  # @yieldparam value [String] Header value
181
- # @return [Enumerator, self] Returns enumerator if no block given, self otherwise
182
- # @example With block
183
- # headers.each do |name, value|
184
- # puts "#{name}: #{value}"
185
- # end
186
- # @example Without block
187
- # enum = headers.each
188
- # enum.to_a # => [["content-type", "text/html"], ...]
194
+ # @return [Enumerator, Wreq::Headers] An Enumerator without a block,
195
+ # otherwise self
196
+ # @example
197
+ # headers.each.to_a
189
198
  def each
190
199
  end
191
200
 
201
+ # Convert every occurrence to name-value pairs.
202
+ #
203
+ # @return [Array<Array(String, String)>]
204
+ # @example
205
+ # headers.to_a # => [["accept", "application/json"], ...]
206
+ def to_a
207
+ end
208
+
209
+ # Convert unique names to a Hash.
210
+ #
211
+ # Hash values use the same nil, String, or Array shape as {#[]}.
212
+ #
213
+ # @return [Hash{String => String, Array<String>}]
214
+ # @example
215
+ # headers.to_h # => {"accept" => "application/json"}
216
+ def to_h
217
+ end
218
+
219
+ # Convert unique names to a Hash. Alias for {#to_h}.
220
+ #
221
+ # @return [Hash{String => String, Array<String>}]
222
+ def to_hash
223
+ end
224
+
192
225
  # Convert headers to a string representation.
226
+ #
227
+ # @return [String] String representation of the headers
193
228
  def to_s
194
229
  end
230
+
231
+ # Return a compact representation for debugging.
232
+ #
233
+ # @return [String]
234
+ # @example
235
+ # headers.inspect # => "#<Wreq::Headers [2 headers]>"
236
+ def inspect
237
+ end
195
238
  end
196
239
  end
197
240
  end
198
241
 
242
+ # ======================== Ruby API Extensions ========================
243
+
199
244
  module Wreq
200
245
  class Headers
246
+ FETCH_UNDEFINED = Object.new.freeze
247
+ private_constant :FETCH_UNDEFINED
248
+
249
+ alias delete remove
250
+ alias size length
251
+
252
+ # Return a header value, a fallback, or the result of a block.
253
+ #
254
+ # The block takes precedence when both a default and block are provided.
255
+ #
256
+ # @param name [String, Symbol] Header name
257
+ # @param default [Object] Optional fallback for a missing name
258
+ # @yieldparam name [String, Symbol] The missing name
259
+ # @return [String, Array<String>, Object]
260
+ # @raise [KeyError] if the name is missing and no fallback is provided
261
+ def fetch(name, default = FETCH_UNDEFINED)
262
+ value = self[name]
263
+ return value unless value.nil?
264
+ return yield(name) if block_given?
265
+ return default unless default.equal?(FETCH_UNDEFINED)
266
+
267
+ raise KeyError, "key not found: #{name.inspect}"
268
+ end
269
+
270
+ # Convert every header occurrence to a name-value pair.
271
+ #
272
+ # @return [Array<Array(String, String)>]
273
+ def to_a
274
+ each.to_a
275
+ end
276
+
277
+ # Convert unique normalized names to a Hash.
278
+ #
279
+ # A name with one occurrence maps to a String, while multiple occurrences
280
+ # map to an Array<String>.
281
+ #
282
+ # @return [Hash{String => String, Array<String>}]
283
+ def to_h
284
+ keys.to_h { |name| [name, self[name]] }
285
+ end
286
+
287
+ alias to_hash to_h
288
+
289
+ # Return a compact representation for debugging.
290
+ #
291
+ # @return [String]
201
292
  def inspect
202
293
  "#<Wreq::Headers [#{length} headers]>"
203
294
  end
@@ -64,6 +64,19 @@ unless defined?(Wreq)
64
64
  def content_length
65
65
  end
66
66
 
67
+ # Get the response headers.
68
+ #
69
+ # Header names are case-insensitive. Use {Wreq::Headers#get_all} to get
70
+ # every value when a header appears more than once. Each call returns a
71
+ # fresh, mutable snapshot. Changing that snapshot does not change the
72
+ # response or a later snapshot, and object identity is not guaranteed.
73
+ #
74
+ # @return [Wreq::Headers] Response headers
75
+ # @example
76
+ # response.headers.get("content-type") # => "application/json"
77
+ def headers
78
+ end
79
+
67
80
  # Get the local socket address.
68
81
  #
69
82
  # @return [String, nil] Local address (e.g., "127.0.0.1:54321"), or nil
@@ -80,6 +93,18 @@ unless defined?(Wreq)
80
93
  def remote_addr
81
94
  end
82
95
 
96
+ # Get cookies parsed from the response's `Set-Cookie` headers.
97
+ #
98
+ # Invalid `Set-Cookie` values are skipped.
99
+ #
100
+ # @return [Array<Wreq::Cookie>] Parsed response cookies
101
+ # @example
102
+ # response.cookies.each do |cookie|
103
+ # puts "#{cookie.name}=#{cookie.value}"
104
+ # end
105
+ def cookies
106
+ end
107
+
83
108
  # Get the response bytes as a binary string.
84
109
  # @return [String] Response body as binary data
85
110
  # @example
@@ -91,13 +116,13 @@ unless defined?(Wreq)
91
116
  # Get the response body as text with a specific charset.
92
117
  # This method allows you to specify a default encoding
93
118
  # to use when decoding the response body.
94
- # # @param default_encoding [String] Default encoding to use (e.g., "UTF-8")
95
- # # @return [String] Response body decoded as text using the specified encoding
119
+ # @param default_encoding [String] Default encoding to use (e.g., "UTF-8")
120
+ # @return [String] Response body decoded as text using the specified encoding
96
121
  # @example
97
122
  # html = response.text("ISO-8859-1")
98
123
  # puts html
99
124
  # @raise [Wreq::DecodingError] if body cannot be decoded with the specified encoding
100
- def text(default_encoding: "UTF-8")
125
+ def text(default_encoding = "UTF-8")
101
126
  end
102
127
 
103
128
  # Parse the response body as JSON.
data/src/emulate.rs CHANGED
@@ -50,6 +50,7 @@ define_ruby_enum!(
50
50
  Chrome147,
51
51
  Chrome148,
52
52
  Chrome149,
53
+ Chrome150,
53
54
 
54
55
  Edge101,
55
56
  Edge122,
data/src/error.rs CHANGED
@@ -72,17 +72,14 @@ define_exception!(DECODING_ERROR, "DecodingError", exception_runtime_error);
72
72
  // Configuration and builder errors
73
73
  define_exception!(BUILDER_ERROR, "BuilderError", exception_runtime_error);
74
74
 
75
- // Thread interruption error
76
- define_exception!(INTERRUPT_ERROR, "InterruptError", exception_interrupt);
77
-
78
75
  /// Memory error constant
79
76
  pub fn memory_error() -> MagnusError {
80
77
  MagnusError::new(ruby!().get_inner(&MEMORY), RACE_CONDITION_ERROR_MSG)
81
78
  }
82
79
 
83
- /// Thread interruption error (raised when Thread.kill cancels a request)
80
+ /// Create a Ruby thread interruption error.
84
81
  pub fn interrupt_error() -> MagnusError {
85
- MagnusError::new(ruby!().get_inner(&INTERRUPT_ERROR), "request interrupted")
82
+ MagnusError::new(ruby!().exception_interrupt(), "request interrupted")
86
83
  }
87
84
 
88
85
  /// LocalJumpError for methods that require a Ruby block.
@@ -159,5 +156,4 @@ pub fn include(ruby: &Ruby) {
159
156
  Lazy::force(&BODY_ERROR, ruby);
160
157
  Lazy::force(&DECODING_ERROR, ruby);
161
158
  Lazy::force(&BUILDER_ERROR, ruby);
162
- Lazy::force(&INTERRUPT_ERROR, ruby);
163
159
  }
@@ -0,0 +1,112 @@
1
+ //! Ruby value conversion helpers for `Wreq::Headers`.
2
+
3
+ use bytes::Bytes;
4
+ use http::{HeaderName, HeaderValue};
5
+ use magnus::{Error, RArray, RString, Symbol, TryConvert, Value, prelude::*, typed_data::Obj};
6
+
7
+ use crate::error::{
8
+ header_name_error_to_magnus, header_value_error_to_magnus, type_value_error_to_magnus,
9
+ };
10
+
11
+ use super::Headers;
12
+
13
+ /// Maximum number of field-value occurrences supported by `HeaderMap`.
14
+ const MAX_HEADER_ENTRIES: usize = 1 << 15;
15
+
16
+ /// Build a header collection from a Ruby source object.
17
+ ///
18
+ /// Accepts another `Wreq::Headers`, a Hash, or any object whose `to_a` result
19
+ /// contains two-element name-value pairs. Array values are delegated to
20
+ /// [`Headers::append`] so each value remains a separate occurrence.
21
+ pub(super) fn from_source(source: Value) -> Result<Headers, Error> {
22
+ if let Ok(headers) = Obj::<Headers>::try_convert(source) {
23
+ return Ok((*headers).clone());
24
+ }
25
+ if !source.respond_to("to_a", false)? {
26
+ return Err(type_value_error_to_magnus(
27
+ "Expected Headers, a Hash, or an enumerable of pairs",
28
+ ));
29
+ }
30
+
31
+ let pairs: RArray = source.funcall_public("to_a", ())?;
32
+ let headers = Headers::default();
33
+ for pair in pairs {
34
+ let pair = RArray::try_convert(pair)
35
+ .map_err(|_| type_value_error_to_magnus("Expected each header entry to be a pair"))?;
36
+ if pair.len() != 2 {
37
+ return Err(type_value_error_to_magnus(
38
+ "Expected each header entry to contain a name and value",
39
+ ));
40
+ }
41
+
42
+ headers.append(pair.entry(0)?, pair.entry(1)?)?;
43
+ }
44
+ Ok(headers)
45
+ }
46
+
47
+ /// Convert a Ruby String or Symbol into a normalized HTTP header name.
48
+ ///
49
+ /// Symbol underscores are changed to hyphens before [`HeaderName`] validates
50
+ /// and normalizes the name. Other Ruby types produce `Wreq::BuilderError`.
51
+ pub(super) fn parse_header_name(value: Value) -> Result<HeaderName, Error> {
52
+ let name = match (RString::from_value(value), Symbol::from_value(value)) {
53
+ (Some(name), _) => name.to_bytes(),
54
+ (None, Some(name)) => Bytes::from(name.name()?.replace('_', "-")),
55
+ (None, None) => {
56
+ return Err(type_value_error_to_magnus(
57
+ "Expected a String or Symbol header name",
58
+ ));
59
+ }
60
+ };
61
+ HeaderName::from_bytes(name.as_ref()).map_err(header_name_error_to_magnus)
62
+ }
63
+
64
+ /// Convert a Ruby String or Array of Strings into validated header values.
65
+ ///
66
+ /// Each Array element becomes one [`HeaderValue`]. An empty Array therefore
67
+ /// produces no values, allowing `set` to remove a header and `append` to do
68
+ /// nothing.
69
+ pub(super) fn parse_header_values(value: Value) -> Result<Vec<HeaderValue>, Error> {
70
+ if let Some(values) = RArray::from_value(value) {
71
+ values.into_iter().map(parse_header_value).collect()
72
+ } else {
73
+ Ok(vec![parse_header_value(value)?])
74
+ }
75
+ }
76
+
77
+ /// Convert one Ruby String into a validated HTTP header value.
78
+ ///
79
+ /// Invalid Ruby types and bytes rejected by [`HeaderValue`] are mapped to
80
+ /// `Wreq::BuilderError`.
81
+ fn parse_header_value(value: Value) -> Result<HeaderValue, Error> {
82
+ let value = RString::try_convert(value)
83
+ .map_err(|_| type_value_error_to_magnus("Expected a String header value"))?;
84
+ HeaderValue::from_maybe_shared(value.to_bytes()).map_err(header_value_error_to_magnus)
85
+ }
86
+
87
+ /// Validate the resulting number of header occurrences before a mutation.
88
+ ///
89
+ /// `current` is the collection length, `replaced` is the number of existing
90
+ /// occurrences removed by `set`, and `added` is the incoming value count.
91
+ /// Checked arithmetic prevents overflow; an invalid calculation or a result
92
+ /// above the native [`HeaderMap`](http::HeaderMap) limit returns
93
+ /// `Wreq::BuilderError` without mutating the collection.
94
+ pub(super) fn ensure_header_count(
95
+ current: usize,
96
+ replaced: usize,
97
+ added: usize,
98
+ ) -> Result<(), Error> {
99
+ let count = current
100
+ .checked_sub(replaced)
101
+ .and_then(|count| count.checked_add(added));
102
+ if count.is_some_and(|count| count <= MAX_HEADER_ENTRIES) {
103
+ Ok(())
104
+ } else {
105
+ Err(header_count_error())
106
+ }
107
+ }
108
+
109
+ /// Build the error returned when the native header map reaches its entry limit.
110
+ pub(super) fn header_count_error() -> Error {
111
+ type_value_error_to_magnus("Header collection exceeds 32,768 entries")
112
+ }