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
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Profile and platform constants mirror the native enum variant names.
4
+ # standard:disable Naming/ConstantName
5
+
3
6
  module Wreq
4
7
  # Browser and client fingerprint profile enumeration backed by Rust.
5
8
  #
@@ -164,6 +167,29 @@ module Wreq
164
167
  def to_s
165
168
  end
166
169
  end
170
+
171
+ unless method_defined?(:==)
172
+ # Value-based equality.
173
+ # @param other [Object]
174
+ # @return [Boolean]
175
+ def ==(other)
176
+ end
177
+ end
178
+
179
+ unless method_defined?(:eql?)
180
+ # Strict equality for Hash key and Set member semantics.
181
+ # @param other [Object]
182
+ # @return [Boolean]
183
+ def eql?(other)
184
+ end
185
+ end
186
+
187
+ unless method_defined?(:hash)
188
+ # Hash value consistent with {#eql?} for use as Hash keys.
189
+ # @return [Integer]
190
+ def hash
191
+ end
192
+ end
167
193
  end
168
194
 
169
195
  # Operating system platform enumeration backed by Rust.
@@ -195,6 +221,36 @@ module Wreq
195
221
  def to_s
196
222
  end
197
223
  end
224
+
225
+ unless method_defined?(:to_sym)
226
+ # Returns the platform as a lowercase symbol (e.g. :windows, :linux).
227
+ # @return [Symbol]
228
+ def to_sym
229
+ end
230
+ end
231
+
232
+ unless method_defined?(:==)
233
+ # Value-based equality.
234
+ # @param other [Object]
235
+ # @return [Boolean]
236
+ def ==(other)
237
+ end
238
+ end
239
+
240
+ unless method_defined?(:eql?)
241
+ # Strict equality for Hash key and Set member semantics.
242
+ # @param other [Object]
243
+ # @return [Boolean]
244
+ def eql?(other)
245
+ end
246
+ end
247
+
248
+ unless method_defined?(:hash)
249
+ # Hash value consistent with {#eql?} for use as Hash keys.
250
+ # @return [Integer]
251
+ def hash
252
+ end
253
+ end
198
254
  end
199
255
 
200
256
  # Emulation option wrapper.
@@ -216,19 +272,31 @@ module Wreq
216
272
  #
217
273
  # @param profile [Wreq::Profile, nil] Fingerprint profile to emulate
218
274
  # @param platform [Wreq::Platform, nil] Operating system platform to emulate
219
- # @param http2 [Boolean] Whether HTTP/2 support is enabled
220
- # @param headers [Boolean] Whether default emulation headers are enabled
275
+ # @param http2 [Boolean, nil] Whether HTTP/2 emulation is enabled; defaults
276
+ # to true when omitted or nil
277
+ # @param headers [Boolean, nil] Whether default emulation headers are enabled;
278
+ # defaults to true when omitted or nil
279
+ # @return [Wreq::Emulation] Configured emulation settings
280
+ # @raise [ArgumentError] if an option is unknown or extra arguments are given
281
+ # @raise [TypeError] if the option argument is not a Hash or a value has the
282
+ # wrong Ruby type
221
283
  class Emulation
222
284
  # Native fields and methods are set by the extension.
223
285
  # This stub is for documentation only.
224
- unless method_defined?(:new)
286
+ unless singleton_methods(false).include?(:new)
225
287
  # @param profile [Wreq::Profile, nil] Fingerprint profile to emulate
226
288
  # @param platform [Wreq::Platform, nil] Operating system platform to emulate
227
- # @param http2 [Boolean] Whether HTTP/2 support is enabled
228
- # @param headers [Boolean] Whether default emulation headers are enabled
289
+ # @param http2 [Boolean, nil] Whether HTTP/2 emulation is enabled; defaults
290
+ # to true when omitted or nil
291
+ # @param headers [Boolean, nil] Whether default emulation headers are enabled;
292
+ # defaults to true when omitted or nil
229
293
  # @return [Wreq::Emulation] Configured emulation settings
