wreq 1.2.4-x86_64-linux → 1.2.6-x86_64-linux

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/crates/wreq-util/.github/FUNDING.yml +15 -0
  3. data/crates/wreq-util/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  4. data/crates/wreq-util/.github/ISSUE_TEMPLATE/custom.md +10 -0
  5. data/crates/wreq-util/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  6. data/crates/wreq-util/.github/dependabot.yml +22 -0
  7. data/crates/wreq-util/.github/workflows/ci.yml +82 -0
  8. data/crates/wreq-util/.github/workflows/release-plz.yml +52 -0
  9. data/crates/wreq-util/.gitignore +24 -0
  10. data/crates/wreq-util/CHANGELOG.md +74 -0
  11. data/crates/wreq-util/LICENSE +201 -0
  12. data/crates/wreq-util/README.md +62 -0
  13. data/crates/wreq-util/release-plz.toml +2 -0
  14. data/crates/wreq-util/rustfmt.toml +5 -0
  15. data/examples/emulate_request.rb +1 -1
  16. data/lib/wreq.rb +72 -45
  17. data/lib/wreq_ruby/3.3/wreq_ruby.so +0 -0
  18. data/lib/wreq_ruby/3.4/wreq_ruby.so +0 -0
  19. data/lib/wreq_ruby/4.0/wreq_ruby.so +0 -0
  20. data/lib/wreq_ruby/body.rb +30 -9
  21. data/lib/wreq_ruby/client.rb +88 -55
  22. data/lib/wreq_ruby/cookie.rb +81 -21
  23. data/lib/wreq_ruby/emulate.rb +77 -7
  24. data/lib/wreq_ruby/error.rb +5 -7
  25. data/lib/wreq_ruby/header.rb +205 -114
  26. data/lib/wreq_ruby/http.rb +74 -0
  27. data/lib/wreq_ruby/response.rb +31 -3
  28. data/script/build_windows_gnu.ps1 +6 -0
  29. data/test/body_sender_test.rb +246 -0
  30. data/test/cookie_test.rb +211 -10
  31. data/test/header_test.rb +228 -192
  32. data/test/json_precision_test.rb +209 -0
  33. data/test/option_validation_test.rb +311 -0
  34. data/test/stream_test.rb +36 -0
  35. data/test/value_semantics_test.rb +238 -0
  36. metadata +19 -2
