wreq 1.1.0 → 1.2.1

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.
data/src/header.rs CHANGED
@@ -3,11 +3,17 @@ use std::cell::RefCell;
3
3
  use bytes::Bytes;
4
4
  use http::{HeaderMap, HeaderName, HeaderValue};
5
5
  use magnus::{
6
- Error, Module, Object, RArray, RModule, Ruby, block::Yield, function, method,
7
- typed_data::Inspect,
6
+ Error, Module, Object, RArray, RHash, RModule, RString, Ruby, TryConvert, Value,
7
+ block::Yield,
8
+ function, method,
9
+ r_hash::ForEach,
10
+ typed_data::{Inspect, Obj},
8
11
  };
12
+ use wreq::header::OrigHeaderMap;
9
13
 
10
- use crate::error::{header_name_error_to_magnus, header_value_error_to_magnus};
14
+ use crate::error::{
15
+ header_name_error_to_magnus, header_value_error_to_magnus, type_value_error_to_magnus,
16
+ };
11
17
 
12
18
  /// HTTP headers collection with read and write operations.
13
19
  ///
@@ -15,7 +21,17 @@ use crate::error::{header_name_error_to_magnus, header_value_error_to_magnus};
15
21
  /// accessing, modifying, and iterating over header name-value pairs.
16
22
  #[derive(Clone, Default)]
17
23
  #[magnus::wrap(class = "Wreq::Headers", free_immediately, size)]
18
- pub struct Headers(RefCell<HeaderMap>);
24
+ pub struct Headers(pub RefCell<HeaderMap>);
25
+
26
+ /// A map from header names to their original casing as received in an HTTP message.
27
+ pub struct OrigHeaders(pub OrigHeaderMap);
28
+
29
+ struct HeaderIter {
30
+ inner: http::header::IntoIter<HeaderValue>,
31
+ next_name: Option<HeaderName>,
32
+ }
33
+
34
+ // ===== impl Headers =====
19
35
 
20
36
  impl Headers {
21
37
  /// Create a new empty Headers instance.
@@ -132,11 +148,49 @@ impl From<HeaderMap> for Headers {
132
148
  }
133
149
  }
134
150
 
