wreq 1.2.5 → 1.2.6

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +20 -13
  3. data/Cargo.toml +10 -2
  4. data/crates/wreq-util/src/emulate/macros.rs +12 -10
  5. data/crates/wreq-util/src/emulate/profile/chrome/header.rs +18 -3
  6. data/crates/wreq-util/src/emulate/profile/firefox/header.rs +16 -0
  7. data/crates/wreq-util/src/emulate/profile/opera/header.rs +6 -7
  8. data/crates/wreq-util/tests/client.rs +103 -1
  9. data/lib/wreq.rb +70 -45
  10. data/lib/wreq_ruby/body.rb +29 -11
  11. data/lib/wreq_ruby/client.rb +88 -55
  12. data/lib/wreq_ruby/cookie.rb +79 -19
  13. data/lib/wreq_ruby/emulate.rb +74 -6
  14. data/lib/wreq_ruby/error.rb +5 -7
  15. data/lib/wreq_ruby/http.rb +74 -0
  16. data/lib/wreq_ruby/response.rb +3 -0
  17. data/script/build_windows_gnu.ps1 +6 -0
  18. data/src/arch.rs +22 -0
  19. data/src/client/body/form.rs +2 -0
  20. data/src/client/body/json.rs +47 -14
  21. data/src/client/body/stream.rs +147 -43
  22. data/src/client/body.rs +11 -15
  23. data/src/client/param.rs +7 -7
  24. data/src/client/req.rs +87 -47
  25. data/src/client/resp.rs +23 -24
  26. data/src/client.rs +310 -230
  27. data/src/cookie.rs +280 -87
  28. data/src/emulate.rs +85 -50
  29. data/src/error.rs +198 -41
  30. data/src/extractor.rs +16 -76
  31. data/src/header.rs +146 -23
  32. data/src/http.rs +62 -33
  33. data/src/lib.rs +26 -20
  34. data/src/macros.rs +71 -46
  35. data/src/options.rs +284 -0
  36. data/src/rt.rs +22 -11
  37. data/src/serde/de/array_deserializer.rs +48 -0
  38. data/src/serde/de/array_enumerator.rs +59 -0
  39. data/src/serde/de/deserializer.rs +307 -0
  40. data/src/serde/de/enum_deserializer.rs +47 -0
  41. data/src/serde/de/hash_deserializer.rs +89 -0
  42. data/src/serde/de/number_deserializer.rs +51 -0
  43. data/src/serde/de/variant_deserializer.rs +101 -0
  44. data/src/serde/de.rs +33 -0
  45. data/src/serde/error.rs +146 -0
  46. data/src/serde/ser/enums.rs +14 -0
  47. data/src/serde/ser/map_serializer.rs +56 -0
  48. data/src/serde/ser/seq_serializer.rs +71 -0
  49. data/src/serde/ser/serializer.rs +194 -0
  50. data/src/serde/ser/struct_serializer.rs +106 -0
  51. data/src/serde/ser/struct_variant_serializer.rs +44 -0
  52. data/src/serde/ser/tuple_variant_serializer.rs +41 -0
  53. data/src/serde/ser.rs +18 -0
  54. data/src/serde/tests.rs +237 -0
  55. data/src/serde.rs +202 -0
  56. data/test/body_sender_test.rb +246 -0
  57. data/test/cookie_test.rb +211 -10
  58. data/test/json_precision_test.rb +209 -0
  59. data/test/option_validation_test.rb +311 -0
  60. data/test/value_semantics_test.rb +238 -0
  61. metadata +25 -2
  62. data/src/header/helper.rs +0 -112
data/test/cookie_test.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "test_helper"
4
+ require "bigdecimal"
5
+ require "open3"
6
+ require "rbconfig"
4
7
 
5
8
  class CookieTest < Minitest::Test
6
9
  def setup
@@ -41,6 +44,14 @@ class CookieTest < Minitest::Test
41
44
  assert_equal true, (c.secure || c.secure?)
42
45
  end
43
46
 
47
+ def test_add_rejects_invalid_cookie_type
48
+ error = assert_raises(TypeError) do
49
+ @jar.add(Object.new, @base_url)
50
+ end
51
+
52
+ assert_equal "cookie must be a Wreq::Cookie or String", error.message
53
+ end
54
+
44
55
  def test_add_multiple_and_remove
45
56
  @jar.add("a=1; Path=/", @base_url)
46
57
  @jar.add("b=2; Path=/", @base_url)
@@ -84,7 +95,9 @@ class CookieTest < Minitest::Test
84
95
  @jar.add("exp=1; Expires=#{t.gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT")}; Path=/", @base_url)
85
96
  c2 = @jar.get_all.find { |c| c.name == "exp" }
86
97
  assert c2
87
- # expires returns Float (unix seconds) or nil
98
+ # expires_at returns Time. expires remains available for numeric callers.
99
+ assert_kind_of Time, c2.expires_at
100
+ assert_predicate c2.expires_at, :utc?
88
101
  if (e = c2.expires)
89
102
  assert_kind_of Float, e