230
- def self.new(profile: nil, platform: nil, http2: true, headers: true)
294
+ # @raise [ArgumentError] if an option is unknown or extra arguments are given
295
+ # @raise [TypeError] if an option or value has the wrong Ruby type
296
+ def self.new(**options)
231
297
  end
232
298
  end
233
299
  end
234
300
  end
301
+
302
+ # standard:enable Naming/ConstantName
@@ -88,7 +88,7 @@ unless defined?(Wreq)
88
88
  #
89
89
  # @example
90
90
  # begin
91
- # client = Wreq::Client.new(max_redirects: 3)
91
+ # client = Wreq::Client.new(allow_redirects: true, max_redirects: 3)
92
92
  # client.get("https://httpbin.io/redirect/10")
93
93
  # rescue Wreq::RedirectError => e
94
94
  # puts "Too many redirects: #{e.message}"
@@ -139,16 +139,14 @@ unless defined?(Wreq)
139
139
 
140
140
  # Configuration and builder errors
141
141
 
142
- # Client configuration is invalid.
142
+ # A native client or request configuration could not be built.
143
143
  #
144
- # Raised when the client is configured with invalid options.
144
+ # Raised when validated Ruby options cannot be represented by the native
145
+ # builder or request body.
145
146
  #
146
147
  # @example
147
148
  # begin
148
- # client = Wreq::Client.new(
149
- # proxy: "invalid://proxy",
150
- # timeout: -1
151
- # )
149
+ # client = Wreq::Client.new(proxy: "invalid://")
152
150
  # rescue Wreq::BuilderError => e
153
151
  # puts "Invalid configuration: #{e.message}"
154
152
  # end
@@ -25,6 +25,43 @@ module Wreq
25
25
  TRACE = nil # @return [Wreq::Method] HTTP TRACE method
26
26
  PATCH = nil # @return [Wreq::Method] HTTP PATCH method
27
27
  end
28
+
29
+ # Returns the HTTP method token (e.g. "GET", "POST").
30
+ # @return [String]
31
+ unless method_defined?(:to_s)
32
+ def to_s
33
+ end
34
+ end
35
+
36
+ # Returns the HTTP method as a lowercase symbol (e.g. :get, :post).
37
+ # @return [Symbol]
38
+ unless method_defined?(:to_sym)
39
+ def to_sym
40
+ end
41
+ end
42
+
43
+ # Value-based equality. Returns true when both represent the same HTTP method.
44
+ # @param other [Object]
45
+ # @return [Boolean]
46
+ unless method_defined?(:==)
47
+ def ==(other)
48
+ end
49
+ end
50
+
51
+ # Strict equality for Hash key and Set member semantics.
52
+ # @param other [Object]
53
+ # @return [Boolean]
54
+ unless method_defined?(:eql?)
55
+ def eql?(other)
56
+ end
57
+ end
58
+
59
+ # Hash value consistent with {#eql?} for use as Hash keys.
60
+ # @return [Integer]
61
+ unless method_defined?(:hash)
62
+ def hash
63
+ end
64
+ end
28
65
  end
29
66
 
30
67
  # HTTP version enumeration backed by Rust.
@@ -63,6 +100,21 @@ module Wreq
63
100
  def ==(other)
64
101
  end
65
102
  end
103
+
104
+ # Strict equality for Hash key and Set member semantics.
105
+ # @param other [Object]
106
+ # @return [Boolean]
107
+ unless method_defined?(:eql?)
108
+ def eql?(other)
109
+ end
110
+ end
111
+
112
+ # Hash value consistent with {#eql?} for use as Hash keys.
113
+ # @return [Integer]
114
+ unless method_defined?(:hash)
115
+ def hash
116
+ end
117
+ end
66
118
  end
67
119
 
68
120
  # HTTP status code wrapper.
@@ -141,6 +193,28 @@ module Wreq
141
193
  # @return [String] Status code as string
142
194
  def to_s
143
195
  end
196
+
197
+ # Returns the status code as an integer.
198
+ # @return [Integer] the numeric HTTP status code
199
+ def to_i
200
+ end
201
+
202
+ # Value-based equality. Only compares with other StatusCode instances.
203
+ # @param other [Object]
204
+ # @return [Boolean]
205
+ def ==(other)
206
+ end
207
+
208
+ # Strict equality for Hash key and Set member semantics.
209
+ # @param other [Object]
210
+ # @return [Boolean]
211
+ def eql?(other)
212
+ end
213
+
214
+ # Hash value consistent with {#eql?} for use as Hash keys.
215
+ # @return [Integer]
216
+ def hash
217
+ end
144
218
  end