135
- struct HeaderIter {
136
- inner: http::header::IntoIter<HeaderValue>,
137
- next_name: Option<HeaderName>,
151
+ impl TryConvert for Headers {
152
+ fn try_convert(value: Value) -> Result<Self, Error> {
153
+ if let Some(rhash) = RHash::from_value(value) {
154
+ let mut headers = HeaderMap::new();
155
+
156
+ rhash.foreach(|name: RString, value: RString| {
157
+ let name = HeaderName::from_bytes(&name.to_bytes())
158
+ .map_err(header_name_error_to_magnus)?;
159
+ let value = HeaderValue::from_maybe_shared(value.to_bytes())
160
+ .map_err(header_value_error_to_magnus)?;
161
+ headers.insert(name, value);
162
+
163
+ Ok(ForEach::Continue)
164
+ })?;
165
+
166
+ return Ok(Self::from(headers));
167
+ }
168
+
169
+ Obj::<Headers>::try_convert(value)
170
+ .map(|headers| headers.0.clone())
171
+ .map(Self)
172
+ }
138
173
  }
139
174
 
175
+ // ===== impl OrigHeaders =====
176
+
177
+ impl TryConvert for OrigHeaders {
178
+ fn try_convert(value: magnus::Value) -> Result<Self, magnus::Error> {
179
+ let mut map = OrigHeaderMap::new();
180
+
181
+ let rarray = RArray::from_value(value)
182
+ .ok_or_else(|| type_value_error_to_magnus("Expected an array of strings"))?;
183
+
184
+ for value in rarray.into_iter().flat_map(RString::from_value) {
185
+ map.insert(value.to_bytes());
186
+ }
187
+
188
+ Ok(Self(map))
189
+ }
190
+ }
191
+
192
+ // ===== impl HeaderIter =====
193
+
140
194
  impl Iterator for HeaderIter {
141
195
  type Item = (Bytes, Bytes);
142
196
  fn next(&mut self) -> Option<Self::Item> {
data/src/http.rs CHANGED
@@ -1,4 +1,4 @@
1
- use magnus::{Error, Module, RModule, Ruby, method, typed_data::Inspect};
1
+ use magnus::{Error, Module, RModule, Ruby, TryConvert, Value, method, typed_data::Inspect};
2
2
 
3
3
  define_ruby_enum!(
4
4
  /// An HTTP version.
@@ -41,6 +41,20 @@ impl Version {
41
41
  pub fn to_s(&self) -> String {
42
42
  self.into_ffi().inspect()
43
43
  }
44
+
45
+ /// Value-based equality for Ruby (`==`).
46
+ #[inline]
47
+ pub fn equals(&self, other: Value) -> bool {
48
+ <&Version>::try_convert(other)
49
+ .map(|other| *self == *other)
50
+ .unwrap_or(false)
51
+ }
52
+ }
53
+
54
+ impl TryConvert for Version {
55
+ fn try_convert(value: magnus::Value) -> Result<Self, magnus::Error> {
56
+ <&Version>::try_convert(value).cloned()
57
+ }
44
58
  }
45
59
 
46
60
  // ===== impl StatusCode =====
@@ -113,6 +127,7 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> {
113
127
  version_class.const_set("HTTP_2", Version::HTTP_2)?;
114
128
  version_class.const_set("HTTP_3", Version::HTTP_3)?;
115
129
  version_class.define_method("to_s", method!(Version::to_s, 0))?;
130
+ version_class.define_method("==", method!(Version::equals, 1))?;
116
131
 
117
132
  let status_code_class = gem_module.define_class("StatusCode", ruby.class_object())?;
118
133
  status_code_class.define_method("as_int", method!(StatusCode::as_int, 0))?;
data/src/macros.rs CHANGED
@@ -14,6 +14,11 @@ macro_rules! apply_option {
14
14
  $builder = $builder.$method(value.0);
15
15
  }
16
16
  };
17
+ (set_if_some_into_inner, $builder:expr, $option:expr, $method:ident) => {
18
+ if let Some(value) = $option.take() {
19
+ $builder = $builder.$method(value.0.into_inner());
20
+ }
21
+ };
17
22
  (set_if_some_map, $builder:expr, $option:expr, $method:ident, $transform:expr) => {
18
23
  if let Some(value) = $option.take() {
19
24
  $builder = $builder.$method($transform(value));
data/src/rt.rs CHANGED
@@ -27,21 +27,3 @@ where
27
27
  })
28
28
  })
29
29
  }
30
-
31
- /// Block on a future to completion on the global Tokio runtime,
32
- /// returning `None` if cancelled via the provided `CancelFlag`.
33
- #[inline]
34
- pub fn maybe_block_on<F, T>(future: F) -> F::Output
35
- where
36
- F: Future<Output = Option<T>>,
37
- {
38
- gvl::nogvl_cancellable(|flag| {
39
- RUNTIME.block_on(async move {
40
- tokio::select! {
41
- biased;
42
- _ = flag.cancelled() => None,
43
- result = future => result,
44
- }
45
- })
46
- })
47
- }
@@ -36,7 +36,7 @@ class ClientCookieProviderTest < Minitest::Test
36
36
 
37
37
  def test_prepopulated_jar_is_used_by_client
38
38
  # pre-populate jar
39
- @jar.add_cookie_str("pref=1; Path=/", "#{HOST}/")
39
+ @jar.add("pref=1; Path=/", "#{HOST}/")
40
40
 
41
41
  res = @client.get("#{HOST}/cookies")
42
42
  assert_equal 200, res.code
data/test/cookie_test.rb CHANGED
@@ -19,9 +19,9 @@ class CookieTest < Minitest::Test
19
19
  assert_equal 0, cookies.length
20
20
  end
21
21
 
22
- def test_add_cookie_str_and_get_all
22
+ def test_add_and_get_all
23
23
  set_cookie = "sid=abc123; Path=/; Domain=example.com; HttpOnly; Secure"
24
- @jar.add_cookie_str(set_cookie, @base_url)
24
+ @jar.add(set_cookie, @base_url)
25
25
 
26
26
  cookies = @jar.get_all
27
27
  assert_kind_of Array, cookies
@@ -42,9 +42,9 @@ class CookieTest < Minitest::Test
42
42
  end
43
43
 
44
44
  def test_add_multiple_and_remove
45
- @jar.add_cookie_str("a=1; Path=/", @base_url)
46
- @jar.add_cookie_str("b=2; Path=/", @base_url)
47
- @jar.add_cookie_str("c=3; Path=/", @base_url)
45
+ @jar.add("a=1; Path=/", @base_url)
46
+ @jar.add("b=2; Path=/", @base_url)
47
+ @jar.add("c=3; Path=/", @base_url)
48
48
 
49
49
  cookies = @jar.get_all
50
50
  assert_equal 3, cookies.length
@@ -58,8 +58,8 @@ class CookieTest < Minitest::Test
58
58
  end
59
59
 
60
60
  def test_clear
61
- @jar.add_cookie_str("x=1; Path=/", @base_url)
62
- @jar.add_cookie_str("y=2; Path=/", @base_url)
61
+ @jar.add("x=1; Path=/", @base_url)
62
+ @jar.add("y=2; Path=/", @base_url)
63
63
  refute_empty @jar.get_all
64
64
 
65
65
  @jar.clear
@@ -69,7 +69,7 @@ class CookieTest < Minitest::Test
69
69
  def test_max_age_and_expires_optional
70
70
  # Max-Age only
71
71
  @jar.clear
72
- @jar.add_cookie_str("ma=1; Max-Age=3600; Path=/", @base_url)
72
+ @jar.add("ma=1; Max-Age=3600; Path=/", @base_url)
73
73
  c1 = @jar.get_all.find { |c| c.name == "ma" }
74
74
  assert c1
75
75
  # can be nil or Integer; just ensure responds and is truthy integer
@@ -81,7 +81,7 @@ class CookieTest < Minitest::Test
81
81
  # Expires only
82
82
  @jar.clear
83
83
  t = Time.now + 3600
84
- @jar.add_cookie_str("exp=1; Expires=#{t.gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT")}; Path=/", @base_url)
84
+ @jar.add("exp=1; Expires=#{t.gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT")}; Path=/", @base_url)
85
85
  c2 = @jar.get_all.find { |c| c.name == "exp" }
