From 5488d0219a2280595fe0f7adc1259d3befee9f77 Mon Sep 17 00:00:00 2001 From: gngpp Date: Fri, 17 Jul 2026 02:24:54 +0800 Subject: [PATCH 1/8] feat(client): accept sub-second timeouts --- lib/wreq.rb | 72 ++++++++++---- lib/wreq_ruby/client.rb | 126 +++++++++++++++-------- src/client.rs | 86 ++++++++-------- src/client/req.rs | 24 +++-- src/lib.rs | 1 + src/time.rs | 56 +++++++++++ test/timeout_test.rb | 214 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 468 insertions(+), 111 deletions(-) create mode 100644 src/time.rs create mode 100644 test/timeout_test.rb diff --git a/lib/wreq.rb b/lib/wreq.rb index 41cd88f..9d15dfe 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -50,8 +50,12 @@ module Wreq # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -83,8 +87,12 @@ def self.request(method, url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -116,8 +124,12 @@ def self.get(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -149,8 +161,12 @@ def self.head(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -182,8 +198,12 @@ def self.post(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -215,8 +235,12 @@ def self.put(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -248,8 +272,12 @@ def self.delete(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -281,8 +309,12 @@ def self.options(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -314,8 +346,12 @@ def self.trace(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index d9a4cfb..90f8e0a 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -70,31 +70,40 @@ class Client # used to store and retrieve cookies for all requests made by this # client. Typically used together with `cookie_store: true`. # - # @param timeout [Integer, nil] Overall timeout for the entire request + # @param timeout [Numeric, nil] Overall timeout for the entire request # in seconds, including connection establishment, request transmission, - # and response reading. If not set, requests may wait indefinitely. - # - # @param connect_timeout [Integer, nil] Maximum time in seconds to wait - # when establishing a connection to the remote server. This is separate - # from the overall timeout. - # - # @param read_timeout [Integer, nil] Maximum time in seconds to wait - # between reading chunks of data from the server. Applies to each - # read operation, not the entire response. - # - # @param tcp_keepalive [Integer, nil] Time in seconds that a connection - # must be idle before TCP keepalive probes are sent. Helps detect - # broken connections. - # - # @param tcp_keepalive_interval [Integer, nil] Time in seconds between - # individual TCP keepalive probes. Only relevant if tcp_keepalive is set. + # and response reading. Fractional seconds are accepted. The value must + # be finite and non-negative; 0 expires immediately. Nil or omission + # leaves the timeout unset. + # + # @param connect_timeout [Numeric, nil] Maximum time in seconds to wait + # when establishing a connection to the remote server. Fractional seconds + # are accepted. The value must be finite and non-negative; 0 expires + # immediately. Nil or omission leaves the timeout unset. + # + # @param read_timeout [Numeric, nil] Maximum time in seconds to wait + # between reading chunks of data from the server. Fractional seconds are + # accepted. The value must be finite and non-negative; 0 expires + # immediately. Nil or omission leaves the timeout unset. + # + # @param tcp_keepalive [Numeric, nil] Time in seconds that a connection + # must be idle before TCP keepalive probes are sent. Fractional seconds + # are accepted. The value must be finite and non-negative; 0 is passed + # through as a zero duration. Nil or omission leaves the option unset. + # + # @param tcp_keepalive_interval [Numeric, nil] Time in seconds between + # individual TCP keepalive probes. Fractional seconds are accepted. The + # value must be finite and non-negative; 0 is passed through as a zero + # duration. Nil or omission leaves the option unset. # # @param tcp_keepalive_retries [Integer, nil] Number of failed keepalive # probes before the connection is considered dead and closed. # - # @param tcp_user_timeout [Integer, nil] Maximum time in seconds that + # @param tcp_user_timeout [Numeric, nil] Maximum time in seconds that # transmitted data may remain unacknowledged before the connection is - # forcibly closed. Available on Android, Fuchsia, and Linux only. + # forcibly closed. Fractional seconds are accepted. The value must be + # finite and non-negative; 0 is passed through as a zero duration. Nil or + # omission leaves the option unset. Available on Android, Fuchsia, and Linux only. # # @param tcp_nodelay [Boolean, nil] Enable TCP_NODELAY socket option, # which disables Nagle's algorithm. When true, small packets are sent @@ -105,9 +114,10 @@ class Client # allowing the reuse of local addresses in TIME_WAIT state. Useful for # reducing port exhaustion in high-throughput scenarios. # - # @param pool_idle_timeout [Integer, nil] Time in seconds before idle - # connections in the pool are evicted and closed. Helps free up - # resources for long-running applications. + # @param pool_idle_timeout [Numeric, nil] Time in seconds before idle + # connections in the pool are evicted and closed. Fractional seconds are + # accepted. The value must be finite and non-negative; 0 expires idle + # entries immediately. Nil or omission leaves the timeout unset. # # @param pool_max_idle_per_host [Integer, nil] Maximum number of idle # connections to maintain per host in the connection pool. Connections @@ -270,8 +280,12 @@ def self.new(**options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -305,8 +319,12 @@ def request(method, url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -338,8 +356,12 @@ def get(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -371,8 +393,12 @@ def head(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -404,8 +430,12 @@ def post(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -437,8 +467,12 @@ def put(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -470,8 +504,12 @@ def delete(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -503,8 +541,12 @@ def options(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -536,8 +578,12 @@ def trace(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. diff --git a/src/client.rs b/src/client.rs index fc4dd6e..f759584 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,7 +4,7 @@ mod query; mod req; pub mod resp; -use std::{net::IpAddr, time::Duration}; +use std::net::IpAddr; use ::serde::Deserialize; use magnus::{Module, Object, RModule, Ruby, TryConvert, Value, function, method, typed_data::Obj}; @@ -22,6 +22,7 @@ use crate::{ http::Method, options::{NativeOption, Options}, rt, + time::Duration, }; /// A builder for `Client`. @@ -54,31 +55,38 @@ struct Builder { cookie_provider: NativeOption, // ========= Timeout options ========= - /// The timeout to use for the client. (in seconds) - timeout: Option, - /// The connect timeout to use for the client. (in seconds) - connect_timeout: Option, - /// The read timeout to use for the client. (in seconds) - read_timeout: Option, + /// The timeout to use for the client, in seconds. + #[serde(default)] + timeout: NativeOption, + /// The connect timeout to use for the client, in seconds. + #[serde(default)] + connect_timeout: NativeOption, + /// The read timeout to use for the client, in seconds. + #[serde(default)] + read_timeout: NativeOption, // ========= TCP options ========= - /// Set that all sockets have `SO_KEEPALIVE` set with the supplied duration. (in seconds) - tcp_keepalive: Option, - /// Set the interval between TCP keepalive probes. (in seconds) - tcp_keepalive_interval: Option, + /// Set `SO_KEEPALIVE` with the supplied duration, in seconds. + #[serde(default)] + tcp_keepalive: NativeOption, + /// Set the interval between TCP keepalive probes, in seconds. + #[serde(default)] + tcp_keepalive_interval: NativeOption, /// Set the number of retries for TCP keepalive. tcp_keepalive_retries: Option, - /// Set an optional user timeout for TCP sockets. (in seconds) + /// Set an optional user timeout for TCP sockets, in seconds. + #[serde(default)] #[allow(dead_code)] - tcp_user_timeout: Option, + tcp_user_timeout: NativeOption, /// Set that all sockets have `NO_DELAY` set. tcp_nodelay: Option, /// Set that all sockets have `SO_REUSEADDR` set. tcp_reuse_address: Option, // ========= Connection pool options ========= - /// Set an optional timeout for idle sockets being kept-alive. (in seconds) - pool_idle_timeout: Option, + /// Set an optional timeout for idle pooled sockets, in seconds. + #[serde(default)] + pool_idle_timeout: NativeOption, /// Sets the maximum idle connection per host allowed in the pool. pool_max_idle_per_host: Option, /// Sets the maximum number of connections in the pool. @@ -175,6 +183,16 @@ impl Builder { cookie_provider, Obj => |value| (*value).clone() ); + + // Duration options. + extract_native_option!(options, builder, timeout); + extract_native_option!(options, builder, connect_timeout); + extract_native_option!(options, builder, read_timeout); + extract_native_option!(options, builder, tcp_keepalive); + extract_native_option!(options, builder, tcp_keepalive_interval); + extract_native_option!(options, builder, tcp_user_timeout); + extract_native_option!(options, builder, pool_idle_timeout); + builder .proxy .set(Extractor::::try_convert(options.as_value())?.into_inner()); @@ -273,18 +291,16 @@ impl Client { // TCP options. apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_keepalive, - tcp_keepalive, - Duration::from_secs + tcp_keepalive ); apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_keepalive_interval, - tcp_keepalive_interval, - Duration::from_secs + tcp_keepalive_interval ); apply_option!( set_if_some, @@ -294,11 +310,10 @@ impl Client { ); #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_user_timeout, - tcp_user_timeout, - Duration::from_secs + tcp_user_timeout ); apply_option!(set_if_some, builder, params.tcp_nodelay, tcp_nodelay); apply_option!( @@ -309,35 +324,26 @@ impl Client { ); // Timeout options. + apply_option!(set_if_some_inner, builder, params.timeout, timeout); apply_option!( - set_if_some_map, - builder, - params.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.connect_timeout, - connect_timeout, - Duration::from_secs + connect_timeout ); apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.read_timeout, - read_timeout, - Duration::from_secs + read_timeout ); // Pool options. apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.pool_idle_timeout, - pool_idle_timeout, - Duration::from_secs + pool_idle_timeout ); apply_option!( set_if_some, diff --git a/src/client/req.rs b/src/client/req.rs index 0603d9e..cb0045d 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -1,4 +1,4 @@ -use std::{net::IpAddr, time::Duration}; +use std::net::IpAddr; use ::serde::Deserialize; use http::header; @@ -17,6 +17,7 @@ use crate::{ http::{Method, Version}, options::{NativeOption, Options}, rt, + time::Duration, }; /// The parameters for a request. @@ -39,10 +40,12 @@ pub struct Request { interface: Option, /// The timeout to use for the request. - timeout: Option, + #[serde(default)] + timeout: NativeOption, /// The read timeout to use for the request. - read_timeout: Option, + #[serde(default)] + read_timeout: NativeOption, /// The HTTP version to use for the request. #[serde(default)] @@ -133,6 +136,8 @@ impl Request { Obj => |value| (*value).clone() ); extract_native_option!(options, builder, version); + extract_native_option!(options, builder, timeout); + extract_native_option!(options, builder, read_timeout); extract_native_option!(options, builder, headers); extract_native_option!(options, builder, orig_headers); extract_native_option!(options, builder, cookies); @@ -202,19 +207,12 @@ pub fn execute_request>( ); // Timeout options. + apply_option!(set_if_some_inner, builder, request.timeout, timeout); apply_option!( - set_if_some_map, - builder, - request.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, + set_if_some_inner, builder, request.read_timeout, - read_timeout, - Duration::from_secs + read_timeout ); // Network options. diff --git a/src/lib.rs b/src/lib.rs index 82d852d..ffe35ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod http; mod options; mod rt; mod serde; +mod time; use magnus::{Error, Module, Ruby, Value}; diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..a2ae8c6 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,56 @@ +//! Ruby time-value conversions shared by native binding modules. + +use std::time::Duration as StdDuration; + +use magnus::numeric::NumericValue; +use magnus::{Error, Integer, Ruby, TryConvert, Value, value::ReprValue}; + +use crate::error::argument_error; + +/// A crate-wide duration converted from non-negative Ruby `Numeric` seconds. +/// +/// Ruby integers are converted directly so the full `u64` seconds range is +/// retained. Other numeric values are converted through `f64` and then rounded +/// to the nanosecond precision of [`StdDuration`]. +pub(crate) struct Duration(pub(crate) StdDuration); + +impl TryConvert for Duration { + /// Convert integer or fractional Ruby seconds without string coercion. + /// + /// # Errors + /// + /// Returns `TypeError` for non-numeric values and `ArgumentError` for + /// negative, non-finite, or out-of-range durations. + fn try_convert(value: Value) -> Result { + let ruby = Ruby::get_with(value); + let numeric = NumericValue::try_convert(value)?; + + // `u64::try_convert` starts with `Integer::try_convert`. Probe with + // `from_value` so a fractional Numeric takes the fallback without + // using `TypeError` as control flow or repeating the Integer check. + if let Some(integer) = Integer::from_value(numeric.as_value()) { + return integer + .to_u64() + .map(StdDuration::from_secs) + .map(Self) + .map_err(|_| invalid_duration(&ruby)); + } + + // `Float::try_convert(...).to_f64()` takes a Ruby Float detour. The + // `rb_num2dbl`-backed conversion yields the primitive required by + // `StdDuration` directly from the already-checked Numeric. + f64::try_convert(numeric.as_value()) + .and_then(|seconds| { + StdDuration::try_from_secs_f64(seconds).map_err(|_| invalid_duration(&ruby)) + }) + .map(Self) + } +} + +/// Build the shared Ruby error for a numeric value outside `Duration`'s domain. +fn invalid_duration(ruby: &Ruby) -> Error { + argument_error( + ruby, + "duration must be finite, non-negative, and within the supported range", + ) +} diff --git a/test/timeout_test.rb b/test/timeout_test.rb new file mode 100644 index 0000000..eb734bc --- /dev/null +++ b/test/timeout_test.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require "test_helper" +require "socket" + +class TimeoutTest < Minitest::Test + SUBSECOND_TIMEOUT = 0.25 + SERVER_DELAY = 1.2 + + def test_client_duration_options_accept_numeric_seconds + [1, 0.125, Rational(1, 8), 0, nil].each do |value| + options = client_duration_options.to_h { |name| [name, value] } + + assert_instance_of Wreq::Client, Wreq::Client.new(**options) + end + end + + def test_each_client_duration_option_rejects_negative_seconds + client_duration_options.each do |name| + error = assert_raises(ArgumentError) do + Wreq::Client.new(**{name => -0.25}) + end + + assert_includes error.message, ":#{name}" + end + end + + def test_duration_rejects_invalid_numeric_seconds + invalid_values = [ + -1, + Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + 2**256 + ] + + invalid_values.each do |value| + error = assert_raises(ArgumentError) do + Wreq::Client.new(timeout: value) + end + + assert_includes error.message, ":timeout" + end + end + + def test_duration_rejects_non_numeric_values + error = assert_raises(TypeError) do + Wreq::Client.new(timeout: "0.25") + end + + assert_includes error.message, ":timeout" + end + + def test_request_duration_options_reject_invalid_values_before_network_io + invalid_values = [ + -1, + -0.25, + Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + 2**256, + "0.25" + ] + + %i[timeout read_timeout].each do |name| + invalid_values.each do |value| + error_class = value.is_a?(String) ? TypeError : ArgumentError + error = assert_raises(error_class) do + Wreq.get("not a url", **{name => value}) + end + + assert_includes error.message, ":#{name}" + end + end + end + + def test_request_timeouts_accept_integer_and_nil_values + with_http_server do |url| + response = Wreq.get(url, timeout: 1, read_timeout: 1) + + assert_equal 200, response.code + assert_equal "ok", response.text + end + + with_http_server do |url| + response = Wreq.get(url, timeout: nil, read_timeout: nil) + + assert_equal 200, response.code + assert_equal "ok", response.text + end + end + + def test_client_fractional_timeout_preserves_subsecond_value + client = Wreq::Client.new(timeout: SUBSECOND_TIMEOUT) + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_fractional_timeout { client.get(url) } + end + end + + def test_request_timeout_override_preserves_subsecond_value + client = Wreq::Client.new(timeout: 2) + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_fractional_timeout do + client.get(url, timeout: SUBSECOND_TIMEOUT) + end + end + end + + def test_request_read_timeout_override_preserves_subsecond_value + client = Wreq::Client.new(read_timeout: 2) + + with_http_server(body_delay: SERVER_DELAY) do |url| + assert_fractional_timeout do + client.get(url, read_timeout: SUBSECOND_TIMEOUT).text + end + end + end + + def test_zero_request_timeouts_expire_immediately + client = Wreq::Client.new + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_immediate_timeout { client.get(url, timeout: 0) } + end + + with_http_server(body_delay: SERVER_DELAY) do |url| + assert_immediate_timeout do + client.get(url, read_timeout: 0).text + end + end + end + + private + + def client_duration_options + options = %i[ + timeout + connect_timeout + read_timeout + tcp_keepalive + tcp_keepalive_interval + pool_idle_timeout + ] + if RUBY_PLATFORM.match?(/linux|android|fuchsia/) + options << :tcp_user_timeout + end + options + end + + def assert_fractional_timeout + elapsed = measure_elapsed do + assert_raises(Wreq::TimeoutError) { yield } + end + + assert_operator elapsed, :>=, 0.1, + "Fractional timeout fired too early after #{elapsed.round(3)} seconds" + assert_operator elapsed, :<, 0.8, + "Fractional timeout fired too late after #{elapsed.round(3)} seconds" + end + + def assert_immediate_timeout + elapsed = measure_elapsed do + assert_raises(Wreq::TimeoutError) { yield } + end + + assert_operator elapsed, :<, 0.5, + "Zero timeout did not expire immediately (#{elapsed.round(3)} seconds)" + end + + def measure_elapsed + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + end + + def with_http_server(response_delay: 0, body_delay: 0) + server = TCPServer.new("127.0.0.1", 0) + thread = Thread.new do + socket = server.accept + read_request(socket) + sleep response_delay if response_delay.positive? + + socket.write( + "HTTP/1.1 200 OK\r\n" \ + "Content-Length: 2\r\n" \ + "Connection: close\r\n\r\n" + ) + sleep body_delay if body_delay.positive? + socket.write("ok") + rescue IOError, SystemCallError + nil + ensure + socket&.close + server.close unless server.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{server.addr[1]}/" + ensure + server&.close unless server&.closed? + thread&.kill + thread&.join(1) + end + + def read_request(socket) + while (line = socket.gets) + break if line == "\r\n" + end + end +end From fca217fabe0211ce080708f3fd7ed338726b6898 Mon Sep 17 00:00:00 2001 From: gngpp Date: Fri, 17 Jul 2026 02:37:47 +0800 Subject: [PATCH 2/8] Clarify timeout and TCP option documentation --- src/client.rs | 14 +++++++------- src/client/req.rs | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/client.rs b/src/client.rs index f759584..5b5f848 100644 --- a/src/client.rs +++ b/src/client.rs @@ -55,26 +55,26 @@ struct Builder { cookie_provider: NativeOption, // ========= Timeout options ========= - /// The timeout to use for the client, in seconds. + /// Overall timeout for a request, including connection and response body. #[serde(default)] timeout: NativeOption, - /// The connect timeout to use for the client, in seconds. + /// Maximum duration allowed to establish a connection. #[serde(default)] connect_timeout: NativeOption, - /// The read timeout to use for the client, in seconds. + /// Maximum idle duration between response body reads. #[serde(default)] read_timeout: NativeOption, // ========= TCP options ========= - /// Set `SO_KEEPALIVE` with the supplied duration, in seconds. + /// Idle duration before TCP keepalive probes begin. #[serde(default)] tcp_keepalive: NativeOption, - /// Set the interval between TCP keepalive probes, in seconds. + /// Interval between TCP keepalive probes. #[serde(default)] tcp_keepalive_interval: NativeOption, /// Set the number of retries for TCP keepalive. tcp_keepalive_retries: Option, - /// Set an optional user timeout for TCP sockets, in seconds. + /// Maximum duration for which transmitted data may remain unacknowledged. #[serde(default)] #[allow(dead_code)] tcp_user_timeout: NativeOption, @@ -84,7 +84,7 @@ struct Builder { tcp_reuse_address: Option, // ========= Connection pool options ========= - /// Set an optional timeout for idle pooled sockets, in seconds. + /// Maximum idle duration before a pooled connection is evicted. #[serde(default)] pool_idle_timeout: NativeOption, /// Sets the maximum idle connection per host allowed in the pool. diff --git a/src/client/req.rs b/src/client/req.rs index cb0045d..353bb63 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -39,11 +39,11 @@ pub struct Request { #[allow(dead_code)] interface: Option, - /// The timeout to use for the request. + /// Overall timeout for this request, overriding the client default. #[serde(default)] timeout: NativeOption, - /// The read timeout to use for the request. + /// Maximum idle duration between body reads, overriding the client default. #[serde(default)] read_timeout: NativeOption, From 2c8cf4377039e82b212e9f0d4c5e41e5bb062503 Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Fri, 17 Jul 2026 21:12:30 +0530 Subject: [PATCH 3/8] feat: give native HTTP value types ruby value semantics (#139) * Give native HTTP value types Ruby value semantics * add relevant ruby definitions --- lib/wreq_ruby/cookie.rb | 27 +++++ lib/wreq_ruby/emulate.rb | 53 +++++++++ lib/wreq_ruby/http.rb | 74 ++++++++++++ src/cookie.rs | 26 ++++ src/emulate.rs | 18 +++ src/http.rs | 81 +++++++++++-- src/macros.rs | 36 ++++++ test/value_semantics_test.rb | 225 +++++++++++++++++++++++++++++++++++ 8 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 test/value_semantics_test.rb diff --git a/lib/wreq_ruby/cookie.rb b/lib/wreq_ruby/cookie.rb index 96fdbb9..ca7f7a6 100644 --- a/lib/wreq_ruby/cookie.rb +++ b/lib/wreq_ruby/cookie.rb @@ -10,6 +10,33 @@ class SameSite Lax = nil # None same-site policy. None = nil + + # Returns the SameSite attribute name (e.g. "Strict", "Lax", "None"). + # @return [String] + def to_s + end + + # Returns the SameSite attribute as a lowercase symbol (e.g. :strict, :lax, :none). + # @return [Symbol] + def to_sym + end + + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end end # A single HTTP cookie. diff --git a/lib/wreq_ruby/emulate.rb b/lib/wreq_ruby/emulate.rb index 545ed33..d0e4672 100644 --- a/lib/wreq_ruby/emulate.rb +++ b/lib/wreq_ruby/emulate.rb @@ -163,6 +163,29 @@ class Profile def to_s end end + + unless method_defined?(:==) + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + end + + unless method_defined?(:eql?) + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + end + + unless method_defined?(:hash) + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end + end end # Operating system platform enumeration backed by Rust. @@ -194,6 +217,36 @@ class Platform def to_s end end + + unless method_defined?(:to_sym) + # Returns the platform as a lowercase symbol (e.g. :windows, :linux). + # @return [Symbol] + def to_sym + end + end + + unless method_defined?(:==) + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + end + + unless method_defined?(:eql?) + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + end + + unless method_defined?(:hash) + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end + end end # Emulation option wrapper. diff --git a/lib/wreq_ruby/http.rb b/lib/wreq_ruby/http.rb index 443f5eb..2bf701e 100644 --- a/lib/wreq_ruby/http.rb +++ b/lib/wreq_ruby/http.rb @@ -25,6 +25,43 @@ class Method TRACE = nil # @return [Wreq::Method] HTTP TRACE method PATCH = nil # @return [Wreq::Method] HTTP PATCH method end + + # Returns the HTTP method token (e.g. "GET", "POST"). + # @return [String] + unless method_defined?(:to_s) + def to_s + end + end + + # Returns the HTTP method as a lowercase symbol (e.g. :get, :post). + # @return [Symbol] + unless method_defined?(:to_sym) + def to_sym + end + end + + # Value-based equality. Returns true when both represent the same HTTP method. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:==) + def ==(other) + end + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:eql?) + def eql?(other) + end + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + unless method_defined?(:hash) + def hash + end + end end # HTTP version enumeration backed by Rust. @@ -63,6 +100,21 @@ def to_s def ==(other) end end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:eql?) + def eql?(other) + end + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + unless method_defined?(:hash) + def hash + end + end end # HTTP status code wrapper. @@ -141,6 +193,28 @@ def server_error? # @return [String] Status code as string def to_s end + + # Returns the status code as an integer. + # @return [Integer] the numeric HTTP status code + def to_i + end + + # Value-based equality. Only compares with other StatusCode instances. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end end end end diff --git a/src/cookie.rs b/src/cookie.rs index 69f3cc7..5b9d5cd 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -24,6 +24,27 @@ define_ruby_enum!( None, ); +impl SameSite { + /// SameSite attribute as a string. + pub fn to_s(&self) -> &'static str { + match self { + SameSite::Strict => "Strict", + SameSite::Lax => "Lax", + SameSite::None => "None", + } + } + + /// SameSite attribute as a lowercase Ruby symbol. + pub fn to_sym(&self) -> magnus::Symbol { + let name = match self { + SameSite::Strict => "strict", + SameSite::Lax => "lax", + SameSite::None => "none", + }; + ruby!().to_symbol(name) + } +} + /// A single HTTP cookie. #[derive(Clone)] #[magnus::wrap(class = "Wreq::Cookie", free_immediately, size)] @@ -285,6 +306,11 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { // SameSite enum let same_site_class = gem_module.define_class("SameSite", ruby.class_object())?; SameSite::define_constants(same_site_class)?; + same_site_class.define_method("to_s", method!(SameSite::to_s, 0))?; + same_site_class.define_method("to_sym", method!(SameSite::to_sym, 0))?; + same_site_class.define_method("==", method!(SameSite::equals, 1))?; + same_site_class.define_method("eql?", method!(SameSite::is_eql, 1))?; + same_site_class.define_method("hash", method!(SameSite::hash_value, 0))?; // Cookie class let cookie_class = gem_module.define_class("Cookie", ruby.class_object())?; diff --git a/src/emulate.rs b/src/emulate.rs index 6c1713b..a977f13 100644 --- a/src/emulate.rs +++ b/src/emulate.rs @@ -219,6 +219,17 @@ impl Platform { pub fn to_s(&self) -> String { self.into_ffi().inspect() } + + pub fn to_sym(&self) -> magnus::Symbol { + let name = match self { + Platform::Windows => "windows", + Platform::MacOS => "macos", + Platform::Linux => "linux", + Platform::Android => "android", + Platform::IOS => "ios", + }; + ruby!().to_symbol(name) + } } // ===== impl Emulation ===== @@ -266,11 +277,18 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { // Profile enum binding let profile = gem_module.define_class("Profile", ruby.class_object())?; profile.define_method("to_s", method!(Profile::to_s, 0))?; + profile.define_method("==", method!(Profile::equals, 1))?; + profile.define_method("eql?", method!(Profile::is_eql, 1))?; + profile.define_method("hash", method!(Profile::hash_value, 0))?; Profile::define_constants(profile)?; // Platform enum binding let platform = gem_module.define_class("Platform", ruby.class_object())?; platform.define_method("to_s", method!(Platform::to_s, 0))?; + platform.define_method("to_sym", method!(Platform::to_sym, 0))?; + platform.define_method("==", method!(Platform::equals, 1))?; + platform.define_method("eql?", method!(Platform::is_eql, 1))?; + platform.define_method("hash", method!(Platform::hash_value, 0))?; Platform::define_constants(platform)?; // Emulation class binding diff --git a/src/http.rs b/src/http.rs index 9b00008..2a9e5fe 100644 --- a/src/http.rs +++ b/src/http.rs @@ -28,6 +28,39 @@ define_ruby_enum!( PATCH, ); +impl Method { + /// HTTP method token as a string. + #[inline] + pub fn to_s(&self) -> &'static str { + match self { + Method::GET => "GET", + Method::HEAD => "HEAD", + Method::POST => "POST", + Method::PUT => "PUT", + Method::DELETE => "DELETE", + Method::OPTIONS => "OPTIONS", + Method::TRACE => "TRACE", + Method::PATCH => "PATCH", + } + } + + /// HTTP method as a lowercase Ruby symbol. + #[inline] + pub fn to_sym(&self) -> magnus::Symbol { + let name = match self { + Method::GET => "get", + Method::HEAD => "head", + Method::POST => "post", + Method::PUT => "put", + Method::DELETE => "delete", + Method::OPTIONS => "options", + Method::TRACE => "trace", + Method::PATCH => "patch", + }; + ruby!().to_symbol(name) + } +} + /// HTTP status code. #[derive(Clone, Copy, PartialEq, Eq, Hash)] #[magnus::wrap(class = "Wreq::StatusCode", free_immediately, size)] @@ -41,14 +74,6 @@ impl Version { pub fn to_s(&self) -> String { self.into_ffi().inspect() } - - /// Value-based equality for Ruby (`==`). - #[inline] - pub fn equals(&self, other: Value) -> bool { - <&Version>::try_convert(other) - .map(|other| *self == *other) - .unwrap_or(false) - } } impl TryConvert for Version { @@ -101,6 +126,35 @@ impl StatusCode { pub fn to_s(&self) -> String { self.0.to_string() } + + /// Value-based equality for Ruby (`==`). + #[inline] + pub fn equals(&self, other: Value) -> bool { + <&StatusCode>::try_convert(other) + .map(|other| *self == *other) + .unwrap_or(false) + } + + /// Strict equality for Ruby (`eql?`). + #[inline] + pub fn is_eql(&self, other: Value) -> bool { + self.equals(other) + } + + /// Hash value for Ruby (`hash`). + #[inline] + pub fn hash_value(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } + + /// Return the status code as an integer (Ruby `to_i`). + #[inline] + pub const fn to_i(&self) -> u16 { + self.0.as_u16() + } } impl From for StatusCode { @@ -112,11 +166,18 @@ impl From for StatusCode { pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { let method_class = gem_module.define_class("Method", ruby.class_object())?; Method::define_constants(method_class)?; + method_class.define_method("to_s", method!(Method::to_s, 0))?; + method_class.define_method("to_sym", method!(Method::to_sym, 0))?; + method_class.define_method("==", method!(Method::equals, 1))?; + method_class.define_method("eql?", method!(Method::is_eql, 1))?; + method_class.define_method("hash", method!(Method::hash_value, 0))?; let version_class = gem_module.define_class("Version", ruby.class_object())?; Version::define_constants(version_class)?; version_class.define_method("to_s", method!(Version::to_s, 0))?; version_class.define_method("==", method!(Version::equals, 1))?; + version_class.define_method("eql?", method!(Version::is_eql, 1))?; + version_class.define_method("hash", method!(Version::hash_value, 0))?; let status_code_class = gem_module.define_class("StatusCode", ruby.class_object())?; status_code_class.define_method("as_int", method!(StatusCode::as_int, 0))?; @@ -126,6 +187,10 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { status_code_class.define_method("client_error?", method!(StatusCode::is_client_error, 0))?; status_code_class.define_method("server_error?", method!(StatusCode::is_server_error, 0))?; status_code_class.define_method("to_s", method!(StatusCode::to_s, 0))?; + status_code_class.define_method("==", method!(StatusCode::equals, 1))?; + status_code_class.define_method("eql?", method!(StatusCode::is_eql, 1))?; + status_code_class.define_method("hash", method!(StatusCode::hash_value, 0))?; + status_code_class.define_method("to_i", method!(StatusCode::to_i, 0))?; Ok(()) } diff --git a/src/macros.rs b/src/macros.rs index 1418b02..9ca1540 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -92,6 +92,24 @@ macro_rules! define_ruby_enum { $(class.const_set(stringify!($rust_variant), <$enum_type>::$rust_variant)?;)* Ok(()) } + + pub fn equals(&self, other: magnus::Value) -> bool { + use magnus::TryConvert; + <&$enum_type>::try_convert(other) + .map(|other| *self == *other) + .unwrap_or(false) + } + + pub fn is_eql(&self, other: magnus::Value) -> bool { + self.equals(other) + } + + pub fn hash_value(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() + } } }; @@ -125,6 +143,24 @@ macro_rules! define_ruby_enum { $(class.const_set(stringify!($rust_variant), <$enum_type>::$rust_variant)?;)* Ok(()) } + + pub fn equals(&self, other: magnus::Value) -> bool { + use magnus::TryConvert; + <&$enum_type>::try_convert(other) + .map(|other| *self == *other) + .unwrap_or(false) + } + + pub fn is_eql(&self, other: magnus::Value) -> bool { + self.equals(other) + } + + pub fn hash_value(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() + } } }; } diff --git a/test/value_semantics_test.rb b/test/value_semantics_test.rb new file mode 100644 index 0000000..6acd872 --- /dev/null +++ b/test/value_semantics_test.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "test_helper" +require "set" + +class ValueSemanticsTest < Minitest::Test + # ---- StatusCode ---- + + def setup + @response = Wreq.get("#{HTTPBIN_URL}/status/201") + end + + def test_status_code_to_i + assert_equal 201, @response.status.to_i + end + + def test_status_code_as_int_still_works + assert_equal 201, @response.status.as_int + end + + def test_status_code_to_i_matches_as_int + assert_equal @response.status.as_int, @response.status.to_i + end + + def test_status_code_equality + a = @response.status + b = @response.status + assert_equal a, b + end + + def test_status_code_eql + a = @response.status + b = @response.status + assert a.eql?(b) + end + + def test_status_code_hash_consistent + a = @response.status + b = @response.status + assert_equal a.hash, b.hash + end + + def test_status_code_as_hash_key + status = @response.status + h = { status => "created" } + assert_equal "created", h[@response.status] + end + + def test_status_code_in_set + s = Set.new + s.add(@response.status) + assert_includes s, @response.status + end + + def test_status_code_not_equal_to_integer + refute_equal @response.status, 201 + refute @response.status.eql?(201) + end + + def test_status_code_different_values_not_equal + ok = Wreq.get("#{HTTPBIN_URL}/status/200").status + created = @response.status + refute_equal ok, created + refute ok.eql?(created) + end + + def test_response_code_still_returns_integer + assert_instance_of Integer, @response.code + assert_equal 201, @response.code + end + + # ---- Version ---- + + def test_version_equality + assert_equal Wreq::Version::HTTP_11, Wreq::Version::HTTP_11 + end + + def test_version_eql + v = Wreq::Version::HTTP_11 + assert v.eql?(Wreq::Version::HTTP_11) + end + + def test_version_hash_consistent + a = Wreq::Version::HTTP_11 + b = Wreq::Version::HTTP_11 + assert_equal a.hash, b.hash + end + + def test_version_different_values_not_equal + refute_equal Wreq::Version::HTTP_11, Wreq::Version::HTTP_2 + end + + def test_version_as_hash_key + v = Wreq::Version::HTTP_11 + h = { v => "http1.1" } + assert_equal "http1.1", h[Wreq::Version::HTTP_11] + end + + def test_version_in_set + s = Set.new([Wreq::Version::HTTP_11, Wreq::Version::HTTP_2]) + assert_includes s, Wreq::Version::HTTP_11 + assert_includes s, Wreq::Version::HTTP_2 + refute_includes s, Wreq::Version::HTTP_3 + end + + # ---- Method ---- + + def test_method_to_s + assert_equal "GET", Wreq::Method::GET.to_s + assert_equal "POST", Wreq::Method::POST.to_s + assert_equal "PUT", Wreq::Method::PUT.to_s + assert_equal "DELETE", Wreq::Method::DELETE.to_s + assert_equal "HEAD", Wreq::Method::HEAD.to_s + assert_equal "OPTIONS", Wreq::Method::OPTIONS.to_s + assert_equal "TRACE", Wreq::Method::TRACE.to_s + assert_equal "PATCH", Wreq::Method::PATCH.to_s + end + + def test_method_to_sym + assert_equal :get, Wreq::Method::GET.to_sym + assert_equal :post, Wreq::Method::POST.to_sym + assert_equal :put, Wreq::Method::PUT.to_sym + assert_equal :delete, Wreq::Method::DELETE.to_sym + assert_equal :head, Wreq::Method::HEAD.to_sym + assert_equal :options, Wreq::Method::OPTIONS.to_sym + assert_equal :trace, Wreq::Method::TRACE.to_sym + assert_equal :patch, Wreq::Method::PATCH.to_sym + end + + def test_method_equality + assert_equal Wreq::Method::GET, Wreq::Method::GET + refute_equal Wreq::Method::GET, Wreq::Method::POST + end + + def test_method_eql + assert Wreq::Method::GET.eql?(Wreq::Method::GET) + refute Wreq::Method::GET.eql?(Wreq::Method::POST) + end + + def test_method_hash_consistent + assert_equal Wreq::Method::GET.hash, Wreq::Method::GET.hash + refute_equal Wreq::Method::GET.hash, Wreq::Method::POST.hash + end + + def test_method_as_hash_key + h = { Wreq::Method::GET => "get it" } + assert_equal "get it", h[Wreq::Method::GET] + assert_nil h[Wreq::Method::POST] + end + + # ---- SameSite ---- + + def test_same_site_to_s + assert_equal "Strict", Wreq::SameSite::Strict.to_s + assert_equal "Lax", Wreq::SameSite::Lax.to_s + assert_equal "None", Wreq::SameSite::None.to_s + end + + def test_same_site_to_sym + assert_equal :strict, Wreq::SameSite::Strict.to_sym + assert_equal :lax, Wreq::SameSite::Lax.to_sym + assert_equal :none, Wreq::SameSite::None.to_sym + end + + def test_same_site_equality + assert_equal Wreq::SameSite::Lax, Wreq::SameSite::Lax + refute_equal Wreq::SameSite::Lax, Wreq::SameSite::Strict + end + + def test_same_site_eql_and_hash + assert Wreq::SameSite::Lax.eql?(Wreq::SameSite::Lax) + assert_equal Wreq::SameSite::Lax.hash, Wreq::SameSite::Lax.hash + end + + # ---- Profile ---- + + def test_profile_equality + assert_equal Wreq::Profile::Chrome134, Wreq::Profile::Chrome134 + refute_equal Wreq::Profile::Chrome134, Wreq::Profile::Chrome135 + end + + def test_profile_eql_and_hash + assert Wreq::Profile::Chrome134.eql?(Wreq::Profile::Chrome134) + assert_equal Wreq::Profile::Chrome134.hash, Wreq::Profile::Chrome134.hash + end + + def test_profile_as_hash_key + h = { Wreq::Profile::Chrome134 => "chrome" } + assert_equal "chrome", h[Wreq::Profile::Chrome134] + assert_nil h[Wreq::Profile::Chrome135] + end + + # ---- Platform ---- + + def test_platform_equality + assert_equal Wreq::Platform::Windows, Wreq::Platform::Windows + refute_equal Wreq::Platform::Windows, Wreq::Platform::Linux + end + + def test_platform_eql_and_hash + assert Wreq::Platform::Windows.eql?(Wreq::Platform::Windows) + assert_equal Wreq::Platform::Windows.hash, Wreq::Platform::Windows.hash + end + + def test_platform_to_sym + assert_equal :windows, Wreq::Platform::Windows.to_sym + assert_equal :macos, Wreq::Platform::MacOS.to_sym + assert_equal :linux, Wreq::Platform::Linux.to_sym + assert_equal :android, Wreq::Platform::Android.to_sym + assert_equal :ios, Wreq::Platform::IOS.to_sym + end + + # ---- Cross-type comparisons ---- + + def test_cross_type_not_equal + refute_equal Wreq::Method::GET, Wreq::Version::HTTP_11 + refute_equal Wreq::SameSite::Lax, Wreq::Method::GET + refute_equal Wreq::Platform::Windows, Wreq::Profile::Chrome134 + end + + def test_cross_type_eql_false + refute Wreq::Method::GET.eql?(Wreq::Version::HTTP_11) + refute Wreq::SameSite::Lax.eql?(Wreq::Method::GET) + end +end \ No newline at end of file From 67ad9228238cf64434a16aa450718e6f7468baaf Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Tue, 21 Jul 2026 09:05:11 +0530 Subject: [PATCH 4/8] fix: ruby macro undefined issue (#140) * fix ruby undefined issue * fix(lib): pass Ruby handle to symbol methods --------- Co-authored-by: gngpp --- src/cookie.rs | 6 +++--- src/emulate.rs | 6 +++--- src/http.rs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cookie.rs b/src/cookie.rs index 5b9d5cd..192575f 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -35,13 +35,13 @@ impl SameSite { } /// SameSite attribute as a lowercase Ruby symbol. - pub fn to_sym(&self) -> magnus::Symbol { - let name = match self { + pub fn to_sym(ruby: &Ruby, rb_self: &Self) -> magnus::Symbol { + let name = match *rb_self { SameSite::Strict => "strict", SameSite::Lax => "lax", SameSite::None => "none", }; - ruby!().to_symbol(name) + ruby.to_symbol(name) } } diff --git a/src/emulate.rs b/src/emulate.rs index a977f13..8feaa1f 100644 --- a/src/emulate.rs +++ b/src/emulate.rs @@ -220,15 +220,15 @@ impl Platform { self.into_ffi().inspect() } - pub fn to_sym(&self) -> magnus::Symbol { - let name = match self { + pub fn to_sym(ruby: &Ruby, rb_self: &Self) -> magnus::Symbol { + let name = match *rb_self { Platform::Windows => "windows", Platform::MacOS => "macos", Platform::Linux => "linux", Platform::Android => "android", Platform::IOS => "ios", }; - ruby!().to_symbol(name) + ruby.to_symbol(name) } } diff --git a/src/http.rs b/src/http.rs index 2a9e5fe..8d63a12 100644 --- a/src/http.rs +++ b/src/http.rs @@ -46,8 +46,8 @@ impl Method { /// HTTP method as a lowercase Ruby symbol. #[inline] - pub fn to_sym(&self) -> magnus::Symbol { - let name = match self { + pub fn to_sym(ruby: &Ruby, rb_self: &Self) -> magnus::Symbol { + let name = match *rb_self { Method::GET => "get", Method::HEAD => "head", Method::POST => "post", @@ -57,7 +57,7 @@ impl Method { Method::TRACE => "trace", Method::PATCH => "patch", }; - ruby!().to_symbol(name) + ruby.to_symbol(name) } } From b7b3e01c2fe2dd32d0f10253b06be1eb15cce50a Mon Sep 17 00:00:00 2001 From: 0x676e67 Date: Tue, 21 Jul 2026 16:30:30 +0800 Subject: [PATCH 5/8] refactor(enum): centralize Ruby value methods (#141) --- lib/wreq.rb | 1 + lib/wreq_ruby/emulate.rb | 7 ++- src/cookie.rs | 31 +++--------- src/emulate.rs | 49 +++++-------------- src/http.rs | 80 +++++++++--------------------- src/macros.rs | 94 +++++++++++++++--------------------- test/value_semantics_test.rb | 25 +++++++--- 7 files changed, 103 insertions(+), 184 deletions(-) diff --git a/lib/wreq.rb b/lib/wreq.rb index 5e5ee21..4f23bc9 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -9,6 +9,7 @@ # Load type hint definitions require_relative "wreq_ruby/http" +require_relative "wreq_ruby/emulate" require_relative "wreq_ruby/client" require_relative "wreq_ruby/response" require_relative "wreq_ruby/body" diff --git a/lib/wreq_ruby/emulate.rb b/lib/wreq_ruby/emulate.rb index d0e4672..027ace3 100644 --- a/lib/wreq_ruby/emulate.rb +++ b/lib/wreq_ruby/emulate.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# Profile and platform constants mirror the native enum variant names. +# standard:disable Naming/ConstantName + module Wreq # Browser and client fingerprint profile enumeration backed by Rust. # @@ -279,7 +282,7 @@ def hash class Emulation # Native fields and methods are set by the extension. # This stub is for documentation only. - unless method_defined?(:new) + unless singleton_methods(false).include?(:new) # @param profile [Wreq::Profile, nil] Fingerprint profile to emulate # @param platform [Wreq::Platform, nil] Operating system platform to emulate # @param http2 [Boolean, nil] Whether HTTP/2 emulation is enabled; defaults @@ -294,3 +297,5 @@ def self.new(**options) end end end + +# standard:enable Naming/ConstantName diff --git a/src/cookie.rs b/src/cookie.rs index 192575f..14da8ea 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -13,38 +13,19 @@ use crate::{ gvl, }; +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `to_sym`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( /// The Cookie SameSite attribute. - const, SameSite, "Wreq::SameSite", cookie::SameSite, - Strict, - Lax, - None, + symbols: + Strict => "strict", + Lax => "lax", + None => "none", ); -impl SameSite { - /// SameSite attribute as a string. - pub fn to_s(&self) -> &'static str { - match self { - SameSite::Strict => "Strict", - SameSite::Lax => "Lax", - SameSite::None => "None", - } - } - - /// SameSite attribute as a lowercase Ruby symbol. - pub fn to_sym(ruby: &Ruby, rb_self: &Self) -> magnus::Symbol { - let name = match *rb_self { - SameSite::Strict => "strict", - SameSite::Lax => "lax", - SameSite::None => "none", - }; - ruby.to_symbol(name) - } -} - /// A single HTTP cookie. #[derive(Clone)] #[magnus::wrap(class = "Wreq::Cookie", free_immediately, size)] diff --git a/src/emulate.rs b/src/emulate.rs index 8feaa1f..c1c20ac 100644 --- a/src/emulate.rs +++ b/src/emulate.rs @@ -1,8 +1,5 @@ use ::serde::Deserialize; -use magnus::{ - Error, Module, Object, RModule, Ruby, Value, function, method, - typed_data::{Inspect, Obj}, -}; +use magnus::{Error, Module, Object, RModule, Ruby, Value, function, method, typed_data::Obj}; use crate::options::{NativeOption, Options}; @@ -41,9 +38,10 @@ impl Builder { } } +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( /// An emulation profile. - const, Profile, "Wreq::Profile", wreq_util::Profile, @@ -187,17 +185,19 @@ define_ruby_enum!( Opera131, ); +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `to_sym`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( /// An emulation profile for OS. - const, Platform, "Wreq::Platform", wreq_util::Platform, - Windows, - MacOS, - Linux, - Android, - IOS, + symbols: + Windows => "windows", + MacOS => "macos", + Linux => "linux", + Android => "android", + IOS => "ios", ); /// A struct to represent the `EmulationOption` class. @@ -205,33 +205,6 @@ define_ruby_enum!( #[magnus::wrap(class = "Wreq::Emulation", free_immediately, size)] pub struct Emulation(pub wreq_util::Emulation); -// ===== impl Profile ===== - -impl Profile { - pub fn to_s(&self) -> String { - self.into_ffi().inspect() - } -} - -// ===== impl Platform ===== - -impl Platform { - pub fn to_s(&self) -> String { - self.into_ffi().inspect() - } - - pub fn to_sym(ruby: &Ruby, rb_self: &Self) -> magnus::Symbol { - let name = match *rb_self { - Platform::Windows => "windows", - Platform::MacOS => "macos", - Platform::Linux => "linux", - Platform::Android => "android", - Platform::IOS => "ios", - }; - ruby.to_symbol(name) - } -} - // ===== impl Emulation ===== impl Emulation { diff --git a/src/http.rs b/src/http.rs index 8d63a12..d5f8376 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,81 +1,45 @@ -use magnus::{Error, Module, RModule, Ruby, TryConvert, Value, method, typed_data::Inspect}; +use magnus::{Error, Module, RModule, Ruby, TryConvert, Value, method}; +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `==`, `eql?`, and `hash` methods. +// `wreq::Version` only exposes protocol strings through `Debug`, so static +// labels avoid allocating an intermediate `String`. define_ruby_enum!( /// An HTTP version. - const, Version, "Wreq::Version", wreq::Version, - HTTP_09, - HTTP_10, - HTTP_11, - HTTP_2, - HTTP_3, + strings: + HTTP_09 => "HTTP/0.9", + HTTP_10 => "HTTP/1.0", + HTTP_11 => "HTTP/1.1", + HTTP_2 => "HTTP/2.0", + HTTP_3 => "HTTP/3.0", ); +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `to_sym`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( /// An HTTP method. Method, "Wreq::Method", wreq::Method, - GET, - HEAD, - POST, - PUT, - DELETE, - OPTIONS, - TRACE, - PATCH, + symbols: + GET => "get", + HEAD => "head", + POST => "post", + PUT => "put", + DELETE => "delete", + OPTIONS => "options", + TRACE => "trace", + PATCH => "patch", ); -impl Method { - /// HTTP method token as a string. - #[inline] - pub fn to_s(&self) -> &'static str { - match self { - Method::GET => "GET", - Method::HEAD => "HEAD", - Method::POST => "POST", - Method::PUT => "PUT", - Method::DELETE => "DELETE", - Method::OPTIONS => "OPTIONS", - Method::TRACE => "TRACE", - Method::PATCH => "PATCH", - } - } - - /// HTTP method as a lowercase Ruby symbol. - #[inline] - pub fn to_sym(ruby: &Ruby, rb_self: &Self) -> magnus::Symbol { - let name = match *rb_self { - Method::GET => "get", - Method::HEAD => "head", - Method::POST => "post", - Method::PUT => "put", - Method::DELETE => "delete", - Method::OPTIONS => "options", - Method::TRACE => "trace", - Method::PATCH => "patch", - }; - ruby.to_symbol(name) - } -} - /// HTTP status code. #[derive(Clone, Copy, PartialEq, Eq, Hash)] #[magnus::wrap(class = "Wreq::StatusCode", free_immediately, size)] pub struct StatusCode(pub wreq::StatusCode); -// ===== impl Version ===== - -impl Version { - /// Convert version to string. - #[inline] - pub fn to_s(&self) -> String { - self.into_ffi().inspect() - } -} - impl TryConvert for Version { fn try_convert(value: magnus::Value) -> Result { <&Version>::try_convert(value).cloned() diff --git a/src/macros.rs b/src/macros.rs index 9ca1540..02d3f5d 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -54,45 +54,63 @@ macro_rules! extract_native_option { } macro_rules! define_ruby_enum { - ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $($variant:ident),* $(,)?) => { - define_ruby_enum!($(#[$meta])* $enum_type, $ruby_class, $ffi_type, $( ($variant, $variant) ),*); + ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, strings: $($variant:ident => $display:expr),* $(,)?) => { + define_ruby_enum!(@impl $(#[$meta])* $enum_type, $ruby_class, $ffi_type, [$(($variant, $display)),*], []); + }; + + ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, symbols: $($variant:ident => $symbol:expr),* $(,)?) => { + define_ruby_enum!(@impl $(#[$meta])* $enum_type, $ruby_class, $ffi_type, [$(($variant, stringify!($variant))),*], [$($variant => $symbol),*]); }; - ($(#[$meta:meta])* const, $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $($variant:ident),* $(,)?) => { - define_ruby_enum!($(#[$meta])* const, $enum_type, $ruby_class, $ffi_type, $( ($variant, $variant) ),*); + ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $($variant:ident),* $(,)?) => { + define_ruby_enum!(@impl $(#[$meta])* $enum_type, $ruby_class, $ffi_type, [$(($variant, stringify!($variant))),*], []); }; - ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $(($rust_variant:ident, $ffi_variant:ident)),* $(,)?) => { + (@impl $(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, [$(($variant:ident, $display:expr)),*], [$($symbol_variant:ident => $symbol:expr),*]) => { $(#[$meta])* #[magnus::wrap(class = $ruby_class, free_immediately, size)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[allow(non_camel_case_types)] #[allow(clippy::upper_case_acronyms)] pub enum $enum_type { - $($rust_variant),* + $($variant),* } impl $enum_type { + /// Return the static text exposed through Ruby's `to_s`. + #[inline] + pub const fn to_s(&self) -> &'static str { + match self { + $(<$enum_type>::$variant => $display,)* + } + } + + define_ruby_enum!(@to_sym $enum_type, [$($symbol_variant => $symbol),*]); + + /// Convert this Ruby wrapper into its native enum value. pub fn into_ffi(self) -> $ffi_type { match self { - $(<$enum_type>::$rust_variant => <$ffi_type>::$ffi_variant,)* + $(<$enum_type>::$variant => <$ffi_type>::$variant,)* } } + /// Convert a known native enum value into its Ruby wrapper. #[allow(dead_code)] pub fn from_ffi(ffi: $ffi_type) -> Self { #[allow(unreachable_patterns)] match ffi { - $(<$ffi_type>::$ffi_variant => <$enum_type>::$rust_variant,)* + $(<$ffi_type>::$variant => <$enum_type>::$variant,)* _ => unreachable!(), } } + /// Register every enum variant as a constant on the Ruby class. pub fn define_constants(class: magnus::RClass) -> Result<(), magnus::Error> { - $(class.const_set(stringify!($rust_variant), <$enum_type>::$rust_variant)?;)* + $(class.const_set(stringify!($variant), <$enum_type>::$variant)?;)* Ok(()) } + /// Compare two wrapped enum values for Ruby's `==`. pub fn equals(&self, other: magnus::Value) -> bool { use magnus::TryConvert; <&$enum_type>::try_convert(other) @@ -100,10 +118,12 @@ macro_rules! define_ruby_enum { .unwrap_or(false) } + /// Compare two wrapped enum values for Ruby's `eql?`. pub fn is_eql(&self, other: magnus::Value) -> bool { self.equals(other) } + /// Return a hash consistent with Ruby's `eql?` contract. pub fn hash_value(&self) -> u64 { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); @@ -113,54 +133,16 @@ macro_rules! define_ruby_enum { } }; - ($(#[$meta:meta])* const, $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $(($rust_variant:ident, $ffi_variant:ident)),* $(,)?) => { - $(#[$meta])* - #[magnus::wrap(class = $ruby_class, free_immediately, size)] - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - #[allow(non_camel_case_types)] - #[allow(clippy::upper_case_acronyms)] - pub enum $enum_type { - $($rust_variant),* - } - - impl $enum_type { - pub const fn into_ffi(self) -> $ffi_type { - match self { - $(<$enum_type>::$rust_variant => <$ffi_type>::$ffi_variant,)* - } - } + (@to_sym $enum_type:ident, []) => {}; - #[allow(dead_code)] - pub const fn from_ffi(ffi: $ffi_type) -> Self { - #[allow(unreachable_patterns)] - match ffi { - $(<$ffi_type>::$ffi_variant => <$enum_type>::$rust_variant,)* - _ => unreachable!(), - } - } - - pub fn define_constants(class: magnus::RClass) -> Result<(), magnus::Error> { - $(class.const_set(stringify!($rust_variant), <$enum_type>::$rust_variant)?;)* - Ok(()) - } - - pub fn equals(&self, other: magnus::Value) -> bool { - use magnus::TryConvert; - <&$enum_type>::try_convert(other) - .map(|other| *self == *other) - .unwrap_or(false) - } - - pub fn is_eql(&self, other: magnus::Value) -> bool { - self.equals(other) - } - - pub fn hash_value(&self) -> u64 { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.hash(&mut hasher); - hasher.finish() - } + (@to_sym $enum_type:ident, [$($variant:ident => $symbol:expr),+]) => { + /// Return the configured Ruby symbol for this enum value. + #[inline] + pub fn to_sym(ruby: &magnus::Ruby, rb_self: &Self) -> magnus::Symbol { + let name = match *rb_self { + $(<$enum_type>::$variant => $symbol,)+ + }; + ruby.to_symbol(name) } }; } diff --git a/test/value_semantics_test.rb b/test/value_semantics_test.rb index 6acd872..78edc27 100644 --- a/test/value_semantics_test.rb +++ b/test/value_semantics_test.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "test_helper" -require "set" class ValueSemanticsTest < Minitest::Test # ---- StatusCode ---- @@ -42,7 +41,7 @@ def test_status_code_hash_consistent def test_status_code_as_hash_key status = @response.status - h = { status => "created" } + h = {status => "created"} assert_equal "created", h[@response.status] end @@ -73,6 +72,11 @@ def test_response_code_still_returns_integer def test_version_equality assert_equal Wreq::Version::HTTP_11, Wreq::Version::HTTP_11 + assert_equal "HTTP/0.9", Wreq::Version::HTTP_09.to_s + assert_equal "HTTP/1.0", Wreq::Version::HTTP_10.to_s + assert_equal "HTTP/1.1", Wreq::Version::HTTP_11.to_s + assert_equal "HTTP/2.0", Wreq::Version::HTTP_2.to_s + assert_equal "HTTP/3.0", Wreq::Version::HTTP_3.to_s end def test_version_eql @@ -92,7 +96,7 @@ def test_version_different_values_not_equal def test_version_as_hash_key v = Wreq::Version::HTTP_11 - h = { v => "http1.1" } + h = {v => "http1.1"} assert_equal "http1.1", h[Wreq::Version::HTTP_11] end @@ -143,7 +147,7 @@ def test_method_hash_consistent end def test_method_as_hash_key - h = { Wreq::Method::GET => "get it" } + h = {Wreq::Method::GET => "get it"} assert_equal "get it", h[Wreq::Method::GET] assert_nil h[Wreq::Method::POST] end @@ -177,6 +181,9 @@ def test_same_site_eql_and_hash def test_profile_equality assert_equal Wreq::Profile::Chrome134, Wreq::Profile::Chrome134 refute_equal Wreq::Profile::Chrome134, Wreq::Profile::Chrome135 + assert_equal "Chrome134", Wreq::Profile::Chrome134.to_s + assert_equal "SafariIos17_4_1", Wreq::Profile::SafariIos17_4_1.to_s + assert_equal "OkHttp4_12", Wreq::Profile::OkHttp4_12.to_s end def test_profile_eql_and_hash @@ -185,7 +192,7 @@ def test_profile_eql_and_hash end def test_profile_as_hash_key - h = { Wreq::Profile::Chrome134 => "chrome" } + h = {Wreq::Profile::Chrome134 => "chrome"} assert_equal "chrome", h[Wreq::Profile::Chrome134] assert_nil h[Wreq::Profile::Chrome135] end @@ -203,6 +210,12 @@ def test_platform_eql_and_hash end def test_platform_to_sym + assert_equal "Windows", Wreq::Platform::Windows.to_s + assert_equal "MacOS", Wreq::Platform::MacOS.to_s + assert_equal "Linux", Wreq::Platform::Linux.to_s + assert_equal "Android", Wreq::Platform::Android.to_s + assert_equal "IOS", Wreq::Platform::IOS.to_s + assert_equal :windows, Wreq::Platform::Windows.to_sym assert_equal :macos, Wreq::Platform::MacOS.to_sym assert_equal :linux, Wreq::Platform::Linux.to_sym @@ -222,4 +235,4 @@ def test_cross_type_eql_false refute Wreq::Method::GET.eql?(Wreq::Version::HTTP_11) refute Wreq::SameSite::Lax.eql?(Wreq::Method::GET) end -end \ No newline at end of file +end From 14e3ef24b46f6c16a090e2d454e2740881621fd0 Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 21 Jul 2026 17:41:25 +0800 Subject: [PATCH 6/8] test(cookie): cover numeric expiry semantics --- lib/wreq_ruby/cookie.rb | 3 ++- test/cookie_test.rb | 28 +++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/wreq_ruby/cookie.rb b/lib/wreq_ruby/cookie.rb index 496f8bc..5f004c5 100644 --- a/lib/wreq_ruby/cookie.rb +++ b/lib/wreq_ruby/cookie.rb @@ -56,7 +56,7 @@ class Cookie # @option options [String] :domain Domain attribute # @option options [String] :path Path attribute # @option options [Integer] :max_age Signed Max-Age in seconds; zero or negative expires immediately - # @option options [Time, Numeric] :expires Expiration time or finite Unix timestamp + # @option options [Time, Numeric] :expires Expiration time or finite Unix timestamp in seconds # @option options [Boolean] :http_only HttpOnly flag # @option options [Boolean] :secure Secure flag # @option options [Wreq::SameSite] :same_site SameSite attribute @@ -137,6 +137,7 @@ def expires_at end # Returns the expiration as fractional Unix seconds. + # Large timestamps may lose precision when represented as a Float. # @deprecated Use {#expires_at} for a Ruby-native time value. # @return [Float, nil] def expires diff --git a/test/cookie_test.rb b/test/cookie_test.rb index 82b80e3..f24ac51 100644 --- a/test/cookie_test.rb +++ b/test/cookie_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "bigdecimal" require "open3" require "rbconfig" @@ -173,16 +174,37 @@ def test_expires_accepts_past_time end def test_expires_accepts_integer_and_fractional_timestamps - [1_893_456_000, 1_893_456_000.125, -1.25].each do |timestamp| + [ + 1_893_456_000, + 1_893_456_000.125, + Rational(-5, 4), + BigDecimal("1893456000.125") + ].each do |timestamp| cookie = Wreq::Cookie.new("timestamp", "value", expires: timestamp) assert_kind_of Time, cookie.expires_at assert_predicate cookie.expires_at, :utc? - assert_in_delta timestamp, cookie.expires_at.to_f, 1e-6 - assert_in_delta timestamp, cookie.expires, 1e-6 + assert_in_delta timestamp.to_f, cookie.expires_at.to_f, 1e-6 + assert_in_delta timestamp.to_f, cookie.expires, 1e-6 end end + def test_expires_normalizes_time_to_utc_without_losing_precision + expiration = Time.new( + 2030, + 1, + 1, + 12, + 34, + Rational(123_456_789, 1_000_000_000), + "+09:00" + ) + actual = Wreq::Cookie.new("offset", "value", expires: expiration).expires_at + + assert_predicate actual, :utc? + assert_equal expiration.to_r, actual.to_r + end + def test_expires_rejects_non_finite_timestamps [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |timestamp| error = assert_raises(ArgumentError) do From 4ec84e6d1ea140bc001ec4ef38d990dd67484d9b Mon Sep 17 00:00:00 2001 From: 0x676e67 Date: Wed, 22 Jul 2026 07:56:12 +0800 Subject: [PATCH 7/8] feat(cookie): expose idiomatic Ruby APIs (#142) --- lib/wreq_ruby/cookie.rb | 22 ++++++++++++++++++++-- src/cookie.rs | 31 ++++++++++++++++++++++++++----- test/cookie_test.rb | 27 ++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/lib/wreq_ruby/cookie.rb b/lib/wreq_ruby/cookie.rb index 5f004c5..1ec0295 100644 --- a/lib/wreq_ruby/cookie.rb +++ b/lib/wreq_ruby/cookie.rb @@ -3,6 +3,8 @@ module Wreq # Cookie SameSite attribute. # # Values follow the Rust enum exposed by the native extension. + # Constant names mirror the native SameSite variants. + # standard:disable Naming/ConstantName class SameSite # Strict same-site policy. Strict = nil @@ -38,6 +40,7 @@ def eql?(other) def hash end end + # standard:enable Naming/ConstantName # A single HTTP cookie. # @@ -116,6 +119,11 @@ def same_site_lax? def same_site_strict? end + # Returns the SameSite directive, or nil when it is not set. + # @return [Wreq::SameSite, nil] + def same_site + end + # @return [String, nil] Path attribute def path end @@ -142,6 +150,11 @@ def expires_at # @return [Float, nil] def expires end + + # Serializes the cookie as a Set-Cookie string. + # @return [String] + def to_s + end end # A cookie store (jar) used by the client to manage cookies across requests. @@ -156,10 +169,11 @@ def self.new def get_all end - # Add a cookie from a Set-Cookie string for the given URL. - # @param cookie [String, Wreq::Cookie] A Set-Cookie string + # Add a Cookie object or Set-Cookie string for the given URL. + # @param cookie [String, Wreq::Cookie] A Set-Cookie string or Cookie object # @param url [String] # @return [void] + # @raise [TypeError] if cookie is neither a String nor Wreq::Cookie def add(cookie, url) end @@ -180,6 +194,8 @@ def clear module Wreq class Cookie + # Returns a compact representation for debugging. + # @return [String] def inspect parts = ["#" end diff --git a/src/cookie.rs b/src/cookie.rs index d8ed256..b4110c0 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -184,6 +184,12 @@ impl Cookie { self.0.same_site() == Some(cookie::SameSite::Strict) } + /// Return the SameSite directive, if set. + #[inline] + pub fn same_site(&self) -> Option { + self.0.same_site().map(SameSite::from_ffi) + } + /// Returns the path directive of the cookie, if set. #[inline] pub fn path(&self) -> Option<&str> { @@ -216,6 +222,12 @@ impl Cookie { pub fn expires(&self) -> Option { self.0.expires_datetime().map(to_unix_timestamp) } + + /// Serialize the cookie as a Set-Cookie string. + #[inline] + pub fn to_s(&self) -> String { + self.to_string() + } } // ===== Native Cookie helpers ===== @@ -318,14 +330,21 @@ impl Jar { } /// Add a cookie to this jar. - pub fn add(&self, cookie: Value, url: String) { + /// + /// # Errors + /// + /// Returns `TypeError` when `cookie` is neither a [`Cookie`] nor a String. + pub fn add(&self, cookie: Value, url: String) -> Result<(), Error> { if let Ok(cookie) = Obj::::try_convert(cookie) { - return gvl::nogvl(|| self.0.add(cookie.clone_for_jar(), &url)); + gvl::nogvl(|| self.0.add(cookie.clone_for_jar(), &url)); + return Ok(()); } - if let Ok(cookie_str) = String::try_convert(cookie) { - gvl::nogvl(|| self.0.add(cookie_str.as_ref(), &url)) - } + let ruby = Ruby::get_with(cookie); + let cookie = String::try_convert(cookie) + .map_err(|_| type_error(&ruby, "cookie must be a Wreq::Cookie or String"))?; + gvl::nogvl(|| self.0.add(cookie.as_ref(), &url)); + Ok(()) } /// Remove a cookie from this jar by name and URL. @@ -466,11 +485,13 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { cookie_class.define_method("secure?", method!(Cookie::secure, 0))?; cookie_class.define_method("same_site_lax?", method!(Cookie::same_site_lax, 0))?; cookie_class.define_method("same_site_strict?", method!(Cookie::same_site_strict, 0))?; + cookie_class.define_method("same_site", method!(Cookie::same_site, 0))?; cookie_class.define_method("path", method!(Cookie::path, 0))?; cookie_class.define_method("domain", method!(Cookie::domain, 0))?; cookie_class.define_method("max_age", method!(Cookie::max_age, 0))?; cookie_class.define_method("expires_at", method!(Cookie::expires_at, 0))?; cookie_class.define_method("expires", method!(Cookie::expires, 0))?; + cookie_class.define_method("to_s", method!(Cookie::to_s, 0))?; // Jar class let jar_class = gem_module.define_class("Jar", ruby.class_object())?; diff --git a/test/cookie_test.rb b/test/cookie_test.rb index f24ac51..53ed157 100644 --- a/test/cookie_test.rb +++ b/test/cookie_test.rb @@ -44,6 +44,14 @@ def test_add_and_get_all assert_equal true, (c.secure || c.secure?) end + def test_add_rejects_invalid_cookie_type + error = assert_raises(TypeError) do + @jar.add(Object.new, @base_url) + end + + assert_equal "cookie must be a Wreq::Cookie or String", error.message + end + def test_add_multiple_and_remove @jar.add("a=1; Path=/", @base_url) @jar.add("b=2; Path=/", @base_url) @@ -115,6 +123,8 @@ def test_cookie_new_minimal assert_equal false, (c.secure || c.secure?) assert_equal false, c.same_site_lax? assert_equal false, c.same_site_strict? + assert_nil c.same_site + assert_equal "sid=abc", c.to_s end def test_cookie_new_full_attributes @@ -144,6 +154,15 @@ def test_cookie_new_full_attributes assert_equal true, (c.secure || c.secure?) assert_equal true, c.same_site_lax? assert_equal false, c.same_site_strict? + assert_equal Wreq::SameSite::Lax, c.same_site + end + + def test_same_site_reader_distinguishes_none_from_unset + cookie = Wreq::Cookie.new("same-site", "value", same_site: Wreq::SameSite::None) + + assert_equal Wreq::SameSite::None, cookie.same_site + refute cookie.same_site_lax? + refute cookie.same_site_strict? end def test_cookie_new_uses_shared_keyword_validation @@ -328,10 +347,12 @@ def test_same_site_flags_from_parsed_header @jar.add("s2=1; Path=/; SameSite=Lax", @base_url) cookies = @jar.get_all - h = cookies.to_h { |ck| [ck.name, [ck.same_site_strict?, ck.same_site_lax?]] } + h = cookies.to_h do |cookie| + [cookie.name, [cookie.same_site, cookie.same_site_strict?, cookie.same_site_lax?]] + end - assert_equal [true, false], h["s1"] - assert_equal [false, true], h["s2"] + assert_equal [Wreq::SameSite::Strict, true, false], h["s1"] + assert_equal [Wreq::SameSite::Lax, false, true], h["s2"] end def test_request_uncompressed_cookies From 138e746735608890f52db94f079d568bed9b7aa8 Mon Sep 17 00:00:00 2001 From: gngpp Date: Thu, 23 Jul 2026 10:19:00 +0800 Subject: [PATCH 8/8] docs(cookie): clarify cookie API behavior --- lib/wreq_ruby/cookie.rb | 62 ++++++++++++++++---------------- src/cookie.rs | 80 ++++++++++++++++++++--------------------- src/error.rs | 2 +- test/cookie_test.rb | 2 +- 4 files changed, 72 insertions(+), 74 deletions(-) diff --git a/lib/wreq_ruby/cookie.rb b/lib/wreq_ruby/cookie.rb index 1ec0295..e13fdb7 100644 --- a/lib/wreq_ruby/cookie.rb +++ b/lib/wreq_ruby/cookie.rb @@ -1,9 +1,8 @@ unless defined?(Wreq) module Wreq - # Cookie SameSite attribute. + # SameSite values for HTTP cookies. # - # Values follow the Rust enum exposed by the native extension. - # Constant names mirror the native SameSite variants. + # The constant names match the native Rust variants. # standard:disable Naming/ConstantName class SameSite # Strict same-site policy. @@ -42,31 +41,30 @@ def hash end # standard:enable Naming/ConstantName - # A single HTTP cookie. + # A single HTTP cookie backed by an immutable native value. # - # Thread-safe: instances are backed by an immutable Rust value and can be - # shared across threads safely. This mirrors the native `Wreq::Cookie`. - # Constructor accepts `name`, `value`, plus optional keyword arguments for - # other attributes. + # Cookie instances can be shared safely between threads. Pass optional + # attributes as keywords to {.new}. class Cookie - # Create a new Cookie instance. + # Creates a Cookie instance. # - # Note: This matches the native binding which defines `new` (not `initialize`). + # The native extension defines `.new` directly instead of `#initialize`. # # @param name [String] Cookie name # @param value [String] Cookie value # @param options [Hash] Optional keyword arguments # @option options [String] :domain Domain attribute # @option options [String] :path Path attribute - # @option options [Integer] :max_age Signed Max-Age in seconds; zero or negative expires immediately - # @option options [Time, Numeric] :expires Expiration time or finite Unix timestamp in seconds + # @option options [Integer] :max_age Signed Max-Age in seconds. + # Zero or negative values expire the cookie immediately. + # @option options [Time, Numeric] :expires A Time or finite Unix timestamp in seconds # @option options [Boolean] :http_only HttpOnly flag # @option options [Boolean] :secure Secure flag # @option options [Wreq::SameSite] :same_site SameSite attribute # @return [Wreq::Cookie] - # @raise [ArgumentError] if an option is unknown, duplicated, or otherwise invalid - # @raise [RangeError] if Max-Age or the expiration is outside the supported range - # @raise [TypeError] if an option has an incompatible value type + # @raise [ArgumentError] if an option is unknown or duplicated, or if :expires is not finite + # @raise [RangeError] if :max_age or :expires is outside the supported range + # @raise [TypeError] if an option has the wrong type # @example # c = Wreq::Cookie.new( # "sid", "abc", @@ -119,7 +117,7 @@ def same_site_lax? def same_site_strict? end - # Returns the SameSite directive, or nil when it is not set. + # Returns the SameSite setting, or nil if it was omitted. # @return [Wreq::SameSite, nil] def same_site end @@ -132,46 +130,48 @@ def path def domain end - # Returns the signed Max-Age in seconds. + # Returns the signed Max-Age lifetime in seconds. # - # Zero and negative values indicate immediate expiration. + # Zero and negative values expire the cookie immediately. # @return [Integer, nil] def max_age end - # Returns the expiration as a UTC Ruby Time. + # Returns the expiration time in UTC. # @return [Time, nil] def expires_at end - # Returns the expiration as fractional Unix seconds. - # Large timestamps may lose precision when represented as a Float. - # @deprecated Use {#expires_at} for a Ruby-native time value. + # Returns the expiration as a Unix timestamp with fractional seconds. + # The Float return value may lose precision for large timestamps. + # @deprecated Use {#expires_at}, which returns Time. # @return [Float, nil] def expires end - # Serializes the cookie as a Set-Cookie string. + # Returns the cookie formatted as a Set-Cookie value. # @return [String] def to_s end end - # A cookie store (jar) used by the client to manage cookies across requests. + # Stores cookies for reuse across requests. + # + # Pass a Jar to Wreq::Client as `cookie_provider` to share its cookies. class Jar - # Create a new, empty cookie jar. + # Creates an empty cookie jar. # @return [Wreq::Jar] def self.new end - # Get all cookies currently stored. + # Returns all stored cookies. # @return [Array] def get_all end - # Add a Cookie object or Set-Cookie string for the given URL. - # @param cookie [String, Wreq::Cookie] A Set-Cookie string or Cookie object - # @param url [String] + # Adds a Cookie object or Set-Cookie string for the given URL. + # @param cookie [String, Wreq::Cookie] Cookie to store + # @param url [String] URL that scopes the cookie # @return [void] # @raise [TypeError] if cookie is neither a String nor Wreq::Cookie def add(cookie, url) @@ -194,7 +194,7 @@ def clear module Wreq class Cookie - # Returns a compact representation for debugging. + # Returns a short representation for debugging. # @return [String] def inspect parts = ["#" diff --git a/src/cookie.rs b/src/cookie.rs index b4110c0..fdb16f6 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -1,8 +1,8 @@ //! Ruby bindings for HTTP cookies and the shared cookie jar. //! -//! Cookie expiration follows [RFC 6265]: Ruby `Time` values and finite Unix -//! timestamps are converted into signed native date-times, while non-positive -//! Max-Age values represent immediate expiration. +//! Expiration follows [RFC 6265]. The binding accepts Ruby `Time` values and +//! finite Unix timestamps without converting them through unsigned +//! `SystemTime`. A non-positive Max-Age expires the cookie immediately. //! //! [RFC 6265]: https://www.rfc-editor.org/rfc/rfc6265.html @@ -47,7 +47,7 @@ pub struct Cookie(RawCookie<'static>); #[derive(Default)] pub struct Cookies(pub Vec); -/// Keyword attributes used to build a [`Cookie`]. +/// Keyword options accepted by `Wreq::Cookie.new`. #[derive(Deserialize)] struct Builder { /// The Domain attribute. @@ -56,11 +56,11 @@ struct Builder { /// The Path attribute. path: Option, - /// The signed Max-Age in seconds. + /// The signed Max-Age lifetime in seconds. #[serde(default)] max_age: NativeOption, - /// The absolute expiration accepted through Magnus conversion. + /// The expiration converted from a Ruby `Time` or Numeric value. #[serde(default)] expires: NativeOption, @@ -75,11 +75,9 @@ struct Builder { same_site: NativeOption>, } -/// A good default `CookieStore` implementation. +/// A cookie jar that can be shared with a Ruby `Wreq::Client`. /// -/// This is the implementation used when simply calling `cookie_store(true)`. -/// This type is exposed to allow creating one and filling it with some -/// existing cookies more easily, before creating a `Client`. +/// Pass a populated jar as the client's `cookie_provider` option. #[derive(Clone, Default)] #[magnus::wrap(class = "Wreq::Jar", free_immediately, size)] pub struct Jar(pub Arc); @@ -87,12 +85,12 @@ pub struct Jar(pub Arc); // ===== impl Builder ===== impl Builder { - /// Deserialize and convert one validated Cookie attribute Hash. + /// Validates and deserializes a Ruby keyword options hash. /// /// # Errors /// - /// Returns `ArgumentError` for unknown or duplicate attributes and retains - /// the Ruby conversion error for invalid values. + /// Returns `ArgumentError` for unknown or duplicate keys. Invalid values + /// return the Ruby conversion error with the option name attached. fn from_options(options: Options<'_>) -> Result { let mut builder = options.validate_keys::()?.deserialize::()?; extract_native_option!(options, builder, max_age); @@ -101,7 +99,7 @@ impl Builder { Ok(builder) } - /// Build a native Cookie from its required identity and optional attributes. + /// Builds a native cookie from its name, value, and optional attributes. fn build(mut self, name: String, value: String) -> Cookie { let mut cookie = RawCookie::new(name, value); @@ -135,12 +133,12 @@ impl Builder { // ===== Ruby Cookie API ===== impl Cookie { - /// Create a new [`Cookie`] from a name, value, and keyword attributes. + /// Creates a [`Cookie`] from its name, value, and keyword options. /// /// # Errors /// - /// Returns `ArgumentError` for unknown or duplicate attributes and retains - /// the Ruby conversion error for invalid values. + /// Returns `ArgumentError` for unknown or duplicate keys. Invalid values + /// return the Ruby conversion error with the option name attached. pub fn new(ruby: &Ruby, args: &[Value]) -> Result { let args = magnus::scan_args::scan_args::<(String, String), (), (), (), RHash, ()>(args)?; let (name, value) = args.required; @@ -172,19 +170,19 @@ impl Cookie { self.0.secure().unwrap_or(false) } - /// Return whether the SameSite directive is Lax. + /// Returns true when the SameSite setting is Lax. #[inline] pub fn same_site_lax(&self) -> bool { self.0.same_site() == Some(cookie::SameSite::Lax) } - /// Return whether the SameSite directive is Strict. + /// Returns true when the SameSite setting is Strict. #[inline] pub fn same_site_strict(&self) -> bool { self.0.same_site() == Some(cookie::SameSite::Strict) } - /// Return the SameSite directive, if set. + /// Returns the SameSite setting, if present. #[inline] pub fn same_site(&self) -> Option { self.0.same_site().map(SameSite::from_ffi) @@ -202,13 +200,13 @@ impl Cookie { self.0.domain() } - /// Return the signed Max-Age in seconds. + /// Returns the signed Max-Age lifetime in seconds. #[inline] pub fn max_age(&self) -> Option { self.0.max_age().map(|d| d.whole_seconds()) } - /// Return the cookie expiration as a UTC Ruby `Time`. + /// Returns the cookie expiration as a UTC Ruby `Time`. pub fn expires_at(ruby: &Ruby, rb_self: &Self) -> Result, Error> { rb_self .0 @@ -217,13 +215,13 @@ impl Cookie { .transpose() } - /// Return the cookie expiration as legacy fractional Unix seconds. + /// Returns the cookie expiration as fractional Unix seconds. #[inline] pub fn expires(&self) -> Option { self.0.expires_datetime().map(to_unix_timestamp) } - /// Serialize the cookie as a Set-Cookie string. + /// Formats the cookie as a Set-Cookie value. #[inline] pub fn to_s(&self) -> String { self.to_string() @@ -235,10 +233,10 @@ impl Cookie { impl Cookie { /// Clone this cookie for insertion into the native jar. /// - /// [RFC 6265 section 5.2.2] treats a non-positive Max-Age as immediate - /// expiration. The native jar currently recognizes zero, so a negative - /// value is normalized only in this insertion clone; the Ruby cookie keeps - /// its original signed value. + /// [RFC 6265 section 5.2.2] says that zero or a negative Max-Age expires a + /// cookie immediately. The native jar recognizes zero for deletion, so + /// this clone maps negative values to zero before insertion. The Ruby + /// object keeps its original signed value. /// /// [RFC 6265 section 5.2.2]: https://www.rfc-editor.org/rfc/rfc6265.html#section-5.2.2 fn clone_for_jar(&self) -> RawCookie<'static> { @@ -359,7 +357,7 @@ impl Jar { } mod helper { - //! Ruby time conversion helpers for `Wreq::Cookie`. + //! Converts between Ruby time values and native cookie timestamps. use cookie::time::{Duration, OffsetDateTime, error::ComponentRange}; use magnus::{ @@ -369,15 +367,15 @@ mod helper { use crate::error::{argument_error, range_error}; - /// A validated cookie expiration accepted from Ruby. + /// A validated cookie expiration converted from Ruby. /// - /// Ruby `Time` values retain nanosecond resolution. Integer timestamps avoid a - /// floating-point conversion, while other Numeric values are accepted as - /// finite fractional Unix timestamps. + /// Ruby `Time` values keep nanosecond precision. Integer timestamps are + /// converted directly; other Numeric values use finite fractional Unix + /// seconds. pub(super) struct CookieExpiration(OffsetDateTime); impl CookieExpiration { - /// Return the validated UTC expiration used by the native cookie. + /// Unwraps the validated UTC expiration. pub(super) fn into_inner(self) -> OffsetDateTime { self.0 } @@ -390,7 +388,7 @@ mod helper { } } - /// Convert a native cookie expiration into a UTC Ruby `Time`. + /// Converts a native cookie expiration into a UTC Ruby `Time`. pub(super) fn to_ruby_time(ruby: &Ruby, value: OffsetDateTime) -> Result { ruby.time_timespec_new( Timespec { @@ -401,12 +399,12 @@ mod helper { ) } - /// Convert a native cookie expiration into legacy fractional Unix seconds. + /// Converts a native cookie expiration into fractional Unix seconds. pub(super) fn to_unix_timestamp(value: OffsetDateTime) -> f64 { value.unix_timestamp() as f64 + f64::from(value.nanosecond()) / 1_000_000_000.0 } - /// Convert a supported Ruby expiration value into a native UTC date-time. + /// Converts a Ruby `Time` or Numeric value into a native UTC timestamp. fn expiration_from_value(ruby: &Ruby, value: Value) -> Result { if let Some(time) = Time::from_value(value) { return expiration_from_time(ruby, time); @@ -421,7 +419,7 @@ mod helper { expiration_from_float(ruby, f64::try_convert(value)?) } - /// Convert a Ruby `Time` without passing through unsigned `SystemTime` math. + /// Converts a Ruby `Time` from its signed timespec without using `SystemTime`. fn expiration_from_time(ruby: &Ruby, value: Time) -> Result { let timespec = value.timespec()?; let nanosecond = u32::try_from(timespec.tv_nsec) @@ -436,13 +434,13 @@ mod helper { .map_err(|error| expiration_range_error(ruby, error)) } - /// Convert exact signed Unix seconds into the native cookie time range. + /// Converts signed Unix seconds into the native cookie time range. fn expiration_from_seconds(ruby: &Ruby, seconds: i64) -> Result { OffsetDateTime::from_unix_timestamp(seconds) .map_err(|error| expiration_range_error(ruby, error)) } - /// Convert a finite fractional Unix timestamp without using a panicking API. + /// Converts a finite fractional Unix timestamp with checked arithmetic. fn expiration_from_float(ruby: &Ruby, seconds: f64) -> Result { if !seconds.is_finite() { return Err(argument_error(ruby, "timestamp must be finite")); @@ -455,7 +453,7 @@ mod helper { .ok_or_else(|| range_error(ruby, "timestamp is outside the supported range")) } - /// Map the native date-time range error to Ruby's `RangeError`. + /// Maps a native timestamp range error to Ruby's `RangeError`. fn expiration_range_error(ruby: &Ruby, error: ComponentRange) -> Error { range_error( ruby, diff --git a/src/error.rs b/src/error.rs index 3549407..15fa60e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -192,7 +192,7 @@ pub fn argument_error(ruby: &Ruby, message: impl Into) -> MagnusError { MagnusError::new(ruby.exception_arg_error(), message.into()) } -/// Build a `RangeError` from a validation message. +/// Builds a Ruby `RangeError` from a validation message. pub fn range_error(ruby: &Ruby, message: impl Into) -> MagnusError { MagnusError::new(ruby.exception_range_error(), message.into()) } diff --git a/test/cookie_test.rb b/test/cookie_test.rb index 53ed157..6915437 100644 --- a/test/cookie_test.rb +++ b/test/cookie_test.rb @@ -95,7 +95,7 @@ def test_max_age_and_expires_optional @jar.add("exp=1; Expires=#{t.gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT")}; Path=/", @base_url) c2 = @jar.get_all.find { |c| c.name == "exp" } assert c2 - # expires_at returns Time and expires retains the numeric compatibility API + # expires_at returns Time. expires remains available for numeric callers. assert_kind_of Time, c2.expires_at assert_predicate c2.expires_at, :utc? if (e = c2.expires)