@@ -0,0 +1,209 @@
1
+ require "test_helper"
2
+ require "json"
3
+ require "socket"
4
+
5
+ class JsonPrecisionTest < Minitest::Test
6
+ INTEGER_BOUNDARIES = [
7
+ (2**53) - 1,
8
+ 2**53,
9
+ (2**63) - 1,
10
+ 2**63,
11
+ (2**64) - 1,
12
+ -((2**53) - 1),
13
+ -(2**53),
14
+ -((2**63) - 1),
15
+ -(2**63),
16
+ -(2**63) - 1,
17
+ -((2**64) - 1),
18
+ -(2**64),
19
+ 2**100,
20
+ -(2**100),
21
+ 2**256,
22
+ -(2**256)
23
+ ].freeze
24
+
25
+ def test_response_json_matches_json_parse_for_large_integers
26
+ payload = precision_payload
27
+
28
+ with_json_server(response_body: JSON.generate(payload)) do |url, _requests|
29
+ response = Wreq.get(url)
30
+ expected = JSON.parse(response.bytes)
31
+ actual = response.json
32
+
33
+ assert_equal payload, expected
34
+ assert_equal expected, actual
35
+ assert actual.fetch("integers").all? { |value| value.instance_of?(Integer) }
36
+ assert_instance_of Float, actual.fetch("fraction")
37
+ assert_instance_of Float, actual.fetch("exponent")
38
+ end
39
+ end
40
+
41
+ def test_request_json_preserves_large_integers_and_nested_values
42
+ payload = precision_payload
43
+
44
+ with_json_server do |url, requests|
45
+ response = Wreq.post(url, json: payload)
46
+ request = requests.pop
47
+
48
+ assert_equal "application/json", request.fetch(:headers).fetch("content-type")
49
+ assert_equal JSON.generate(payload), request.fetch(:body)
50
+ assert_equal payload, response.json
51
+ end
52
+ end
53
+
54
+ def test_request_json_nil_is_serialized_as_null
55
+ with_json_server do |url, requests|
56
+ response = Wreq.post(url, json: nil)
57
+ request = requests.pop
58
+
59
+ assert_equal "null", request.fetch(:body)
60
+ assert_nil response.json
61
+ end
62
+ end
63
+
64
+ def test_request_json_accepts_symbols_and_preserves_object_order
65
+ payload = {second: 2**100, first: :value}
66
+
67
+ with_json_server do |url, requests|
68
+ response = Wreq.post(url, json: payload)
69
+ request = requests.pop
70
+ actual = response.json
71
+
72
+ assert_equal JSON.generate(payload), request.fetch(:body)
73
+ assert_equal ["second", "first"], actual.keys
74
+ assert_equal({"second" => 2**100, "first" => "value"}, actual)
75
+ end
76
+ end
77
+
78
+ def test_request_json_enforces_documented_nesting_limit
79
+ accepted = 0
80
+ 100.times { accepted = [accepted] }
81
+
82
+ with_json_server do |url, requests|
83
+ Wreq.post(url, json: accepted)
84
+
85
+ assert_equal JSON.generate(accepted), requests.pop.fetch(:body)
86
+ end
87
+
88
+ error = assert_raises(Wreq::BuilderError) do
89
+ Wreq.post("http://127.0.0.1:1/", json: [accepted])
90
+ end
91
+ assert_match(/nesting exceeds 100 levels/, error.message)
92
+ end
93
+
94
+ def test_unsupported_request_json_raises_before_socket_io
95
+ server = TCPServer.new("127.0.0.1", 0)
96
+ port = server.addr[1]
97
+
98
+ error = assert_raises(Wreq::BuilderError) do
99
+ Wreq.post("http://127.0.0.1:#{port}/", json: {"value" => Float::NAN})
100
+ end
101
+
102
+ cyclic = []
103
+ cyclic << cyclic
104
+ nesting_error = assert_raises(Wreq::BuilderError) do
105
+ Wreq.post("http://127.0.0.1:#{port}/", json: cyclic)
106
+ end
107
+
108
+ cyclic_hash = {}
109
+ cyclic_hash["self"] = cyclic_hash
110
+ hash_nesting_error = assert_raises(Wreq::BuilderError) do
111
+ Wreq.post("http://127.0.0.1:#{port}/", json: cyclic_hash)
112
+ end
113
+
114
+ key_error = assert_raises(Wreq::BuilderError) do
115
+ Wreq.post("http://127.0.0.1:#{port}/", json: {1 => "value"})
116
+ end
117
+
118
+ assert_match(/non-finite/, error.message)
119
+ assert_match(/nesting/, nesting_error.message)
120
+ assert_match(/nesting/, hash_nesting_error.message)
121
+ assert_match(/keys/, key_error.message)
122
+ assert_equal :wait_readable, server.accept_nonblock(exception: false)
123
+ ensure
124
+ server&.close unless server&.closed?
125
+ end
126
+
127
+ def test_invalid_response_json_raises_decoding_error
128
+ with_json_server(response_body: '{"id":') do |url, _requests|
129
+ response = Wreq.get(url)
130
+
131
+ assert_raises(Wreq::DecodingError) { response.json }
132
+ end
133
+ end
134
+
135
+ def test_fractional_and_large_exponent_numbers_match_json_parse
136
+ source = '{"fraction":0.12345678901234567890123456789,"exponent":1e400}'
137
+
138
+ with_json_server(response_body: source) do |url, _requests|
139
+ response = Wreq.get(url)
140
+ expected = JSON.parse(response.bytes)
141
+ actual = response.json
142
+
143
+ assert_equal expected, actual
144
+ assert_instance_of Float, actual.fetch("fraction")
145
+ assert_equal Float::INFINITY, actual.fetch("exponent")
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def precision_payload
152
+ {
153
+ "integers" => INTEGER_BOUNDARIES,
154
+ "nested" => {
155
+ "array" => [2**100, {"negative" => -(2**100)}],
156
+ "object" => {"unsigned_64_max" => (2**64) - 1}
157
+ },
158
+ "shapes" => ["text", true, false, nil],
159
+ "fraction" => 1.25,
160
+ "exponent" => 1.0e40
161
+ }
162
+ end
163
+
164
+ def with_json_server(response_body: nil)
165
+ server = TCPServer.new("127.0.0.1", 0)
166
+ port = server.addr[1]
167
+ requests = Queue.new
168
+ thread = Thread.new do
169
+ socket = server.accept
170
+ begin
171
+ request_line = socket.gets
172
+ headers = read_headers(socket)
173
+ content_length = headers.fetch("content-length", "0").to_i
174
+ body = content_length.zero? ? "" : socket.read(content_length)
175
+ requests << {request_line: request_line, headers: headers, body: body}
176
+
177
+ response = response_body.nil? ? body : response_body
178
+ socket.write "HTTP/1.1 200 OK\r\n"
179
+ socket.write "Content-Type: application/json\r\n"
180
+ socket.write "Content-Length: #{response.bytesize}\r\n"
181
+ socket.write "Connection: close\r\n\r\n"
182
+ socket.write response
183
+ ensure
184
+ socket.close unless socket.closed?
185
+ end
186
+ rescue IOError, SystemCallError
187
+ nil
188
+ ensure
189
+ server.close unless server.closed?
190
+ end
191
+ thread.report_on_exception = false
192
+
193
+ yield "http://127.0.0.1:#{port}/", requests
194
+ ensure
195
+ server&.close unless server&.closed?
196
+ thread&.join(5)
197
+ end
198
+
199
+ def read_headers(socket)
200
+ headers = {}
201
+ while (line = socket.gets)
202
+ break if line == "\r\n"
203
+
204
+ name, value = line.split(":", 2)
205
+ headers[name.downcase] = value.strip
206
+ end
207
+ headers
208
+ end
209
+ end
@@ -0,0 +1,311 @@
1
+ require "test_helper"
2
+
3
+ class OptionValidationTest < Minitest::Test
4
+ INVALID_URL = "not a url"
5
+
6
+ def test_unknown_client_option_names_every_invalid_key_without_values
7
+ secret = "must-not-appear"
8
+
9
+ error = assert_raises(ArgumentError) do
10
+ Wreq::Client.new(timout: secret, history: secret)
11
+ end
12
+
13
+ assert_includes error.message, ":timout"
14
+ assert_includes error.message, ":history"
15
+ refute_includes error.message, secret
16
+ end
17
+
18
+ def test_unknown_options_are_checked_before_known_values
19
+ secret = "must-not-appear"
20
+
21
+ error = assert_raises(ArgumentError) do
22
+ Wreq::Client.new(timeout: secret, history: Object.new, timout: secret)
23
+ end
24
+
25
+ assert_includes error.message, ":history"
26
+ assert_includes error.message, ":timout"
27
+ refute_includes error.message, secret
28
+
29
+ conflict_error = assert_raises(ArgumentError) do
30
+ Wreq::Client.new(http1_only: true, http2_only: true, history: secret)
31
+ end
32
+ assert_includes conflict_error.message, ":history"
33
+ refute_includes conflict_error.message, secret
34
+ end
35
+
36
+ def test_unknown_request_options_raise_for_client_and_module_methods
37
+ client_error = assert_raises(ArgumentError) do
38
+ Wreq::Client.new.get(INVALID_URL, history: true)
39
+ end
40
+ module_error = assert_raises(ArgumentError) do
41
+ Wreq.get(INVALID_URL, history: true)
42
+ end
43
+
44
+ assert_includes client_error.message, ":history"
45
+ assert_includes module_error.message, ":history"
46
+ end
47
+
48
+ def test_hash_expansion_uses_the_same_validation
49
+ valid = {timeout: 1, allow_redirects: true, max_redirects: 2}
50
+ invalid = {timout: 1}
51
+
52
+ assert_instance_of Wreq::Client, Wreq::Client.new(**valid)
53
+ error = assert_raises(ArgumentError) { Wreq::Client.new(**invalid) }
54
+ assert_includes error.message, ":timout"
55
+ end
56
+
57
+ def test_string_option_keys_are_supported_and_duplicate_forms_are_rejected
58
+ assert_instance_of Wreq::Client, Wreq::Client.new({"timeout" => 1})
59
+
60
+ error = assert_raises(ArgumentError) do
61
+ Wreq::Client.new({:timeout => 1, "timeout" => 2})
62
+ end
63
+ assert_includes error.message, "duplicate option: :timeout"
64
+ end
65
+
66
+ def test_non_string_or_symbol_option_key_raises_type_error
67
+ error = assert_raises(TypeError) { Wreq::Client.new({1 => true}) }
68
+
69
+ assert_includes error.message, "option keys"
70
+ end
71
+
72
+ def test_hash_subclass_cannot_hide_unknown_options
73
+ options_class = Class.new(Hash) do
74
+ def keys
75
+ []
76
+ end
77
+ end
78
+ options = options_class.new
79
+ options[:timout] = 1
80
+
81
+ error = assert_raises(ArgumentError) { Wreq::Client.new(options) }
82
+ assert_includes error.message, ":timout"
83
+ end
84
+
85
+ def test_invalid_known_values_name_the_option
86
+ timeout_error = assert_raises(TypeError) do
87
+ Wreq::Client.new(timeout: "slow")
88
+ end
89
+ address_error = assert_raises(ArgumentError) do
90
+ Wreq::Client.new(local_address: "not-an-address")
91
+ end
92
+ proxy_error = assert_raises(TypeError) do
93
+ Wreq::Client.new(proxy: Object.new)
94
+ end
95
+ proxy_uri_error = assert_raises(Wreq::BuilderError) do
96
+ Wreq::Client.new(proxy: "invalid://")
97
+ end
98
+ orig_headers_error = assert_raises(Wreq::BuilderError) do
99
+ Wreq.get(INVALID_URL, orig_headers: ["Accept", Object.new])
100
+ end
101
+
102
+ assert_includes timeout_error.message, ":timeout"
103
+ assert_includes address_error.message, ":local_address"
104
+ assert_includes proxy_error.message, ":proxy"
105
+ assert_includes proxy_uri_error.message, ":proxy"
106
+ assert_includes orig_headers_error.message, ":orig_headers"
107
+ end
108
+
109
+ def test_consumed_body_sender_error_preserves_class_and_names_option
110
+ sender = Wreq::BodySender.new(1)
111
+
112
+ assert_raises(Wreq::BuilderError) do
113
+ Wreq.post(INVALID_URL, body: sender)
114
+ end
115
+ error = assert_raises(Wreq::MemoryError) do
116
+ Wreq.post(INVALID_URL, body: sender)
117
+ end
118
+
119
+ assert_includes error.message, ":body"
120
+ ensure
121
+ sender&.close
122
+ end
123
+
124
+ def test_option_conversion_preserves_raised_exception_class
125
+ source = Object.new
126
+ source.define_singleton_method(:to_a) { raise IOError, "boom" }
127
+
128
+ error = assert_raises(IOError) do
129
+ Wreq::Client.new(headers: source)
130
+ end
131
+
132
+ assert_includes error.message, ":headers"
133
+ assert_includes error.message, "boom"
134
+ end
135
+
136
+ def test_out_of_range_integer_names_the_option
137
+ error = assert_raises(ArgumentError) do
138
+ Wreq::Client.new(timeout: 2**256)
139
+ end
140
+
141
+ assert_includes error.message, ":timeout"
142
+ end
143
+
144
+ def test_invalid_request_value_names_the_option_before_network_io
145
+ error = assert_raises(TypeError) do
146
+ Wreq.get(INVALID_URL, cookies: Object.new)
147
+ end
148
+
149
+ assert_includes error.message, ":cookies"
150
+ end
151
+
152
+ def test_client_constructor_rejects_non_hash_and_extra_arguments
153
+ assert_raises(TypeError) { Wreq::Client.new("ignored") }
154
+ assert_raises(ArgumentError) { Wreq::Client.new({}, {}) }
155
+ end
156
+
157
+ def test_emulation_constructor_rejects_invalid_arguments_and_options
158
+ assert_raises(TypeError) { Wreq::Emulation.new("ignored") }
159
+ assert_raises(ArgumentError) { Wreq::Emulation.new({}, {}) }
160
+
161
+ error = assert_raises(ArgumentError) { Wreq::Emulation.new(unknown: true) }
162
+ assert_includes error.message, ":unknown"
163
+ end
164
+
165
+ def test_invalid_emulation_values_name_the_option
166
+ http2_error = assert_raises(TypeError) do
167
+ Wreq::Emulation.new(http2: "yes")
168
+ end
169
+ profile_error = assert_raises(TypeError) do
170
+ Wreq::Emulation.new(profile: Object.new)
171
+ end
172
+
173
+ assert_includes http2_error.message, ":http2"
174
+ assert_includes profile_error.message, ":profile"
175
+ end
176
+
177
+ def test_body_options_are_mutually_exclusive
178
+ error = assert_raises(ArgumentError) do
179
+ Wreq.post(INVALID_URL, body: "raw", form: {a: 1}, json: {b: 2})
180
+ end
181
+
182
+ assert_includes error.message, ":body"
183
+ assert_includes error.message, ":form"
184
+ assert_includes error.message, ":json"
185
+ end
186
+
187
+ def test_body_conflict_does_not_consume_sender
188
+ sender = Wreq::BodySender.new(1)
189
+
190
+ assert_raises(ArgumentError) do
191
+ Wreq.post(INVALID_URL, body: sender, json: {value: true})
192
+ end
193
+ assert_raises(Wreq::BuilderError) do
194
+ Wreq.post(INVALID_URL, body: sender)
195
+ end
196
+ ensure
197
+ sender&.close
198
+ end
199
+
200
+ def test_dependent_option_error_does_not_consume_sender
201
+ sender = Wreq::BodySender.new(1)
202
+
203
+ assert_raises(ArgumentError) do
204
+ Wreq.post(INVALID_URL, body: sender, max_redirects: 2)
205
+ end
206
+ assert_raises(Wreq::BuilderError) do
207
+ Wreq.post(INVALID_URL, body: sender)
208
+ end
209
+ ensure
210
+ sender&.close
211
+ end
212
+
213
+ def test_authentication_options_are_mutually_exclusive
214
+ error = assert_raises(ArgumentError) do
215
+ Wreq.get(
216
+ INVALID_URL,
217
+ auth: "secret",
218
+ bearer_auth: "secret",
219
+ basic_auth: ["user", "secret"]
220
+ )
221
+ end
222
+
223
+ assert_includes error.message, ":auth"
224
+ assert_includes error.message, ":bearer_auth"
225
+ assert_includes error.message, ":basic_auth"
226
+ refute_includes error.message, "secret"
227
+ end
228
+
229
+ def test_protocol_only_options_are_mutually_exclusive
230
+ error = assert_raises(ArgumentError) do
231
+ Wreq::Client.new(http1_only: true, http2_only: true)
232
+ end
233
+
234
+ assert_includes error.message, ":http1_only"
235
+ assert_includes error.message, ":http2_only"
236
+ end
237
+
238
+ def test_validation_reports_the_first_failed_rule
239
+ error = assert_raises(ArgumentError) do
240
+ Wreq::Client.new(
241
+ http1_only: true,
242
+ http2_only: true,
243
+ max_redirects: 2,
244
+ headers: Object.new
245
+ )
246
+ end
247
+
248
+ assert_includes error.message, ":http1_only"
249
+ assert_includes error.message, ":http2_only"
250
+ refute_includes error.message, ":max_redirects"
251
+ end
252
+
253
+ def test_explicit_proxy_and_no_proxy_are_mutually_exclusive
254
+ error = assert_raises(ArgumentError) do
255
+ Wreq::Client.new(proxy: "http://127.0.0.1:8080", no_proxy: true)
256
+ end
257
+
258
+ assert_includes error.message, ":proxy"
259
+ assert_includes error.message, ":no_proxy"
260
+ end
261
+
262
+ def test_max_redirects_requires_redirects_to_be_enabled
263
+ client_error = assert_raises(ArgumentError) do
264
+ Wreq::Client.new(max_redirects: 2)
265
+ end
266
+ disabled_error = assert_raises(ArgumentError) do
267
+ Wreq::Client.new(allow_redirects: false, max_redirects: 2)
268
+ end
269
+ request_error = assert_raises(ArgumentError) do
270
+ Wreq.get(INVALID_URL, max_redirects: 2)
271
+ end
272
+
273
+ [client_error, disabled_error, request_error].each do |error|
274
+ assert_includes error.message, ":max_redirects"
275
+ assert_includes error.message, ":allow_redirects"
276
+ end
277
+ end
278
+
279
+ def test_unsupported_platform_option_raises_argument_error
280
+ return if RUBY_PLATFORM.match?(/linux|android|fuchsia/)
281
+
282
+ error = assert_raises(ArgumentError) do
283
+ Wreq::Client.new(tcp_user_timeout: 1)
284
+ end
285
+ assert_instance_of ArgumentError, error
286
+ assert_includes error.message, ":tcp_user_timeout"
287
+ end
288
+
289
+ def test_windows_rejects_interface_for_client_and_request
290
+ return unless Gem.win_platform?
291
+
292
+ client_error = assert_raises(ArgumentError) do
293
+ Wreq::Client.new(interface: "Ethernet")
294
+ end
295
+ request_error = assert_raises(ArgumentError) do
296
+ Wreq.get(INVALID_URL, interface: "Ethernet")
297
+ end
298
+
299
+ assert_instance_of ArgumentError, client_error
300
+ assert_instance_of ArgumentError, request_error
301
+ assert_includes client_error.message, ":interface"
302
+ assert_includes request_error.message, ":interface"
303
+ end
304
+
305
+ def test_valid_nil_and_zero_option_construction
306
+ assert_instance_of Wreq::Client, Wreq::Client.new
307
+ assert_instance_of Wreq::Client,
308
+ Wreq::Client.new(proxy: nil, interface: nil, tcp_user_timeout: nil)
309
+ assert_instance_of Wreq::Emulation, Wreq::Emulation.new(profile: nil, http2: true)
310
+ end
311
+ end
data/test/stream_test.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "test_helper"
2
2
  require "socket"
