wreq 1.0.0

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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/Cargo.toml +54 -0
  3. data/Gemfile +17 -0
  4. data/LICENSE +201 -0
  5. data/README.md +150 -0
  6. data/Rakefile +90 -0
  7. data/build.rs +9 -0
  8. data/examples/body.rb +42 -0
  9. data/examples/client.rb +33 -0
  10. data/examples/emulation_request.rb +37 -0
  11. data/examples/headers.rb +27 -0
  12. data/examples/proxy.rb +113 -0
  13. data/examples/send_stream.rb +85 -0
  14. data/examples/stream.rb +14 -0
  15. data/examples/thread_interrupt.rb +83 -0
  16. data/extconf.rb +7 -0
  17. data/lib/wreq.rb +313 -0
  18. data/lib/wreq_ruby/body.rb +36 -0
  19. data/lib/wreq_ruby/client.rb +516 -0
  20. data/lib/wreq_ruby/cookie.rb +144 -0
  21. data/lib/wreq_ruby/emulation.rb +186 -0
  22. data/lib/wreq_ruby/error.rb +159 -0
  23. data/lib/wreq_ruby/header.rb +197 -0
  24. data/lib/wreq_ruby/http.rb +132 -0
  25. data/lib/wreq_ruby/response.rb +208 -0
  26. data/script/build_platform_gem.rb +34 -0
  27. data/src/client/body/form.rs +2 -0
  28. data/src/client/body/json.rs +16 -0
  29. data/src/client/body/stream.rs +148 -0
  30. data/src/client/body.rs +57 -0
  31. data/src/client/param.rs +19 -0
  32. data/src/client/query.rs +2 -0
  33. data/src/client/req.rs +251 -0
  34. data/src/client/resp.rs +250 -0
  35. data/src/client.rs +392 -0
  36. data/src/cookie.rs +277 -0
  37. data/src/emulation.rs +317 -0
  38. data/src/error.rs +147 -0
  39. data/src/extractor.rs +199 -0
  40. data/src/gvl.rs +154 -0
  41. data/src/header.rs +177 -0
  42. data/src/http.rs +127 -0
  43. data/src/lib.rs +97 -0
  44. data/src/macros.rs +118 -0
  45. data/src/rt.rs +47 -0
  46. data/test/client_cookie_test.rb +46 -0
  47. data/test/client_test.rb +136 -0
  48. data/test/cookie_test.rb +166 -0
  49. data/test/emulation_test.rb +21 -0
  50. data/test/error_handling_test.rb +89 -0
  51. data/test/header_test.rb +290 -0
  52. data/test/module_methods_test.rb +75 -0
  53. data/test/request_parameters_test.rb +175 -0
  54. data/test/request_test.rb +234 -0
  55. data/test/response_test.rb +69 -0
  56. data/test/stream_test.rb +81 -0
  57. data/test/test_helper.rb +9 -0
  58. data/wreq.gemspec +68 -0
  59. metadata +112 -0
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ unless defined?(Wreq)
4
+ module Wreq
5
+ # HTTP response object containing status, headers, and body.
6
+ #
7
+ # This class wraps a native Rust implementation providing efficient
8
+ # access to HTTP response data including status codes, headers, body
9
+ # content, and streaming capabilities.
10
+ #
11
+ # @example Basic response handling
12
+ # response = client.get("https://api.example.com")
13
+ # puts response.status.as_int # => 200
14
+ # puts response.text
15
+ #
16
+ # @example JSON response
17
+ # response = client.get("https://api.example.com/data")
18
+ # data = response.json
19
+ #
20
+ # @example Streaming response
21
+ # response = client.get("https://example.com/large-file")
22
+ # response.stream.each do |chunk|
23
+ # # Process chunk
24
+ # end
25
+ class Response
26
+ # Get the HTTP status code as an integer.
27
+ #
28
+ # @return [Integer] Status code (e.g., 200, 404, 500)
29
+ # @example
30
+ # response.code # => 200
31
+ def code
32
+ end
33
+
34
+ # Get the HTTP status code object.
35
+ #
36
+ # @return [Wreq::StatusCode] Status code wrapper with helper methods
37
+ # @example
38
+ # status = response.status
39
+ # status.success? # => true
40
+ def status
41
+ end
42
+
43
+ # Get the HTTP protocol version used.
44
+ #
45
+ # @return [Wreq::Version] HTTP version (HTTP/1.1, HTTP/2, etc.)
46
+ # @example
47
+ # response.version # => Wreq::Version::HTTP_11
48
+ def version
49
+ end
50
+
51
+ # Get the final URL after redirects.
52
+ #
53
+ # @return [String] The final URL
54
+ # @example
55
+ # response.url # => "https://example.com/final-page"
56
+ def url
57
+ end
58
+
59
+ # Get the content length if known.
60
+ #
61
+ # @return [Integer, nil] Content length in bytes, or nil if unknown
62
+ # @example
63
+ # response.content_length # => 1024
64
+ def content_length
65
+ end
66
+
67
+ # Get the local socket address.
68
+ #
69
+ # @return [String, nil] Local address (e.g., "127.0.0.1:54321"), or nil
70
+ # @example
71
+ # response.local_addr # => "192.168.1.100:54321"
72
+ def local_addr
73
+ end
74
+
75
+ # Get the remote socket address.
76
+ #
77
+ # @return [String, nil] Remote address (e.g., "93.184.216.34:443"), or nil
78
+ # @example
79
+ # response.remote_addr # => "93.184.216.34:443"
80
+ def remote_addr
81
+ end
82
+
83
+ # Get the response bytes as a binary string.
84
+ # @return [String] Response body as binary data
85
+ # @example
86
+ # binary_data = response.bytes
87
+ # puts binary_data.size # => 1024
88
+ def bytes
89
+ end
90
+
91
+ # Get the response body as text.
92
+ #
93
+ # @return [String] Response body decoded as UTF-8 text
94
+ # @example
95
+ # html = response.text
96
+ # puts html
97
+ # @raise [Wreq::DecodingError] if body cannot be decoded as binary
98
+ def text
99
+ end
100
+
101
+ # Get the response body as text with a specific charset.
102
+ # This method allows you to specify a default encoding
103
+ # to use when decoding the response body.
104
+ # # @param default_encoding [String] Default encoding to use (e.g., "UTF-8")
105
+ # # @return [String] Response body decoded as text using the specified encoding
106
+ # @example
107
+ # html = response.text_with_charset("ISO-8859-1")
108
+ # puts html
109
+ # @raise [Wreq::DecodingError] if body cannot be decoded with the specified encoding
110
+ def text_with_charset(default_encoding)
111
+ end
112
+
113
+ # Parse the response body as JSON.
114
+ #
115
+ # @return [Object] Parsed JSON (Hash, Array, String, Integer, Float, Boolean, nil)
116
+ # @raise [Wreq::DecodingError] if body is not valid JSON
117
+ # @example
118
+ # data = response.json
119
+ # puts data["key"]
120
+ def json
121
+ end
122
+
123
+ # Get a streaming iterator for the response body, yielding each chunk.
124
+ #
125
+ # This method allows you to process large HTTP responses efficiently,
126
+ # by yielding each chunk of the body as it arrives, without loading
127
+ # the entire response into memory.
128
+ #
129
+ # @return An iterator over response body chunks (binary String)
130
+ # @yield [chunk] Each chunk of the response body as a binary String
131
+ # @example Save response to file
132
+ # File.open("output.bin", "wb") do |f|
133
+ # response.chunks { |chunk| f.write(chunk) }
134
+ # end
135
+ # @example Count total bytes streamed
136
+ # total = 0
137
+ # response.chunks { |chunk| total += chunk.bytesize }
138
+ # puts "Downloaded #{total} bytes"
139
+ #
140
+ # Note: The returned Receiver is only for reading response bodies, not for uploads.
141
+ def chunks
142
+ end
143
+
144
+ # Close the response and free associated resources.
145
+ #
146
+ # @return [void]
147
+ # @example
148
+ # response.close
149
+ def close
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ # ======================== Ruby API Extensions ========================
156
+
157
+ module Wreq
158
+ class Response
159
+ # Returns a compact string representation of the response.
160
+ #
161
+ # Format: #<Wreq::Response STATUS content-type="..." body=SIZE>
162
+ #
163
+ # @return [String] Compact formatted response information
164
+ # @example
165
+ # puts response.to_s
166
+ # # => #<Wreq::Response 200 content-type="application/json" body=456B>
167
+ def to_s
168
+ parts = ["#<Wreq::Response"]
169
+
170
+ # Status code
171
+ parts << code.to_s
172
+
173
+ # Content-Type header if present
174
+ if headers.respond_to?(:get)
175
+ content_type = headers.get("content-type")
176
+ parts << "content-type=#{content_type.inspect}" if content_type
177
+ end
178
+
179
+ # Body size
180
+ if content_length
181
+ parts << "body=#{format_bytes(content_length)}"
182
+ end
183
+
184
+ parts.join(" ") + ">"
185
+ end
186
+
187
+ private
188
+
189
+ def format_bytes(bytes)
190
+ return "0B" if bytes.zero?
191
+
192
+ units = ["B", "KB", "MB", "GB"]
193
+ size = bytes.to_f
194
+ unit_index = 0
195
+
196
+ while size >= 1024 && unit_index < units.length - 1
197
+ size /= 1024.0
198
+ unit_index += 1
199
+ end
200
+
201
+ if unit_index == 0
202
+ "#{size.to_i}#{units[unit_index]}"
203
+ else
204
+ "#{size.round(1)}#{units[unit_index]}"
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Build a platform-specific gem with pre-compiled native extensions.
5
+ #
6
+ # Usage: ruby script/build_platform_gem.rb PLATFORM
7
+ # Example: ruby script/build_platform_gem.rb arm64-darwin
8
+ #
9
+ # Expects compiled .bundle/.so files in version-specific directories:
10
+ # lib/wreq_ruby/3.3/wreq_ruby.bundle
11
+ # lib/wreq_ruby/3.4/wreq_ruby.bundle
12
+ # lib/wreq_ruby/4.0/wreq_ruby.bundle
13
+
14
+ require "rubygems/package"
15
+ require "fileutils"
16
+
17
+ platform = ARGV.fetch(0) { abort "Usage: #{$0} PLATFORM" }
18
+
19
+ spec = Gem::Specification.load("wreq.gemspec")
20
+ spec.platform = Gem::Platform.new(platform)
21
+ spec.extensions = []
22
+ # Keep in sync with Rakefile cross_compiling block
23
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.3", "< 4.1.dev")
24
+
25
+ # Add version-specific compiled extensions
26
+ binaries = Dir.glob("lib/wreq_ruby/[0-9]*/*.{bundle,so}")
27
+ abort "No compiled binaries found in lib/wreq_ruby/*/. Did compilation succeed?" if binaries.empty?
28
+ spec.files += binaries
29
+
30
+ FileUtils.mkdir_p("pkg")
31
+ gem_file = Gem::Package.build(spec)
32
+ FileUtils.mv(gem_file, "pkg/")
33
+
34
+ puts "Built: pkg/#{File.basename(gem_file)}"
@@ -0,0 +1,2 @@
1
+ /// Alias for form parameters.
2
+ pub type Form = crate::client::param::Params;
@@ -0,0 +1,16 @@
1
+ use indexmap::IndexMap;
2
+ use serde::{Deserialize, Serialize};
3
+
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>),
16
+ }
@@ -0,0 +1,148 @@
1
+ use std::{
2
+ pin::Pin,
3
+ sync::RwLock,
4
+ task::{Context, Poll},
5
+ };
6
+
7
+ use bytes::Bytes;
8
+ use futures_util::{Stream, StreamExt, TryFutureExt};
9
+ use magnus::{Error, RString, TryConvert, Value};
10
+ use tokio::sync::{
11
+ Mutex,
12
+ mpsc::{self},
13
+ };
14
+
15
+ use crate::{
16
+ error::{memory_error, mpsc_send_error_to_magnus},
17
+ rt,
18
+ };
19
+
20
+ /// A receiver for streaming HTTP response bodies.
21
+ pub struct BodyReceiver(Mutex<Pin<Box<dyn Stream<Item = wreq::Result<Bytes>> + Send>>>);
22
+
23
+ /// A sender for streaming HTTP request bodies.
24
+ #[magnus::wrap(class = "Wreq::BodySender", free_immediately, size)]
25
+ pub struct BodySender(RwLock<InnerBodySender>);
26
+
27
+ struct InnerBodySender {
28
+ tx: Option<mpsc::Sender<Bytes>>,
29
+ rx: Option<mpsc::Receiver<Bytes>>,
30
+ }
31
+
32
+ // ===== impl BodyReceiver =====
33
+
34
+ impl BodyReceiver {
35
+ /// Create a new [`BodyReceiver`] instance.
36
+ #[inline]
37
+ pub fn new(stream: impl Stream<Item = wreq::Result<Bytes>> + Send + 'static) -> BodyReceiver {
38
+ BodyReceiver(Mutex::new(Box::pin(stream)))
39
+ }
40
+ }
41
+
42
+ impl Iterator for BodyReceiver {
43
+ type Item = Bytes;
44
+
45
+ fn next(&mut self) -> Option<Self::Item> {
46
+ rt::maybe_block_on(async {
47
+ self.0
48
+ .lock()
49
+ .await
50
+ .as_mut()
51
+ .next()
52
+ .await
53
+ .and_then(|r| r.ok())
54
+ })
55
+ }
56
+ }
57
+
58
+ // ===== impl BodySender =====
59
+
60
+ impl BodySender {
61
+ /// Ruby: `Wreq::Sender.new(capacity = 8)`
62
+ pub fn new(args: &[Value]) -> Self {
63
+ let capacity: usize = if let Some(v) = args.first() {
64
+ usize::try_convert(*v).unwrap_or(8)
65
+ } else {
66
+ 8
67
+ };
68
+
69
+ let (tx, rx) = mpsc::channel(capacity);
70
+ BodySender(RwLock::new(InnerBodySender {
71
+ tx: Some(tx),
72
+ rx: Some(rx),
73
+ }))
74
+ }
75
+
76
+ /// Ruby: `push(data)` where data is String or bytes
77
+ pub fn push(rb_self: &Self, data: RString) -> Result<(), Error> {
78
+ let bytes = data.to_bytes();
79
+ let inner = rb_self.0.read().unwrap();
80
+ if let Some(ref tx) = inner.tx {
81
+ rt::try_block_on(tx.send(bytes).map_err(mpsc_send_error_to_magnus))?;
82
+ }
83
+ Ok(())
84
+ }
85
+
86
+ /// Ruby: `close` to close the sender
87
+ pub fn close(&self) {
88
+ let mut inner = self.0.write().unwrap();
89
+ inner.tx.take();
90
+ inner.rx.take();
91
+ }
92
+ }
93
+
94
+ impl TryFrom<&BodySender> for ReceiverStream<Bytes> {
95
+ type Error = magnus::Error;
96
+
97
+ fn try_from(sender: &BodySender) -> Result<Self, Self::Error> {
98
+ sender
99
+ .0
100
+ .write()
101
+ .unwrap()
102
+ .rx
103
+ .take()
104
+ .map(ReceiverStream::new)
105
+ .ok_or_else(memory_error)
106
+ }
107
+ }
108
+
109
+ /// A wrapper around [`tokio::sync::mpsc::Receiver`] that implements [`Stream`].
110
+ pub struct ReceiverStream<T> {
111
+ inner: mpsc::Receiver<T>,
112
+ }
113
+
114
+ impl<T> ReceiverStream<T> {
115
+ /// Create a new [`ReceiverStream`].
116
+ #[inline]
117
+ pub fn new(recv: mpsc::Receiver<T>) -> Self {
118
+ Self { inner: recv }
119
+ }
120
+ }
121
+
122
+ impl<T> Stream for ReceiverStream<T> {
123
+ type Item = T;
124
+
125
+ #[inline]
126
+ fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
127
+ self.inner.poll_recv(cx)
128
+ }
129
+
130
+ /// Returns the bounds of the stream based on the underlying receiver.
131
+ ///
132
+ /// For open channels, it returns `(receiver.len(), None)`.
133
+ ///
134
+ /// For closed channels, it returns `(receiver.len(), Some(used_capacity))`
135
+ /// where `used_capacity` is calculated as `receiver.max_capacity() -
136
+ /// receiver.capacity()`. This accounts for any [`Permit`] that is still
137
+ /// able to send a message.
138
+ ///
139
+ /// [`Permit`]: struct@tokio::sync::mpsc::Permit
140
+ fn size_hint(&self) -> (usize, Option<usize>) {
141
+ if self.inner.is_closed() {
142
+ let used_capacity = self.inner.max_capacity() - self.inner.capacity();
143
+ (self.inner.len(), Some(used_capacity))
144
+ } else {
145
+ (self.inner.len(), None)
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,57 @@
1
+ mod form;
2
+ mod json;
3
+ mod stream;
4
+
5
+ use bytes::Bytes;
6
+ use futures_util::StreamExt;
7
+ use magnus::{
8
+ Error, Module, Object, RModule, RString, Ruby, TryConvert, Value, function, method,
9
+ typed_data::Obj,
10
+ };
11
+
12
+ pub use self::{
13
+ form::Form,
14
+ json::Json,
15
+ stream::{BodyReceiver, BodySender, ReceiverStream},
16
+ };
17
+
18
+ /// Represents the body of an HTTP request.
19
+ /// Supports text, bytes, and streaming bodies (Proc/Enumerator).
20
+ pub enum Body {
21
+ /// Static bytes body
22
+ Bytes(Bytes),
23
+ /// Streaming body
24
+ Stream(ReceiverStream<Bytes>),
25
+ }
26
+
27
+ impl TryConvert for Body {
28
+ fn try_convert(val: Value) -> Result<Self, Error> {
29
+ if let Ok(s) = RString::try_convert(val) {
30
+ return Ok(Body::Bytes(s.to_bytes()));
31
+ }
32
+
33
+ let obj = Obj::<BodySender>::try_convert(val)?;
34
+ let stream = ReceiverStream::try_from(&*obj)?;
35
+ Ok(Body::Stream(stream))
36
+ }
37
+ }
38
+
39
+ impl From<Body> for wreq::Body {
40
+ fn from(body: Body) -> Self {
41
+ match body {
42
+ Body::Bytes(b) => wreq::Body::from(b),
43
+ Body::Stream(stream) => {
44
+ let try_stream = stream.map(Ok::<Bytes, std::io::Error>);
45
+ wreq::Body::wrap_stream(try_stream)
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> {
52
+ 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))?;
56
+ Ok(())
57
+ }
@@ -0,0 +1,19 @@
1
+ use indexmap::IndexMap;
2
+ use serde::{Deserialize, Serialize};
3
+
4
+ /// Represents HTTP parameters from Python as either a mapping or a sequence of key-value pairs.
5
+ pub type Params = IndexMap<String, ParamValue>;
6
+
7
+ /// Represents a single parameter value that can be automatically converted from Python types.
8
+ #[derive(Serialize, Deserialize)]
9
+ #[serde(untagged)]
10
+ pub enum ParamValue {
11
+ /// A boolean value from Python `bool`.
12
+ Boolean(bool),
13
+ /// An integer value from Python `int`.
14
+ Number(isize),
15
+ /// A floating-point value from Python `float`.
16
+ Float64(f64),
17
+ /// A string value from Python `str`.
18
+ String(String),
19
+ }
@@ -0,0 +1,2 @@
1
+ /// Alias for query parameters.
2
+ pub type Query = super::param::Params;