145
219
  end
146
220
  end
@@ -127,6 +127,9 @@ unless defined?(Wreq)
127
127
 
128
128
  # Parse the response body as JSON.
129
129
  #
130
+ # Integral JSON numbers are returned as arbitrary-precision Ruby Integer
131
+ # values. Fractional and exponent-form numbers are returned as Float values.
132
+ #
130
133
  # @return [Object] Parsed JSON (Hash, Array, String, Integer, Float, Boolean, nil)
131
134
  # @raise [Wreq::DecodingError] if body is not valid JSON
132
135
  # @example
@@ -32,7 +32,12 @@ function Invoke-Step {
32
32
  )
33
33
 
34
34
  Write-Host "==> $Name"
35
+ # Native command failures do not honor ErrorActionPreference in Windows PowerShell.
36
+ $global:LASTEXITCODE = 0
35
37
  & $Command
38
+ if ($LASTEXITCODE -ne 0) {
39
+ throw "$Name failed with exit code $LASTEXITCODE."
40
+ }
36
41
  }
37
42
 
38
43
  function Get-MissingUcrtTools {
@@ -122,6 +127,7 @@ Invoke-Step "Check MSYS2 UCRT build tools" {
122
127
  $stillMissing = @(Get-MissingUcrtTools)
123
128
  if ($stillMissing.Count -eq 0) {
124
129
  Write-Warning "pacman returned exit code $LASTEXITCODE, but all required tools are present; continuing."
130
+ $global:LASTEXITCODE = 0
125
131
  return
126
132
  }
127
133
 
data/src/arch.rs CHANGED
@@ -4,6 +4,28 @@
4
4
  //! ABI boundaries, or OS APIs used by the Rust extension. Normal HTTP client
5
5
  //! behavior should stay in the client/runtime modules so platform workarounds
6
6
  //! do not leak into the rest of the binding.
7
+ #![allow(unsafe_code)]
8
+
9
+ /// Whether the native client exposes TCP user-timeout configuration.
10
+ pub(crate) const SUPPORTS_TCP_USER_TIMEOUT: bool = cfg!(any(
11
+ target_os = "android",
12
+ target_os = "fuchsia",
13
+ target_os = "linux"
14
+ ));
15
+
16
+ /// Whether the native client exposes network-interface binding.
17
+ pub(crate) const SUPPORTS_INTERFACE: bool = cfg!(any(
18
+ target_os = "android",
19
+ target_os = "fuchsia",
20
+ target_os = "illumos",
21
+ target_os = "ios",
22
+ target_os = "linux",
23
+ target_os = "macos",
24
+ target_os = "solaris",
25
+ target_os = "tvos",
26
+ target_os = "visionos",
27
+ target_os = "watchos",
28
+ ));
7
29
 
8
30
  #[cfg(all(target_os = "windows", target_env = "gnu"))]
9
31
  mod windows_gnu {
@@ -1,2 +1,4 @@
1
+ //! Form request body conversion.
2
+
1
3
  /// Alias for form parameters.
2
4
  pub type Form = crate::client::param::Params;
@@ -1,16 +1,49 @@
1
- use indexmap::IndexMap;
2
- use serde::{Deserialize, Serialize};
1
+ //! JSON conversion at the Ruby request and response boundary.
2
+ //!
3
+ //! Request values are converted from Ruby into an owned [`Json`] tree before
4
+ //! network I/O. Response bodies are deserialized into the same tree by wreq and
5
+ //! then converted back into Ruby values.
6
+ //!
7
+ //! The underlying `serde_json` configuration preserves object insertion order
8
+ //! and arbitrary-size integer tokens in both directions.
3
9
 
4
- /// Represents a JSON value for HTTP requests.
5
- /// Supports objects, arrays, numbers, strings, booleans, and null.
6
- #[derive(Serialize, Deserialize)]
7
- #[serde(untagged)]
8
- pub enum Json {
9
- Object(IndexMap<String, Json>),
10
- Boolean(bool),
11
- Number(isize),
12
- Float(f64),
13
- String(String),
14
- Null(Option<isize>),
15
- Array(Vec<Json>),
10
+ use ::serde::{Deserialize, Serialize};
11
+ use magnus::{Error, Ruby, TryConvert, Value};
12
+
13
+ use crate::{error::json_serialization_error, serde::deserialize_json};
14
+
15
+ /// An owned JSON tree shared by request and response conversion.
16
+ ///
17
+ /// This wrapper keeps the configured `serde_json::Value` representation private
18
+ /// so callers use the same precision and ordering behavior in both directions.
19
+ #[derive(Deserialize, Serialize)]
20
+ #[serde(transparent)]
21
+ pub struct Json(serde_json::Value);
22
+
23
+ /// Convert supported Ruby request values into an owned JSON tree.
24
+ ///
25
+ /// Supported values are `Hash`, `Array`, `String`, `Symbol`, `Integer`, finite
26
+ /// `Float`, booleans, and `nil`. Hash keys must be strings or symbols. Arrays
27
+ /// and hashes are limited to 100 nesting levels, which also bounds cyclic input.
28
+ /// Unsupported values are reported as `Wreq::BuilderError` before network I/O.
29
+ impl TryConvert for Json {
30
+ fn try_convert(value: Value) -> Result<Self, Error> {
31
+ let ruby = Ruby::get_with(value);
32
+ deserialize_json(&ruby, value)
33
+ .map(Self)
34
+ .map_err(|error| json_serialization_error(&ruby, error))
35
+ }
36
+ }
37
+
38
+ #[cfg(test)]
39
+ mod tests {
40
+ use super::Json;
41
+
42
+ #[test]
43
+ fn preserves_number_precision_and_object_order() {
44
+ let source = br#"{"second":115792089237316195423570985008687907853269984665640564039457584007913129639936,"first":1}"#;
45
+ let json: Json = serde_json::from_slice(source).unwrap();
46
+
47
+ assert_eq!(source, serde_json::to_vec(&json).unwrap().as_slice());
48
+ }
16
49
  }
@@ -1,34 +1,58 @@
1
+ //! Streaming request and response body support.
2
+
1
3
  use std::{
4
+ cell::{Ref, RefCell, RefMut},
5
+ panic::catch_unwind,
2
6
  pin::Pin,
3
- sync::RwLock,
4
7
  task::{Context, Poll},
5
8
  };
6
9
 
7
10
  use bytes::Bytes;
8
11
  use futures_util::{Stream, StreamExt};
9
- use magnus::{Error, RString, TryConvert, Value};
10
- use tokio::sync::{
11
- Mutex,
12
- mpsc::{self},
13
- };
12
+ use magnus::{Error, Integer, RString, Ruby, Value, scan_args::scan_args};
13
+ use tokio::sync::{Mutex, Semaphore, mpsc};
14
14
 
15
15
  use crate::{
16
- error::{memory_error, mpsc_send_error_to_magnus, wreq_error_to_magnus},
16
+ error::{
17
+ argument_error, body_sender_borrow_error, body_sender_borrow_mut_error,
18
+ body_sender_send_error, closed_body_sender_error, memory_error, type_error, wreq_error,
19
+ },
17
20
  rt,
18
21
  };
19
22
 
23
+ /// Number of chunks buffered when Ruby omits the channel capacity.
24
+ const DEFAULT_CHANNEL_CAPACITY: usize = 8;
25
+
20
26
  /// A receiver for streaming HTTP response bodies.
21
27
  pub struct BodyReceiver(Mutex<Pin<Box<dyn Stream<Item = wreq::Result<Bytes>> + Send>>>);
22
28
 
23
- /// A sender for streaming HTTP request bodies.
29
+ /// A bounded producer for a single streaming HTTP request body.
30
+ ///
31
+ /// The receiving side may be attached to one request. The producer remains
32
+ /// writable until [`BodySender::close`] is called or the request drops its
33
+ /// receiver. Ruby's GVL protects state access; no [`RefCell`] borrow is kept
34
+ /// while request backpressure waits without the GVL.
24
35
  #[magnus::wrap(class = "Wreq::BodySender", free_immediately, size)]
25
- pub struct BodySender(RwLock<InnerBodySender>);
36
+ pub struct BodySender(RefCell<InnerBodySender>);
26
37
 
38
+ /// Mutable ownership state for both halves of the body channel.
27
39
  struct InnerBodySender {
40
+ /// Producing side, removed by [`BodySender::close`].
28
41
  tx: Option<mpsc::Sender<Bytes>>,
42
+ /// Receiving side, removed when the sender is attached to a request.
29
43
  rx: Option<mpsc::Receiver<Bytes>>,
30
44
  }
31
45
 
46
+ impl InnerBodySender {
47
+ /// Return whether the channel can no longer accept body chunks.
48
+ fn is_closed(&self) -> bool {
49
+ match &self.tx {
50
+ Some(tx) => tx.is_closed(),
51
+ None => true,
52
+ }
53
+ }
54
+ }
55
+
32
56
  // ===== impl BodyReceiver =====
33
57
 
34
58
  impl BodyReceiver {
@@ -39,8 +63,9 @@ impl BodyReceiver {
39
63
  }
40
64
 
41
65
  /// Read the next body chunk, converting stream errors into Ruby errors.
42
- pub fn next(&self) -> Result<Option<Bytes>, Error> {
66
+ pub fn next(&self, ruby: &Ruby) -> Result<Option<Bytes>, Error> {
43
67
  rt::try_block_on(
68
+ ruby,
44
69
  async {
45
70
  match self.0.lock().await.as_mut().next().await {
46
71
  Some(Ok(data)) => Ok(Some(data)),
@@ -48,7 +73,7 @@ impl BodyReceiver {
48
73
  None => Ok(None),
49
74
  }
50
75
  },
51
- wreq_error_to_magnus,
76
+ wreq_error,
52
77
  )
53
78
  }
54
79
  }
@@ -56,54 +81,133 @@ impl BodyReceiver {
56
81
  // ===== impl BodySender =====
57
82
 
58
83
  impl BodySender {
59
- /// Ruby: `Wreq::Sender.new(capacity = 8)`
60
- pub fn new(args: &[Value]) -> Self {
61
- let capacity: usize = if let Some(v) = args.first() {
62
- usize::try_convert(*v).unwrap_or(8)
63
- } else {
64
- 8
65
- };
66
-
67
- let (tx, rx) = mpsc::channel(capacity);
68
- BodySender(RwLock::new(InnerBodySender {
84
+ /// Create a bounded request-body channel.
85
+ ///
86
+ /// Ruby: `Wreq::BodySender.new(capacity = 8)`. Capacity must be greater
87
+ /// than zero and no larger than [`Semaphore::MAX_PERMITS`].
88
+ ///
89
+ /// # Errors
90
+ ///
91
+ /// Returns `TypeError` for a non-Integer capacity and `ArgumentError` for
92
+ /// an invalid range or argument count.
93
+ pub fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
94
+ let capacity = parse_capacity(ruby, args)?;
95
+
96
+ // Create the Tokio channel without allowing an unwind to cross the Ruby FFI boundary.
97
+ //
98
+ // Known panic conditions are rejected by [`parse_capacity`]. The unwind guard
99
+ // remains as a defensive fallback if Tokio adds another channel invariant.
100
+ let (tx, rx) =
101
+ catch_unwind(|| mpsc::channel(capacity)).map_err(|_| invalid_capacity_error(ruby))?;
102
+
103
+ Ok(BodySender(RefCell::new(InnerBodySender {
69
104
  tx: Some(tx),
70
105
  rx: Some(rx),
71
- }))
106
+ })))
72
107
  }
73
108
 
74
- /// Ruby: `push(data)` where data is String or bytes
75
- pub fn push(rb_self: &Self, data: RString) -> Result<(), Error> {
76
- let bytes = data.to_bytes();
77
- let inner = rb_self.0.read().unwrap();
78
- if let Some(ref tx) = inner.tx {
79
- rt::try_block_on(tx.send(bytes), mpsc_send_error_to_magnus)?;
80
- }
81
- Ok(())
109
+ /// Push a binary chunk, waiting for capacity when the channel is full.
110
+ ///
111
+ /// Ruby: `push(data)` where `data` is a String.
112
+ ///
113
+ /// # Errors
114
+ ///
115
+ /// Returns `IOError` after either channel side has closed. An interrupted
116
+ /// wait raises Ruby's standard `Interrupt` exception.
117
+ pub fn push(ruby: &Ruby, rb_self: &Self, data: RString) -> Result<(), Error> {
118
+ // Clone during the shared borrow, then release it before waiting
119
+ // for capacity. Request attachment needs a mutable borrow.
120
+ let tx = match &rb_self.read_inner(ruby)?.tx {
121
+ Some(tx) if !tx.is_closed() => tx.clone(),
122
+ _ => return Err(closed_body_sender_error(ruby)),
123
+ };
124
+
125
+ rt::try_block_on(ruby, tx.send(data.to_bytes()), body_sender_send_error)
82
126
  }
83
127
 
84
- /// Ruby: `close` to close the sender
85
- pub fn close(&self) {
86
- let mut inner = self.0.write().unwrap();
128
+ /// Close the producing side while retaining the receiver and queued chunks.
129
+ ///
130
+ /// Calling this method more than once has no additional effect.
131
+ ///
132
+ /// # Errors
133
+ ///
134
+ /// Returns `Wreq::BodyError` if the internal state is already borrowed.
135
+ pub fn close(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
136
+ let mut inner = rb_self.write_inner(ruby)?;
87
137
  inner.tx.take();
88
- inner.rx.take();
138
+ Ok(())
89
139
  }
90
- }
91
140
 
92
- impl TryFrom<&BodySender> for ReceiverStream<Bytes> {
93
- type Error = magnus::Error;
141
+ /// Return whether this sender can no longer accept body chunks.
142
+ ///
143
+ /// # Errors
144
+ ///
145
+ /// Returns `Wreq::BodyError` if the internal state is already borrowed.
146
+ pub fn is_closed(ruby: &Ruby, rb_self: &Self) -> Result<bool, Error> {
147
+ rb_self.read_inner(ruby).map(|r| r.is_closed())
148
+ }
94
149
 
95
- fn try_from(sender: &BodySender) -> Result<Self, Self::Error> {
96
- sender
97
- .0
98
- .write()
99
- .unwrap()
150
+ /// Borrow the channel state without panicking on accidental re-entry.
151
+ fn read_inner(&self, ruby: &Ruby) -> Result<Ref<'_, InnerBodySender>, Error> {
152
+ self.0
153
+ .try_borrow()
154
+ .map_err(|err| body_sender_borrow_error(ruby, err))
155
+ }
156
+
157
+ /// Mutably borrow the channel state without panicking on accidental re-entry.
158
+ fn write_inner(&self, ruby: &Ruby) -> Result<RefMut<'_, InnerBodySender>, Error> {
159
+ self.0
160
+ .try_borrow_mut()
161
+ .map_err(|err| body_sender_borrow_mut_error(ruby, err))
162
+ }
163
+
164
+ /// Move the receiving side into one request body.
165
+ ///
166
+ /// # Errors
167
+ ///
168
+ /// Returns `Wreq::MemoryError` if the receiver was already consumed, or
169
+ /// `Wreq::BodyError` if Ruby re-enters while the state is borrowed.
170
+ pub(super) fn take_receiver(&self, ruby: &Ruby) -> Result<ReceiverStream<Bytes>, Error> {
171
+ self.write_inner(ruby)?
100
172
  .rx
101
173
  .take()
102
174
  .map(ReceiverStream::new)
103
- .ok_or_else(memory_error)
175
+ .ok_or_else(|| memory_error(ruby))
104
176
  }
105
177
  }
106
178
 
179
+ /// Parse and validate the optional Ruby channel capacity.
180
+ ///
181
+ /// [`mpsc::channel`] panics for zero or values above
182
+ /// [`Semaphore::MAX_PERMITS`], so validation must finish before channel creation.
183
+ fn parse_capacity(ruby: &Ruby, args: &[Value]) -> Result<usize, Error> {
184
+ let Some(value) = scan_args::<(), (Option<Value>,), (), (), (), ()>(args)?
185
+ .optional
186
+ .0
187
+ else {
188
+ return Ok(DEFAULT_CHANNEL_CAPACITY);
189
+ };
190
+
191
+ let integer = Integer::from_value(value)
192
+ .ok_or_else(|| type_error(ruby, "capacity must be an Integer"))?;
193
+ let capacity = integer
194
+ .to_i64()
195
+ .ok()
196
+ .and_then(|capacity| usize::try_from(capacity).ok())
197
+ .filter(|capacity| (1..=Semaphore::MAX_PERMITS).contains(capacity))
198
+ .ok_or_else(|| invalid_capacity_error(ruby))?;
199
+
200
+ Ok(capacity)
201
+ }
202
+
203
+ /// Build the synchronous Ruby error used for an invalid channel capacity.
204
+ fn invalid_capacity_error(ruby: &Ruby) -> Error {
205
+ argument_error(
206
+ ruby,
207
+ format!("capacity must be between 1 and {}", Semaphore::MAX_PERMITS),
208
+ )
209
+ }
210
+
107
211
  /// A wrapper around [`tokio::sync::mpsc::Receiver`] that implements [`Stream`].
108
212
  pub struct ReceiverStream<T> {
109
213
  inner: mpsc::Receiver<T>,
data/src/client/body.rs CHANGED
@@ -1,6 +1,6 @@
1
- mod form;
2
- mod json;
3
- mod stream;
1
+ pub mod form;
2
+ pub mod json;
3
+ pub mod stream;
4
4
 
5
5
  use bytes::Bytes;
6
6
  use futures_util::StreamExt;
@@ -9,29 +9,24 @@ use magnus::{
9
9
  typed_data::Obj,
10
10
  };
11
11
 
12
- pub use self::{
13
- form::Form,
14
- json::Json,
15
- stream::{BodyReceiver, BodySender, ReceiverStream},
16
- };
17
-
18
12
  /// Represents the body of an HTTP request.
19
13
  /// Supports text, bytes, and streaming bodies (Proc/Enumerator).
20
14
  pub enum Body {
21
15
  /// Static bytes body
22
16
  Bytes(Bytes),
23
17
  /// Streaming body
24
- Stream(ReceiverStream<Bytes>),
18
+ Stream(stream::ReceiverStream<Bytes>),
25
19
  }
26
20
 
27
21
  impl TryConvert for Body {
28
22
  fn try_convert(val: Value) -> Result<Self, Error> {
23
+ let ruby = Ruby::get_with(val);
29
24
  if let Ok(s) = RString::try_convert(val) {
30
25
  return Ok(Body::Bytes(s.to_bytes()));
31
26
  }
32
27
 
33
- let obj = Obj::<BodySender>::try_convert(val)?;
34
- let stream = ReceiverStream::try_from(&*obj)?;
28
+ let obj = Obj::<stream::BodySender>::try_convert(val)?;
29
+ let stream = obj.take_receiver(&ruby)?;
35
30
  Ok(Body::Stream(stream))
36
31
  }
37
32
  }
@@ -50,8 +45,9 @@ impl From<Body> for wreq::Body {
50
45
 
51
46
  pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> {
52
47
  let sender_class = gem_module.define_class("BodySender", ruby.class_object())?;
53
- sender_class.define_singleton_method("new", function!(BodySender::new, -1))?;
54
- sender_class.define_method("push", method!(BodySender::push, 1))?;
55
- sender_class.define_method("close", magnus::method!(BodySender::close, 0))?;
48
+ sender_class.define_singleton_method("new", function!(stream::BodySender::new, -1))?;
49
+ sender_class.define_method("push", method!(stream::BodySender::push, 1))?;
50
+ sender_class.define_method("close", magnus::method!(stream::BodySender::close, 0))?;
51
+ sender_class.define_method("closed?", magnus::method!(stream::BodySender::is_closed, 0))?;
56
52
  Ok(())
57
53
  }