3
+ require "timeout"
3
4
 
4
5
  class StreamTest < Minitest::Test
5
6
  def test_simple_push_stream
@@ -174,6 +175,41 @@ class StreamTest < Minitest::Test
174
175
  assert killed, "Connect+timeout phase should be interruptible"
175
176
  end
176
177
 
178
+ def test_thread_raise_propagates_ruby_interrupt
179
+ server = TCPServer.new("127.0.0.1", 0)
180
+ accepted = Queue.new
181
+ errors = Queue.new
182
+
183
+ server_thread = Thread.new do
184
+ socket = server.accept
185
+ accepted << true
186
+ sleep
187
+ ensure
188
+ socket&.close
189
+ end
190
+
191
+ request_thread = Thread.new do
192
+ Wreq.get("http://127.0.0.1:#{server.addr[1]}/", timeout: 60)
193
+ rescue Interrupt, StandardError => error
194
+ errors << error
195
+ end
196
+
197
+ Timeout.timeout(5) { accepted.pop }
198
+ request_thread.raise(Interrupt, "test interrupt")
199
+
200
+ assert request_thread.join(5), "Interrupted request thread should stop"
201
+
202
+ error = Timeout.timeout(1) { errors.pop }
203
+ assert_instance_of Interrupt, error
204
+ assert_equal "test interrupt", error.message
205
+ ensure
206
+ request_thread&.kill
207
+ request_thread&.join(1)
208
+ server_thread&.kill
209
+ server_thread&.join(1)
210
+ server&.close
211
+ end
212
+
177
213
  def test_thread_interrupt_body_reading
178
214
  url = "#{HTTPBIN_URL}/drip?duration=5&numbytes=5"
179
215
  thread = Thread.new do