86
86
  assert c2
87
87
  # expires returns Float (unix seconds) or nil
@@ -144,8 +144,8 @@ class CookieTest < Minitest::Test
144
144
 
145
145
  def test_same_site_flags_from_parsed_header
146
146
  @jar.clear
147
- @jar.add_cookie_str("s1=1; Path=/; SameSite=Strict", @base_url)
148
- @jar.add_cookie_str("s2=1; Path=/; SameSite=Lax", @base_url)
147
+ @jar.add("s1=1; Path=/; SameSite=Strict", @base_url)
148
+ @jar.add("s2=1; Path=/; SameSite=Lax", @base_url)
149
149
 
150
150
  cookies = @jar.get_all
151
151
  h = cookies.to_h { |ck| [ck.name, [ck.same_site_strict?, ck.same_site_lax?]] }
@@ -154,13 +154,27 @@ class CookieTest < Minitest::Test
154
154
  assert_equal [false, true], h["s2"]
155
155
  end
156
156
 
157
- def test_request_cookie_value_percent_encoding
158
- raw_value = "hello world?"
157
+ def test_request_uncompressed_cookies
159
158
  client = Wreq::Client.new
160
159
  resp = client.get(
161
- "http://localhost:8080/cookies",
162
- cookies: {"mykey" => raw_value}
160
+ "https://httpbin.io/cookies",
161
+ cookies: {"foo" => "bar", "baz" => "qux"}
163
162
  )
164
- assert_includes resp.text, "hello%20world%3F"
163
+ json = resp.json
164
+ assert_instance_of Hash, json
165
+ assert_equal "bar", json["foo"]
166
+ assert_equal "qux", json["baz"]
167
+ end
168
+
169
+ def test_request_compressed_cookies
170
+ client = Wreq::Client.new
171
+ resp = client.get(
172
+ "https://httpbin.io/cookies",
173
+ cookies: "foo=bar; baz=qux"
174
+ )
175
+ json = resp.json
176
+ assert_instance_of Hash, json
177
+ assert_equal "bar", json["foo"]
178
+ assert_equal "qux", json["baz"]
165
179
  end