90
103
  assert_operator e, :>, Time.now.to_f - 1_000_000 # sanity bound
@@ -104,15 +117,18 @@ class CookieTest < Minitest::Test
104
117
  assert_nil c.domain
105
118
  assert_nil c.max_age
106
119
  assert_nil c.expires
120
+ assert_nil c.expires_at
107
121
 
108
122
  assert_equal false, (c.http_only || c.http_only?)
109
123
  assert_equal false, (c.secure || c.secure?)
110
124
  assert_equal false, c.same_site_lax?
111
125
  assert_equal false, c.same_site_strict?
126
+ assert_nil c.same_site
127
+ assert_equal "sid=abc", c.to_s
112
128
  end
113
129
 
114
130
  def test_cookie_new_full_attributes
115
- exp = Time.now.to_f + 7200.0
131
+ exp = Time.utc(2030, 1, 1, 0, 0, Rational(123_456_789, 1_000_000_000))
116
132
  c = Wreq::Cookie.new("sess", "v",
117
133
  domain: "example.com",
118
134
  path: "/",
@@ -130,16 +146,199 @@ class CookieTest < Minitest::Test
130
146
  # Max-Age returns seconds as Integer
131
147
  assert_equal 3600, c.max_age
132
148
 
133
- # Expires returns Float seconds-since-epoch (with small tolerance)
134
- assert c.expires
135
- assert_kind_of Float, c.expires
136
- assert_in_delta exp, c.expires, 2.0
149
+ assert_equal exp, c.expires_at
150
+ assert_predicate c.expires_at, :utc?
151
+ assert_in_delta exp.to_f, c.expires, 1e-6
137
152
 
138
153
  assert_equal true, (c.http_only || c.http_only?)
139
154
  assert_equal true, (c.secure || c.secure?)
140
- # constructor currently sets SameSite to none
141
155
  assert_equal true, c.same_site_lax?
142
156
  assert_equal false, c.same_site_strict?
157
+ assert_equal Wreq::SameSite::Lax, c.same_site
158
+ end
159
+
160
+ def test_same_site_reader_distinguishes_none_from_unset
161
+ cookie = Wreq::Cookie.new("same-site", "value", same_site: Wreq::SameSite::None)
162
+
163
+ assert_equal Wreq::SameSite::None, cookie.same_site
164
+ refute cookie.same_site_lax?
165
+ refute cookie.same_site_strict?
166
+ end
167
+
168
+ def test_cookie_new_uses_shared_keyword_validation
169
+ cookie = Wreq::Cookie.new("sid", "abc", **{"path" => "/"})
170
+ assert_equal "/", cookie.path
171
+
172
+ secret = "must-not-appear"
173
+ unknown_error = assert_raises(ArgumentError) do
174
+ Wreq::Cookie.new("sid", "abc", domian: secret)
175
+ end
176
+ assert_includes unknown_error.message, ":domian"
177
+ refute_includes unknown_error.message, secret
178
+
179
+ duplicate_options = {path: "/one"}
180
+ duplicate_options["path"] = "/two"
181
+ duplicate_error = assert_raises(ArgumentError) do
182
+ Wreq::Cookie.new("sid", "abc", **duplicate_options)
183
+ end
184
+ assert_includes duplicate_error.message, "duplicate option: :path"
185
+ end
186
+
187
+ def test_expires_accepts_past_time
188
+ expiration = Time.at(Rational(-5, 4)).utc
189
+ cookie = Wreq::Cookie.new("past", "value", expires: expiration)
190
+
191
+ assert_equal expiration, cookie.expires_at
192
+ assert_in_delta(-1.25, cookie.expires, 1e-9)
193
+ end
194
+
195
+ def test_expires_accepts_integer_and_fractional_timestamps
196
+ [
197
+ 1_893_456_000,
198
+ 1_893_456_000.125,
199
+ Rational(-5, 4),
200
+ BigDecimal("1893456000.125")
201
+ ].each do |timestamp|
202
+ cookie = Wreq::Cookie.new("timestamp", "value", expires: timestamp)
203
+
204
+ assert_kind_of Time, cookie.expires_at
205
+ assert_predicate cookie.expires_at, :utc?
206
+ assert_in_delta timestamp.to_f, cookie.expires_at.to_f, 1e-6
207
+ assert_in_delta timestamp.to_f, cookie.expires, 1e-6
208
+ end
209
+ end
210
+
211
+ def test_expires_normalizes_time_to_utc_without_losing_precision
212
+ expiration = Time.new(
213
+ 2030,
214
+ 1,
215
+ 1,
216
+ 12,
217
+ 34,
218
+ Rational(123_456_789, 1_000_000_000),
219
+ "+09:00"
220
+ )
221
+ actual = Wreq::Cookie.new("offset", "value", expires: expiration).expires_at
222
+
223
+ assert_predicate actual, :utc?
224
+ assert_equal expiration.to_r, actual.to_r
225
+ end
226
+
227
+ def test_expires_rejects_non_finite_timestamps
228
+ [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |timestamp|
229
+ error = assert_raises(ArgumentError) do
230
+ Wreq::Cookie.new("invalid", "value", expires: timestamp)
231
+ end
232
+
233
+ assert_match(/expires.*finite/, error.message)
234
+ end
235
+ end
236
+
237
+ def test_expires_rejects_unrepresentable_times
238
+ expirations = [
239
+ -(2**63),
240
+ 2**63 - 1,
241
+ -Float::MAX,
242
+ Float::MAX,
243
+ Time.utc(10_000, 1, 1)
244
+ ]
245
+
246
+ expirations.each do |expiration|
247
+ error = assert_raises(RangeError) do
248
+ Wreq::Cookie.new("invalid", "value", expires: expiration)
249
+ end
250
+
251
+ assert_match(/expires.*supported range/, error.message)
252
+ end
253
+ end
254
+
255
+ def test_max_age_accepts_signed_boundaries_without_wrapping
256
+ [-(2**63), -1, 0, 2**63 - 1].each do |max_age|
257
+ cookie = Wreq::Cookie.new("max-age", "value", max_age: max_age)
258
+
259
+ assert_equal max_age, cookie.max_age
260
+ end
261
+
262
+ [-(2**63) - 1, 2**63].each do |max_age|
263
+ assert_raises(RangeError) do
264
+ Wreq::Cookie.new("max-age", "value", max_age: max_age)
265
+ end
266
+ end
267
+ end
268
+
269
+ def test_non_positive_max_age_removes_cookie_from_jar
270
+ [-1, 0].each do |max_age|
271
+ jar = Wreq::Jar.new
272
+ jar.add("session=old; Path=/", @base_url)
273
+ deletion = Wreq::Cookie.new("session", "gone", path: "/", max_age: max_age)
274
+
275
+ jar.add(deletion, @base_url)
276
+
277
+ assert_equal max_age, deletion.max_age
278
+ assert_empty jar.get_all
279
+ end
280
+ end
281
+
282
+ def test_past_expiration_removes_cookie_from_jar
283
+ @jar.add("session=old; Path=/", @base_url)
284
+ deletion = Wreq::Cookie.new(
285
+ "session",
286
+ "gone",
287
+ path: "/",
288
+ expires: Time.at(-1).utc
289
+ )
290
+
291
+ @jar.add(deletion, @base_url)
292
+
293
+ assert_empty @jar.get_all
294
+ end
295
+
296
+ def test_expiration_regressions_exit_subprocess_normally
297
+ lib_dir = File.expand_path("../lib", __dir__)
298
+ script = <<~RUBY
299
+ require "wreq"
300
+
301
+ past = Wreq::Cookie.new("past", "value", expires: -1.0)
302
+ abort "past timestamp was not retained" unless past.expires_at == Time.at(-1).utc
303
+
304
+ [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |timestamp|
305
+ begin
306
+ Wreq::Cookie.new("invalid", "value", expires: timestamp)
307
+ rescue ArgumentError
308
+ next
309
+ end
310
+
311
+ abort "non-finite timestamp did not raise ArgumentError"
312
+ end
313
+
314
+ [Float::MAX, -Float::MAX].each do |timestamp|
315
+ begin
316
+ Wreq::Cookie.new("invalid", "value", expires: timestamp)
317
+ rescue RangeError
318
+ next
319
+ end
320
+
321
+ abort "unrepresentable finite timestamp did not raise RangeError"
322
+ end
323
+
324
+ [-(2**63) - 1, 2**63].each do |max_age|
325
+ begin
326
+ Wreq::Cookie.new("invalid", "value", max_age: max_age)
327
+ rescue RangeError
328
+ next
329
+ end
330
+
331
+ abort "out-of-range Max-Age did not raise RangeError"
332
+ end
333
+
334
+ puts "ok"
335
+ RUBY
336
+
337
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", lib_dir, "-e", script)
338
+
339
+ assert status.success?, "subprocess failed with #{status.inspect}: #{stderr}"
340
+ assert_equal "ok\n", stdout
341
+ refute_match(/panicked|fatal|access violation|cannot convert float seconds to Duration/i, stderr)
143
342
  end
144
343
 
145
344
  def test_same_site_flags_from_parsed_header
@@ -148,10 +347,12 @@ class CookieTest < Minitest::Test
148
347
  @jar.add("s2=1; Path=/; SameSite=Lax", @base_url)
149
348
 
150
349
  cookies = @jar.get_all
151
- h = cookies.to_h { |ck| [ck.name, [ck.same_site_strict?, ck.same_site_lax?]] }
350
+ h = cookies.to_h do |cookie|
351
+ [cookie.name, [cookie.same_site, cookie.same_site_strict?, cookie.same_site_lax?]]
352
+ end
152
353
 
153
- assert_equal [true, false], h["s1"]
154
- assert_equal [false, true], h["s2"]
354
+ assert_equal [Wreq::SameSite::Strict, true, false], h["s1"]
355
+ assert_equal [Wreq::SameSite::Lax, false, true], h["s2"]
155
356
  end
156
357
 
157
358
  def test_request_uncompressed_cookies
@@ -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