166
180
  end
@@ -4,18 +4,18 @@ require "test_helper"
4
4
 
5
5
  class EmulationTest < Minitest::Test
6
6
  def test_all_emulation_device_constants_are_non_nil
7
- Wreq::EmulationDevice.constants.each do |name|
8
- const = Wreq::EmulationDevice.const_get(name)
9
- assert_instance_of Wreq::EmulationDevice, const,
10
- "#{name} should be EmulationDevice, got #{const.inspect}"
7
+ Wreq::Profile.constants.each do |name|
8
+ const = Wreq::Profile.const_get(name)
9
+ assert_instance_of Wreq::Profile, const,
10
+ "#{name} should be Profile, got #{const.inspect}"
11
11
  end
12
12
  end
13
13
 
14
14
  def test_all_emulation_os_constants_are_non_nil
15
- Wreq::EmulationOS.constants.each do |name|
16
- const = Wreq::EmulationOS.const_get(name)
17
- assert_instance_of Wreq::EmulationOS, const,
18
- "#{name} should be EmulationOS, got #{const.inspect}"
15
+ Wreq::Platform.constants.each do |name|
16
+ const = Wreq::Platform.const_get(name)
17
+ assert_instance_of Wreq::Platform, const,
18
+ "#{name} should be Platform, got #{const.inspect}"
19
19
  end
20
20
  end
21
21
  end
@@ -82,7 +82,10 @@ class ErrorHandlingTest < Minitest::Test
82
82
  Wreq.get(url, proxy: proxy, timeout: 5)
83
83
  flunk "Expected proxy connection error but got response"
84
84
  rescue => e
85
- assert_instance_of Wreq::ProxyConnectionError, e
85
+ assert(
86
+ e.is_a?(Wreq::ProxyConnectionError) || e.is_a?(Wreq::RequestError),
87
+ "Expected ProxyConnectionError or RequestError, got #{e.class}: #{e.message}"
88
+ )
86
89
  end
87
90
  end
88
91
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class InspectTest < Minitest::Test
6
+ # ---- Headers ----
7
+
8
+ def test_headers_inspect_empty
9
+ headers = Wreq::Headers.new
10
+ assert_equal "#<Wreq::Headers [0 headers]>", headers.inspect
11
+ end
12
+
13
+ def test_headers_inspect_with_entries
14
+ headers = Wreq::Headers.new
15
+ headers.set("Content-Type", "text/html")
16
+ headers.set("Accept", "application/json")
17
+ assert_equal "#<Wreq::Headers [2 headers]>", headers.inspect
18
+ end
19
+
20
+ # ---- Cookie ----
21
+
22
+ def test_cookie_inspect_minimal
23
+ c = Wreq::Cookie.new("sid", "secret123")
24
+ result = c.inspect
25
+ assert_includes result, "#<Wreq::Cookie"
26
+ assert_includes result, "sid"
27
+ refute_includes result, "secret123"
28
+ assert result.end_with?(">")
29
+ end
30
+
31
+ def test_cookie_inspect_with_domain_and_path
32
+ c = Wreq::Cookie.new("sid", "val",
33
+ domain: "example.com",
34
+ path: "/app")
35
+ result = c.inspect
36
+ assert_includes result, "domain=example.com"
37
+ assert_includes result, "path=/app"
38
+ end
39
+
40
+ def test_cookie_inspect_with_flags
41
+ c = Wreq::Cookie.new("sid", "val",
42
+ secure: true,
43
+ http_only: true)
44
+ result = c.inspect
45
+ assert_includes result, "secure"
46
+ assert_includes result, "http_only"
47
+ end
48
+
49
+ def test_cookie_inspect_omits_nil_attributes
50
+ c = Wreq::Cookie.new("sid", "val")
51
+ result = c.inspect
52
+ refute_includes result, "domain="
53
+ refute_includes result, "path="
54
+ refute_includes result, "secure"
55
+ refute_includes result, "http_only"
56
+ end
57
+
58
+ # ---- Jar ----
59
+
60
+ def test_jar_inspect_empty
61
+ jar = Wreq::Jar.new
62
+ assert_equal "#<Wreq::Jar [0 cookies]>", jar.inspect
63
+ end
64
+
65
+ def test_jar_inspect_with_cookies
66
+ jar = Wreq::Jar.new
67
+ jar.add("a=1; Path=/", "https://example.com")
68
+ jar.add("b=2; Path=/", "https://example.com")
69
+ assert_equal "#<Wreq::Jar [2 cookies]>", jar.inspect
70
+ end
71
+
72
+ # ---- Client ----
73
+
74
+ def test_client_inspect
75
+ client = Wreq::Client.new
76
+ assert_equal "#<Wreq::Client>", client.inspect
77
+ end
78
+
79
+ def test_client_inspect_with_options
80
+ client = Wreq::Client.new(timeout: 30, gzip: true)
81
+ assert_equal "#<Wreq::Client>", client.inspect
82
+ end
83
+
84
+ # ---- Response ----
85
+
86
+ def test_response_to_s_returns_body
87
+ response = Wreq.get("http://localhost:8080/json")
88
+ assert_equal response.text, response.to_s
89
+ end
90
+
91
+ def test_response_inspect_format
92
+ response = Wreq.get("http://localhost:8080/json")
93
+ result = response.inspect
94
+ assert result.start_with?("#<Wreq::Response")
95
+ assert_includes result, "200"
96
+ assert result.end_with?(">")
97
+ end
98
+
99
+ # ---- StatusCode ----
100
+
101
+ def test_status_code_inspect
102
+ response = Wreq.get("http://localhost:8080/status/200")
103
+ result = response.status.inspect
104
+ assert result.start_with?("#<Wreq::StatusCode")
105
+ assert_includes result, response.status.to_s
106
+ assert result.end_with?(">")
107
+ end
108
+
109
+ # ---- Version ----
110
+
111
+ def test_version_inspect_from_constant
112
+ v = Wreq::Version::HTTP_11
113
+ result = v.inspect
114
+ assert result.start_with?("#<Wreq::Version")
115
+ assert_includes result, v.to_s
116
+ assert result.end_with?(">")
117
+ end
118
+
119
+ def test_version_inspect_from_response
120
+ response = Wreq.get("http://localhost:8080/get")
121
+ result = response.version.inspect
122
+ assert result.start_with?("#<Wreq::Version")
123
+ assert result.end_with?(">")
124
+ end
125
+ end
@@ -0,0 +1,115 @@
1
+ require "test_helper"
2
+
3
+ class OrigHeaderTest < Minitest::Test
4
+ URL = "https://tls.browserleaks.com/http1"
5
+
6
+ CASES = [
7
+ {
8
+ name: "mixed_case_descending",
9
+ headers: {
10
+ "X-Zeta-Token" => "zeta",
11
+ "x-alpha-key" => "alpha",
12
+ "X-MiXeD-CaSe" => "mixed"
13
+ },
14
+ orig_headers: ["X-Zeta-Token", "x-alpha-key", "X-MiXeD-CaSe"]
15
+ },
16
+ {
17
+ name: "reverse_alpha_order",
18
+ headers: {
19
+ "X-Third" => "3",
20
+ "X-Second" => "2",
21
+ "X-First" => "1"
22
+ },
23
+ orig_headers: ["X-Third", "X-Second", "X-First"]
24
+ },
25
+ {
26
+ name: "preserve_weird_casing",
27
+ headers: {
28
+ "x-a" => "a",
29
+ "X-B" => "b",
30
+ "x-C" => "c"
31
+ },
32
+ orig_headers: ["x-C", "x-a", "X-B"]
33
+ },
34
+ {
35
+ name: "interleaved_tokens",
36
+ headers: {
37
+ "X-Token-3" => "v3",
38
+ "X-Token-1" => "v1",
39
+ "X-Token-2" => "v2"
40
+ },
41
+ orig_headers: ["X-Token-1", "X-Token-2", "X-Token-3"]
42
+ }
43
+ ].freeze
44
+
45
+ def test_client_default_orig_headers_preserves_header_order_in_multiple_shuffled_cases
46
+ CASES.each do |kase|
47
+ client = Wreq::Client.new(
48
+ headers: kase[:headers],
49
+ orig_headers: kase[:orig_headers]
50
+ )
51
+
52
+ response = client.get(URL, version: Wreq::Version::HTTP_11)
53
+ assert_equal 200, response.code, "case=#{kase[:name]}"
54
+
55
+ echoed_headers = extract_http1_headers(response.json, kase[:name])
56
+ assert_header_order(echoed_headers, kase[:orig_headers], kase[:name])
57
+ assert_header_values(echoed_headers, kase[:headers], kase[:name])
58
+ end
59
+ end
60
+
61
+ def test_module_request_orig_headers_preserves_header_order_in_multiple_shuffled_cases
62
+ CASES.each do |kase|
63
+ response = Wreq.get(
64
+ URL,
65
+ headers: kase[:headers],
66
+ orig_headers: kase[:orig_headers],
67
+ version: Wreq::Version::HTTP_11
68
+ )
69
+ assert_equal 200, response.code, "case=#{kase[:name]}"
70
+
71
+ echoed_headers = extract_http1_headers(response.json, kase[:name])
72
+ assert_header_order(echoed_headers, kase[:orig_headers], kase[:name])
73
+ assert_header_values(echoed_headers, kase[:headers], kase[:name])
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def extract_http1_headers(json, case_name)
80
+ http1 = fetch_by_name(json, "http1")
81
+ refute_nil http1, "case=#{case_name}: expected JSON key 'http1', got #{json.keys.inspect}"
82
+
83
+ headers = fetch_by_name(http1, "headers")
84
+ refute_nil headers, "case=#{case_name}: expected JSON key 'http1.headers'"
85
+ headers
86
+ end
87
+
88
+ def fetch_by_name(hash_like, key_name)
89
+ return hash_like[key_name] if hash_like.respond_to?(:key?) && hash_like.key?(key_name)
90
+ return hash_like[key_name.to_sym] if hash_like.respond_to?(:key?) && hash_like.key?(key_name.to_sym)
91
+
92
+ pair = hash_like.find { |k, _| k.to_s == key_name }
93
+ pair&.last
94
+ end
95
+
96
+ def assert_header_order(echoed_headers, ordered_names, case_name)
97
+ echoed_keys = echoed_headers.keys
98
+ positions = ordered_names.map do |expected_name|
99
+ index = echoed_keys.index(expected_name)
100
+ refute_nil index, "case=#{case_name}: expected header to exist in echo: #{expected_name}"
101
+ index
102
+ end
103
+
104
+ assert_equal positions.sort, positions,
105
+ "case=#{case_name}: expected header order #{ordered_names.inspect}, got keys #{echoed_keys.inspect}"
106
+ end
107
+
108
+ def assert_header_values(echoed_headers, expected_headers, case_name)
109
+ expected_headers.each do |name, expected_value|
110
+ assert echoed_headers.key?(name),
111
+ "case=#{case_name}: expected exact-case header name #{name}, got #{echoed_headers.keys.inspect}"
112
+ assert_equal expected_value, echoed_headers[name]
113
+ end
114
+ end
115
+ end
data/test/request_test.rb CHANGED
@@ -6,6 +6,16 @@ class WreqHttpbinTest < Minitest::Test
6
6
  @client = Wreq::Client.new(timeout: 30)
7
7
  end
8
8
 
9
+ def test_spec_http1_version
10
+ response = Wreq.get("https://tls.browserleaks.com", version: Wreq::Version::HTTP_11)
11
+ assert_equal response.version, Wreq::Version::HTTP_11
12
+ end
13
+
14
+ def test_spec_http2_version
15
+ response = Wreq.get("https://tls.browserleaks.com", version: Wreq::Version::HTTP_2)
16
+ assert_equal response.version, Wreq::Version::HTTP_2
17
+ end
18
+
9
19
  def test_module_get_method
10
20
  response = Wreq.get("http://localhost:8080/get")
11
21
  assert_equal 200, response.code