diff --git a/Cargo.lock b/Cargo.lock index 9caa026..7c8e22c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1001,6 +1001,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -1015,6 +1025,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -1534,7 +1555,9 @@ dependencies = [ "rb-sys", "rb-sys-env", "serde", + "serde_ignored", "serde_json", + "serde_path_to_error", "tokio", "wreq", "wreq-util", diff --git a/Cargo.toml b/Cargo.toml index b1bc6aa..bad747a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,10 +33,12 @@ wreq = { version = "6.0.0-rc", features = [ ] } wreq-util = { version = "3.0.0-rc", features = ["emulation-compression"] } serde = { version = "1.0.228", features = ["derive"] } +serde_ignored = "0.1.14" serde_json = { version = "1.0.150", features = [ "arbitrary_precision", "preserve_order", ] } +serde_path_to_error = "0.1.20" indexmap = { version = "2.14.0", features = ["serde"] } cookie = "0.18.1" bytes = "1.12.1" diff --git a/lib/wreq.rb b/lib/wreq.rb index 2ae520c..5e5ee21 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -22,20 +22,26 @@ module Wreq # @return [String] VERSION = nil + # Module request methods accept only the options documented for each + # method. Unknown, ambiguous, ineffective, and unavailable platform options + # raise ArgumentError. Known values retain the error class from their Ruby + # or native conversion, such as TypeError or Wreq::BuilderError. Validation + # finishes before network I/O. + # Send an HTTP request. # # @param method [Wreq::Method] HTTP method to use # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -44,14 +50,15 @@ module Wreq # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.request(method, url, **options) end @@ -60,14 +67,14 @@ def self.request(method, url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -76,14 +83,15 @@ def self.request(method, url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.get(url, **options) end @@ -92,14 +100,14 @@ def self.get(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -108,14 +116,15 @@ def self.get(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.head(url, **options) end @@ -124,14 +133,14 @@ def self.head(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -140,14 +149,15 @@ def self.head(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.post(url, **options) end @@ -156,14 +166,14 @@ def self.post(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -172,14 +182,15 @@ def self.post(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.put(url, **options) end @@ -188,14 +199,14 @@ def self.put(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -204,14 +215,15 @@ def self.put(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.delete(url, **options) end @@ -220,14 +232,14 @@ def self.delete(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -236,14 +248,15 @@ def self.delete(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.options(url, **options) end @@ -252,14 +265,14 @@ def self.options(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -268,14 +281,15 @@ def self.options(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.trace(url, **options) end @@ -284,14 +298,14 @@ def self.trace(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -300,14 +314,15 @@ def self.trace(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def self.patch(url, **options) end end diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index 6908165..c9bef7c 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -11,6 +11,12 @@ module Wreq # The client is thread-safe and maintains an internal connection pool for # efficient request reuse. # + # Client and request options are limited to the keys listed below. Unknown, + # ambiguous, ineffective, and unavailable platform options raise + # ArgumentError. Known values retain the error class from their Ruby or + # native conversion, such as TypeError or Wreq::BuilderError. Request + # validation finishes before network I/O. + # # @example Basic usage # client = Wreq::Client.new # # Use client for HTTP requests @@ -50,8 +56,7 @@ class Client # will be returned directly to the caller. # # @param max_redirects [Integer, nil] Maximum number of redirects to - # follow before returning an error. Only applies when allow_redirects - # is true. Default is typically 10 if not specified. + # follow before returning an error. Requires `allow_redirects: true`. # # @param cookie_store [Boolean, nil] Enable an in-memory cookie jar # that automatically handles Set-Cookie headers and sends appropriate @@ -85,7 +90,7 @@ class Client # # @param tcp_user_timeout [Integer, nil] Maximum time in seconds that # transmitted data may remain unacknowledged before the connection is - # forcibly closed. Linux-specific option (Android, Fuchsia, Linux only). + # forcibly closed. 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 @@ -133,7 +138,9 @@ class Client # Supports HTTP, HTTPS, and SOCKS5 proxies. Format: "protocol://host:port" # Example: "http://proxy.example.com:8080" # @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. - # @param interface [String, nil] Bind the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind sockets to a network interface on + # platforms supported by the native client. Unsupported platforms raise + # ArgumentError. # # @param gzip [Boolean, nil] Accept and automatically decompress gzip # content encoding. When true, adds "Accept-Encoding: gzip" header. @@ -151,10 +158,13 @@ class Client # # @return [Wreq::Client] A configured HTTP client instance ready to make requests. # - # @raise [ArgumentError] if incompatible options are specified together - # (e.g., http1_only and http2_only both true). - # @raise [RuntimeError] if the underlying client cannot be initialized - # due to system resource constraints or invalid configuration. + # @raise [ArgumentError] if an option is unknown, conflicting, + # ineffective, or unavailable on the current platform. + # @raise [TypeError] if the option argument is not a Hash. + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted or validated. + # @raise [Wreq::BuilderError, Wreq::TlsError] if the native client cannot + # be initialized. # # @example Minimal client # client = Wreq::Client.new @@ -179,8 +189,7 @@ class Client # client = Wreq::Client.new( # allow_redirects: true, # max_redirects: 5, - # referer: true, - # history: true + # referer: true # ) # # @example Client with compression @@ -236,18 +245,22 @@ def self.new(**options) # Send an HTTP request. # + # Only the options listed here are accepted. Body options (`body`, `form`, + # `json`) and authentication options (`auth`, `bearer_auth`, `basic_auth`) + # are mutually exclusive. `max_redirects` requires `allow_redirects: true`. + # # @param method [Wreq::Method] HTTP method to use # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -256,14 +269,17 @@ def self.new(**options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [ArgumentError] if options are unknown, conflicting, ineffective, + # or unavailable on the current platform + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def request(method, url, **options) end @@ -272,14 +288,14 @@ def request(method, url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -288,14 +304,15 @@ def request(method, url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def get(url, **options) end @@ -304,14 +321,14 @@ def get(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -320,14 +337,15 @@ def get(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def head(url, **options) end @@ -336,14 +354,14 @@ def head(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -352,14 +370,15 @@ def head(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def post(url, **options) end @@ -368,14 +387,14 @@ def post(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -384,14 +403,15 @@ def post(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def put(url, **options) end @@ -400,14 +420,14 @@ def put(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -416,14 +436,15 @@ def put(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def delete(url, **options) end @@ -432,14 +453,14 @@ def delete(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -448,14 +469,15 @@ def delete(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def options(url, **options) end @@ -464,14 +486,14 @@ def options(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -480,14 +502,15 @@ def options(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def trace(url, **options) end @@ -496,14 +519,14 @@ def trace(url, **options) # @param url [String] Target URL # @param headers [Wreq::Headers, Hash{String=>String}, nil] Custom headers for this request # @param orig_headers [Array, nil] Original header names used to preserve raw header order and HTTP/1 case-sensitive header handling - # @param default_headers [Boolean, nil] Whether to apply default emulation headers + # @param default_headers [Boolean, nil] Whether to apply native default headers # @param query [Hash, nil] URL query parameters # @param auth [String, nil] Authorization header value # @param bearer_auth [String, nil] Bearer token for Authorization header # @param basic_auth [Array, nil] Username and password for basic auth # @param cookies [Hash{String=>String}, String, nil] Cookies to send # @param allow_redirects [Boolean, nil] Whether to follow redirects - # @param max_redirects [Integer, nil] Maximum number of redirects to follow + # @param max_redirects [Integer, nil] Maximum redirects; requires allow_redirects: true # @param gzip [Boolean, nil] Enable gzip compression # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression @@ -512,14 +535,15 @@ def trace(url, **options) # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) # @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 the socket to a specific network interface via `SO_BINDTODEVICE` (e.g., "eth0", "wlan0", "tun0"). Effective only on systems that support the option (Linux/Android/Fuchsia) and typically requires privileges (root or CAP_NET_ADMIN). + # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. # @param emulation [Wreq::Emulation, nil] Device/OS emulation for this request # @param version [Wreq::Version, nil] HTTP version to use # @param form [Hash{String=>String}, nil] Form data (application/x-www-form-urlencoded) - # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values - # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender + # @param json [Object, nil] JSON body serialized by the native encoder; Integer values retain arbitrary precision + # @param body [String, Wreq::BodySender, nil] Raw or streaming request body # @return [Wreq::Response] HTTP response - # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O + # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option + # value cannot be converted, validated, or built def patch(url, **options) end end diff --git a/lib/wreq_ruby/emulate.rb b/lib/wreq_ruby/emulate.rb index 66fb744..545ed33 100644 --- a/lib/wreq_ruby/emulate.rb +++ b/lib/wreq_ruby/emulate.rb @@ -215,18 +215,28 @@ def to_s # # @param profile [Wreq::Profile, nil] Fingerprint profile to emulate # @param platform [Wreq::Platform, nil] Operating system platform to emulate - # @param http2 [Boolean] Whether HTTP/2 support is enabled - # @param headers [Boolean] Whether default emulation headers are enabled + # @param http2 [Boolean, nil] Whether HTTP/2 emulation is enabled; defaults + # to true when omitted or nil + # @param headers [Boolean, nil] Whether default emulation headers are enabled; + # defaults to true when omitted or nil + # @return [Wreq::Emulation] Configured emulation settings + # @raise [ArgumentError] if an option is unknown or extra arguments are given + # @raise [TypeError] if the option argument is not a Hash or a value has the + # wrong Ruby type class Emulation # Native fields and methods are set by the extension. # This stub is for documentation only. unless method_defined?(:new) # @param profile [Wreq::Profile, nil] Fingerprint profile to emulate # @param platform [Wreq::Platform, nil] Operating system platform to emulate - # @param http2 [Boolean] Whether HTTP/2 support is enabled - # @param headers [Boolean] Whether default emulation headers are enabled + # @param http2 [Boolean, nil] Whether HTTP/2 emulation is enabled; defaults + # to true when omitted or nil + # @param headers [Boolean, nil] Whether default emulation headers are enabled; + # defaults to true when omitted or nil # @return [Wreq::Emulation] Configured emulation settings - def self.new(profile: nil, platform: nil, http2: true, headers: true) + # @raise [ArgumentError] if an option is unknown or extra arguments are given + # @raise [TypeError] if an option or value has the wrong Ruby type + def self.new(**options) end end end diff --git a/lib/wreq_ruby/error.rb b/lib/wreq_ruby/error.rb index 19fbd5c..65dd4dc 100644 --- a/lib/wreq_ruby/error.rb +++ b/lib/wreq_ruby/error.rb @@ -88,7 +88,7 @@ class StatusError < StandardError; end # # @example # begin - # client = Wreq::Client.new(max_redirects: 3) + # client = Wreq::Client.new(allow_redirects: true, max_redirects: 3) # client.get("https://httpbin.io/redirect/10") # rescue Wreq::RedirectError => e # puts "Too many redirects: #{e.message}" @@ -139,16 +139,14 @@ class DecodingError < StandardError; end # Configuration and builder errors - # Client configuration is invalid. + # A native client or request configuration could not be built. # - # Raised when the client is configured with invalid options. + # Raised when validated Ruby options cannot be represented by the native + # builder or request body. # # @example # begin - # client = Wreq::Client.new( - # proxy: "invalid://proxy", - # timeout: -1 - # ) + # client = Wreq::Client.new(proxy: "invalid://") # rescue Wreq::BuilderError => e # puts "Invalid configuration: #{e.message}" # end diff --git a/src/arch.rs b/src/arch.rs index d0888e3..cf427d5 100644 --- a/src/arch.rs +++ b/src/arch.rs @@ -6,6 +6,27 @@ //! do not leak into the rest of the binding. #![allow(unsafe_code)] +/// Whether the native client exposes TCP user-timeout configuration. +pub(crate) const SUPPORTS_TCP_USER_TIMEOUT: bool = cfg!(any( + target_os = "android", + target_os = "fuchsia", + target_os = "linux" +)); + +/// Whether the native client exposes network-interface binding. +pub(crate) const SUPPORTS_INTERFACE: bool = cfg!(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", +)); + #[cfg(all(target_os = "windows", target_env = "gnu"))] mod windows_gnu { //! Windows GNU support. diff --git a/src/client.rs b/src/client.rs index f93aa1e..3bdec58 100644 --- a/src/client.rs +++ b/src/client.rs @@ -7,38 +7,37 @@ pub mod resp; use std::{net::IpAddr, time::Duration}; use ::serde::Deserialize; -use magnus::{ - Module, Object, RHash, RModule, Ruby, TryConvert, Value, function, method, typed_data::Obj, -}; +use magnus::{Module, Object, RModule, Ruby, TryConvert, Value, function, method, typed_data::Obj}; use wreq::Proxy; use crate::{ + arch::{SUPPORTS_INTERFACE, SUPPORTS_TCP_USER_TIMEOUT}, client::{req::execute_request, resp::Response}, cookie::Jar, emulate::Emulation, - error::wreq_error_to_magnus, + error::wreq_error, extractor::Extractor, gvl, header::{Headers, OrigHeaders, UserAgent}, http::Method, - serde, + options::{NativeOption, Options}, }; /// A builder for `Client`. #[derive(Default, Deserialize)] struct Builder { // The emulation option for the client. - #[serde(skip)] - emulation: Option, + #[serde(default)] + emulation: NativeOption, /// The user agent to use for the client. - #[serde(skip)] - user_agent: Option, + #[serde(default)] + user_agent: NativeOption, /// The headers to use for the client. - #[serde(skip)] - headers: Option, + #[serde(default)] + headers: NativeOption, /// The original headers to use for the client. - #[serde(skip)] - orig_headers: Option, + #[serde(default)] + orig_headers: NativeOption, /// Whether to use referer. referer: Option, /// Whether to allow redirects. @@ -50,8 +49,8 @@ struct Builder { /// Whether to use cookie store. cookie_store: Option, /// Whether to use cookie store provider. - #[serde(skip)] - cookie_provider: Option, + #[serde(default)] + cookie_provider: NativeOption, // ========= Timeout options ========= /// The timeout to use for the client. (in seconds) @@ -69,7 +68,7 @@ struct Builder { /// Set the number of retries for TCP keepalive. tcp_keepalive_retries: Option, /// Set an optional user timeout for TCP sockets. (in seconds) - #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + #[allow(dead_code)] tcp_user_timeout: Option, /// Set that all sockets have `NO_DELAY` set. tcp_nodelay: Option, @@ -93,15 +92,15 @@ struct Builder { https_only: Option, // ========= TLS options ========= - /// Whether to verify the SSL certificate or root certificate file path. + /// Whether to verify TLS certificates. verify: Option, // ========= Network options ========= /// Whether to disable the proxy for the client. no_proxy: Option, /// The proxy to use for the client. - #[serde(skip)] - proxy: Option, + #[serde(default)] + proxy: NativeOption, /// Bind to a local IP Address. local_address: Option, /// Bind to an interface by `SO_BINDTODEVICE`. @@ -119,42 +118,65 @@ struct Builder { zstd: Option, } -#[derive(Clone, Default)] +#[derive(Clone)] #[magnus::wrap(class = "Wreq::Client", free_immediately, size)] pub struct Client(wreq::Client); // ===== impl Builder ===== impl Builder { - /// Create a new [`Builder`] from Ruby keyword arguments. - fn new(ruby: &magnus::Ruby, keyword: &Value) -> Result { - let Ok(hash) = RHash::try_convert(*keyword) else { - return Ok(Default::default()); - }; - - let mut builder: Self = serde::deserialize_ruby(ruby, hash)?; - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(emulation))) { - builder.emulation = Some((*Obj::::try_convert(v)?).clone()); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(user_agent))) { - builder.user_agent = Some(UserAgent::try_convert(v)?); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(headers))) { - builder.headers = Some(Headers::try_convert(v)?); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(orig_headers))) { - builder.orig_headers = Some(OrigHeaders::try_convert(v)?); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(cookie_provider))) { - builder.cookie_provider = Some((*Obj::::try_convert(v)?).clone()); - } - - builder.proxy = Extractor::::try_convert(*keyword)?.into_inner(); + /// Create a new [`Builder`] from a Ruby options Hash. + /// + /// # Errors + /// + /// Returns `ArgumentError` for unknown, duplicate, conflicting, + /// ineffective, or platform-specific options. Known values retain their + /// Ruby conversion error class and include the option name. + fn from_options(options: Options<'_>) -> Result { + let options = options.validate_keys::()?; + let mut builder = options + .validator() + .reject_unsupported(stringify!(tcp_user_timeout), SUPPORTS_TCP_USER_TIMEOUT) + .reject_unsupported(stringify!(interface), SUPPORTS_INTERFACE) + .finish()? + .deserialize::()?; + + options + .validator() + .reject_conflicts([ + (stringify!(http1_only), builder.http1_only == Some(true)), + (stringify!(http2_only), builder.http2_only == Some(true)), + ]) + .reject_conflicts([ + (stringify!(proxy), options.is_non_nil(stringify!(proxy))), + (stringify!(no_proxy), builder.no_proxy == Some(true)), + ]) + .require_when_present( + stringify!(max_redirects), + builder.max_redirects.is_some(), + builder.allow_redirects == Some(true), + ":allow_redirects to be true", + ) + .finish()?; + + extract_native_option!( + options, + builder, + emulation, + Obj => |value| (*value).clone() + ); + extract_native_option!(options, builder, user_agent); + extract_native_option!(options, builder, headers); + extract_native_option!(options, builder, orig_headers); + extract_native_option!( + options, + builder, + cookie_provider, + Obj => |value| (*value).clone() + ); + builder + .proxy + .set(Extractor::::try_convert(options.as_value())?.into_inner()); Ok(builder) } @@ -164,234 +186,291 @@ impl Builder { impl Client { /// Create a new [`Client`] with the given keyword arguments. - pub fn new(ruby: &Ruby, keyword: &[Value]) -> Result { - if let Some(keyword) = keyword.first() { - let mut params = Builder::new(ruby, keyword)?; - gvl::nogvl(|| { - let mut builder = wreq::Client::builder(); - - // Emulation options. - apply_option!(set_if_some_inner, builder, params.emulation, emulation); - - // User agent options. - apply_option!(set_if_some_inner, builder, params.user_agent, user_agent); - - // Headers options. - apply_option!( - set_if_some_into_inner, - builder, - params.headers, - default_headers - ); - apply_option!( - set_if_some_inner, - builder, - params.orig_headers, - orig_headers - ); - - // Allow redirects options. - apply_option!(set_if_some, builder, params.referer, referer); - apply_option!( - set_if_true_with, - builder, - params.allow_redirects, - redirect, - false, - params - .max_redirects - .take() - .map(wreq::redirect::Policy::limited) - .unwrap_or_default() - ); - - // Cookie options. - apply_option!(set_if_some, builder, params.cookie_store, cookie_store); - apply_option!( - set_if_some_inner, - builder, - params.cookie_provider, - cookie_provider - ); - - // TCP options. - apply_option!( - set_if_some_map, - builder, - params.tcp_keepalive, - tcp_keepalive, - Duration::from_secs - ); - apply_option!( - set_if_some_map, - builder, - params.tcp_keepalive_interval, - tcp_keepalive_interval, - Duration::from_secs - ); - apply_option!( - set_if_some, - builder, - params.tcp_keepalive_retries, - tcp_keepalive_retries - ); - #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] - apply_option!( - set_if_some_map, - builder, - params.tcp_user_timeout, - tcp_user_timeout, - Duration::from_secs - ); - apply_option!(set_if_some, builder, params.tcp_nodelay, tcp_nodelay); - apply_option!( - set_if_some, - builder, - params.tcp_reuse_address, - tcp_reuse_address - ); - - // Timeout options. - apply_option!( - set_if_some_map, - builder, - params.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, - builder, - params.connect_timeout, - connect_timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, - builder, - params.read_timeout, - read_timeout, - Duration::from_secs - ); - - // Pool options. - apply_option!( - set_if_some_map, - builder, - params.pool_idle_timeout, - pool_idle_timeout, - Duration::from_secs - ); - apply_option!( - set_if_some, - builder, - params.pool_max_idle_per_host, - pool_max_idle_per_host - ); - apply_option!(set_if_some, builder, params.pool_max_size, pool_max_size); - - // Protocol options. - apply_option!(set_if_true, builder, params.http1_only, http1_only, false); - apply_option!(set_if_true, builder, params.http2_only, http2_only, false); - apply_option!(set_if_some, builder, params.https_only, https_only); - - // TLS options. - apply_option!(set_if_some, builder, params.verify, tls_cert_verification); - - // Network options. - apply_option!(set_if_some, builder, params.proxy, proxy); - apply_option!(set_if_true, builder, params.no_proxy, no_proxy, false); - apply_option!(set_if_some, builder, params.local_address, local_address); - #[cfg(any( - target_os = "android", - target_os = "fuchsia", - target_os = "illumos", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "solaris", - target_os = "tvos", - target_os = "visionos", - target_os = "watchos", - ))] - apply_option!(set_if_some, builder, params.interface, interface); - - // Compression options. - apply_option!(set_if_some, builder, params.gzip, gzip); - apply_option!(set_if_some, builder, params.brotli, brotli); - apply_option!(set_if_some, builder, params.deflate, deflate); - apply_option!(set_if_some, builder, params.zstd, zstd); - - builder.build().map(Client).map_err(wreq_error_to_magnus) - }) - } else { - gvl::nogvl(|| Ok(Self(wreq::Client::new()))) - } + /// + /// # Errors + /// + /// Returns Ruby configuration errors from [`Builder::from_options`] or the + /// native fallible client builder. Extra positional arguments return + /// `ArgumentError`. + pub fn new(ruby: &Ruby, args: &[Value]) -> Result { + Options::from_args(ruby, args, "client")? + .map(Builder::from_options) + .transpose() + .map(Option::unwrap_or_default) + .and_then(|params| Self::build(ruby, params)) + } + + /// Build the default client through the same fallible path as `new`. + /// + /// # Errors + /// + /// Returns `Wreq::BuilderError`, `Wreq::TlsError`, or another mapped native + /// initialization error without unwinding through Ruby. + pub(crate) fn default_client(ruby: &Ruby) -> Result { + Self::build(ruby, Builder::default()) + } + + /// Apply validated parameters and build the native client without the GVL. + /// + /// # Errors + /// + /// Maps native build failures only after the GVL has been reacquired. + fn build(ruby: &Ruby, mut params: Builder) -> Result { + let result = gvl::nogvl(|| { + let mut builder = wreq::Client::builder(); + + // Emulation options. + apply_option!(set_if_some_inner, builder, params.emulation, emulation); + + // User agent options. + apply_option!(set_if_some_inner, builder, params.user_agent, user_agent); + + // Headers options. + apply_option!( + set_if_some_into_inner, + builder, + params.headers, + default_headers + ); + apply_option!( + set_if_some_inner, + builder, + params.orig_headers, + orig_headers + ); + + // Allow redirects options. + apply_option!(set_if_some, builder, params.referer, referer); + match params.allow_redirects { + Some(false) => { + builder = builder.redirect(wreq::redirect::Policy::none()); + } + Some(true) => { + builder = builder.redirect( + params + .max_redirects + .take() + .map(wreq::redirect::Policy::limited) + .unwrap_or_default(), + ); + } + None => {} + } + + // Cookie options. + apply_option!(set_if_some, builder, params.cookie_store, cookie_store); + apply_option!( + set_if_some_inner, + builder, + params.cookie_provider, + cookie_provider + ); + + // TCP options. + apply_option!( + set_if_some_map, + builder, + params.tcp_keepalive, + tcp_keepalive, + Duration::from_secs + ); + apply_option!( + set_if_some_map, + builder, + params.tcp_keepalive_interval, + tcp_keepalive_interval, + Duration::from_secs + ); + apply_option!( + set_if_some, + builder, + params.tcp_keepalive_retries, + tcp_keepalive_retries + ); + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + apply_option!( + set_if_some_map, + builder, + params.tcp_user_timeout, + tcp_user_timeout, + Duration::from_secs + ); + apply_option!(set_if_some, builder, params.tcp_nodelay, tcp_nodelay); + apply_option!( + set_if_some, + builder, + params.tcp_reuse_address, + tcp_reuse_address + ); + + // Timeout options. + apply_option!( + set_if_some_map, + builder, + params.timeout, + timeout, + Duration::from_secs + ); + apply_option!( + set_if_some_map, + builder, + params.connect_timeout, + connect_timeout, + Duration::from_secs + ); + apply_option!( + set_if_some_map, + builder, + params.read_timeout, + read_timeout, + Duration::from_secs + ); + + // Pool options. + apply_option!( + set_if_some_map, + builder, + params.pool_idle_timeout, + pool_idle_timeout, + Duration::from_secs + ); + apply_option!( + set_if_some, + builder, + params.pool_max_idle_per_host, + pool_max_idle_per_host + ); + apply_option!(set_if_some, builder, params.pool_max_size, pool_max_size); + + // Protocol options. + apply_option!(set_if_true, builder, params.http1_only, http1_only, false); + apply_option!(set_if_true, builder, params.http2_only, http2_only, false); + apply_option!(set_if_some, builder, params.https_only, https_only); + + // TLS options. + apply_option!(set_if_some, builder, params.verify, tls_cert_verification); + + // Network options. + apply_option!(set_if_some, builder, params.proxy, proxy); + apply_option!(set_if_true, builder, params.no_proxy, no_proxy, false); + apply_option!(set_if_some, builder, params.local_address, local_address); + #[cfg(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", + ))] + apply_option!(set_if_some, builder, params.interface, interface); + + // Compression options. + apply_option!(set_if_some, builder, params.gzip, gzip); + apply_option!(set_if_some, builder, params.brotli, brotli); + apply_option!(set_if_some, builder, params.deflate, deflate); + apply_option!(set_if_some, builder, params.zstd, zstd); + + builder.build().map(Client) + }); + + // Ruby exceptions must be created after the GVL has been reacquired. + result.map_err(|err| wreq_error(ruby, err)) } } impl Client { + /// Send a request through a newly built default client. + /// + /// Request arguments are validated before the native client is built, so + /// invalid options fail without initializing a connection pool. + pub(crate) fn request_with_default_client( + ruby: &Ruby, + args: &[Value], + ) -> Result { + let ((method, url), request) = extract_request!(args, (Obj, String)); + let client = Self::default_client(ruby)?; + execute_request(ruby, client.0, *method, url, request) + } + + /// Send a request with `method` through a newly built default client. + /// + /// Request arguments are validated before the native client is built, so + /// invalid options fail without initializing a connection pool. + pub(crate) fn execute_with_default_client( + ruby: &Ruby, + method: Method, + args: &[Value], + ) -> Result { + let ((url,), request) = extract_request!(args, (String,)); + let client = Self::default_client(ruby)?; + execute_request(ruby, client.0, method, url, request) + } + /// Send a HTTP request. #[inline] - pub fn request(rb_self: &Self, args: &[Value]) -> Result { + pub fn request(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((method, url), request) = extract_request!(args, (Obj, String)); - execute_request(rb_self.0.clone(), *method, url, request) + execute_request(ruby, rb_self.0.clone(), *method, url, request) } /// Send a GET request. #[inline] - pub fn get(rb_self: &Self, args: &[Value]) -> Result { + pub fn get(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((url,), request) = extract_request!(args, (String,)); - execute_request(rb_self.0.clone(), Method::GET, url, request) + execute_request(ruby, rb_self.0.clone(), Method::GET, url, request) } /// Send a POST request. #[inline] - pub fn post(rb_self: &Self, args: &[Value]) -> Result { + pub fn post(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((url,), request) = extract_request!(args, (String,)); - execute_request(rb_self.0.clone(), Method::POST, url, request) + execute_request(ruby, rb_self.0.clone(), Method::POST, url, request) } /// Send a PUT request. #[inline] - pub fn put(rb_self: &Self, args: &[Value]) -> Result { + pub fn put(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((url,), request) = extract_request!(args, (String,)); - execute_request(rb_self.0.clone(), Method::PUT, url, request) + execute_request(ruby, rb_self.0.clone(), Method::PUT, url, request) } /// Send a DELETE request. #[inline] - pub fn delete(rb_self: &Self, args: &[Value]) -> Result { + pub fn delete(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((url,), request) = extract_request!(args, (String,)); - execute_request(rb_self.0.clone(), Method::DELETE, url, request) + execute_request(ruby, rb_self.0.clone(), Method::DELETE, url, request) } /// Send a HEAD request. #[inline] - pub fn head(rb_self: &Self, args: &[Value]) -> Result { + pub fn head(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((url,), request) = extract_request!(args, (String,)); - execute_request(rb_self.0.clone(), Method::HEAD, url, request) + execute_request(ruby, rb_self.0.clone(), Method::HEAD, url, request) } /// Send an OPTIONS request. #[inline] - pub fn options(rb_self: &Self, args: &[Value]) -> Result { + pub fn options(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((url,), request) = extract_request!(args, (String,)); - execute_request(rb_self.0.clone(), Method::OPTIONS, url, request) + execute_request(ruby, rb_self.0.clone(), Method::OPTIONS, url, request) } /// Send a TRACE request. #[inline] - pub fn trace(rb_self: &Self, args: &[Value]) -> Result { + pub fn trace(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((url,), request) = extract_request!(args, (String,)); - execute_request(rb_self.0.clone(), Method::TRACE, url, request) + execute_request(ruby, rb_self.0.clone(), Method::TRACE, url, request) } /// Send a PATCH request. #[inline] - pub fn patch(rb_self: &Self, args: &[Value]) -> Result { + pub fn patch(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let ((url,), request) = extract_request!(args, (String,)); - execute_request(rb_self.0.clone(), Method::PATCH, url, request) + execute_request(ruby, rb_self.0.clone(), Method::PATCH, url, request) } } diff --git a/src/client/body.rs b/src/client/body.rs index e0bf8a4..b618061 100644 --- a/src/client/body.rs +++ b/src/client/body.rs @@ -20,12 +20,13 @@ pub enum Body { impl TryConvert for Body { fn try_convert(val: Value) -> Result { + let ruby = Ruby::get_with(val); if let Ok(s) = RString::try_convert(val) { return Ok(Body::Bytes(s.to_bytes())); } let obj = Obj::::try_convert(val)?; - let stream = stream::ReceiverStream::try_from(&*obj)?; + let stream = obj.take_receiver(&ruby)?; Ok(Body::Stream(stream)) } } diff --git a/src/client/body/json.rs b/src/client/body/json.rs index 98b4368..644862f 100644 --- a/src/client/body/json.rs +++ b/src/client/body/json.rs @@ -31,7 +31,7 @@ impl TryConvert for Json { let ruby = Ruby::get_with(value); deserialize_json(&ruby, value) .map(Self) - .map_err(json_serialization_error) + .map_err(|error| json_serialization_error(&ruby, error)) } } diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 6a15326..88c194b 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -9,14 +9,13 @@ use std::{ use bytes::Bytes; use futures_util::{Stream, StreamExt}; -use magnus::{Error, Integer, RString, Ruby, Value}; +use magnus::{Error, Integer, RString, Ruby, Value, scan_args::scan_args}; use tokio::sync::{Mutex, Semaphore, mpsc}; use crate::{ error::{ - body_sender_borrow_error_to_magnus, body_sender_borrow_mut_error_to_magnus, - body_sender_send_error_to_magnus, closed_body_sender_error, memory_error, - wreq_error_to_magnus, + argument_error, body_sender_borrow_error, body_sender_borrow_mut_error, + body_sender_send_error, closed_body_sender_error, memory_error, type_error, wreq_error, }, rt, }; @@ -64,8 +63,9 @@ impl BodyReceiver { } /// Read the next body chunk, converting stream errors into Ruby errors. - pub fn next(&self) -> Result, Error> { + pub fn next(&self, ruby: &Ruby) -> Result, Error> { rt::try_block_on( + ruby, async { match self.0.lock().await.as_mut().next().await { Some(Ok(data)) => Ok(Some(data)), @@ -73,7 +73,7 @@ impl BodyReceiver { None => Ok(None), } }, - wreq_error_to_magnus, + wreq_error, ) } } @@ -113,16 +113,16 @@ impl BodySender { /// # Errors /// /// Returns `IOError` after either channel side has closed. An interrupted - /// wait retains the existing `Wreq::InterruptError` behavior. - pub fn push(rb_self: &Self, data: RString) -> Result<(), Error> { + /// wait raises Ruby's standard `Interrupt` exception. + pub fn push(ruby: &Ruby, rb_self: &Self, data: RString) -> Result<(), Error> { // Clone during the shared borrow, then release it before waiting // for capacity. Request attachment needs a mutable borrow. - let tx = match &rb_self.read_inner()?.tx { + let tx = match &rb_self.read_inner(ruby)?.tx { Some(tx) if !tx.is_closed() => tx.clone(), - _ => return Err(closed_body_sender_error()), + _ => return Err(closed_body_sender_error(ruby)), }; - rt::try_block_on(tx.send(data.to_bytes()), body_sender_send_error_to_magnus) + rt::try_block_on(ruby, tx.send(data.to_bytes()), body_sender_send_error) } /// Close the producing side while retaining the receiver and queued chunks. @@ -132,8 +132,8 @@ impl BodySender { /// # Errors /// /// Returns `Wreq::BodyError` if the internal state is already borrowed. - pub fn close(&self) -> Result<(), Error> { - let mut inner = self.write_inner()?; + pub fn close(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> { + let mut inner = rb_self.write_inner(ruby)?; inner.tx.take(); Ok(()) } @@ -143,22 +143,36 @@ impl BodySender { /// # Errors /// /// Returns `Wreq::BodyError` if the internal state is already borrowed. - pub fn is_closed(&self) -> Result { - self.read_inner().map(|r| r.is_closed()) + pub fn is_closed(ruby: &Ruby, rb_self: &Self) -> Result { + rb_self.read_inner(ruby).map(|r| r.is_closed()) } /// Borrow the channel state without panicking on accidental re-entry. - fn read_inner(&self) -> Result, Error> { + fn read_inner(&self, ruby: &Ruby) -> Result, Error> { self.0 .try_borrow() - .map_err(body_sender_borrow_error_to_magnus) + .map_err(|err| body_sender_borrow_error(ruby, err)) } /// Mutably borrow the channel state without panicking on accidental re-entry. - fn write_inner(&self) -> Result, Error> { + fn write_inner(&self, ruby: &Ruby) -> Result, Error> { self.0 .try_borrow_mut() - .map_err(body_sender_borrow_mut_error_to_magnus) + .map_err(|err| body_sender_borrow_mut_error(ruby, err)) + } + + /// Move the receiving side into one request body. + /// + /// # Errors + /// + /// Returns `Wreq::MemoryError` if the receiver was already consumed, or + /// `Wreq::BodyError` if Ruby re-enters while the state is borrowed. + pub(super) fn take_receiver(&self, ruby: &Ruby) -> Result, Error> { + self.write_inner(ruby)? + .rx + .take() + .map(ReceiverStream::new) + .ok_or_else(|| memory_error(ruby)) } } @@ -167,22 +181,15 @@ impl BodySender { /// [`mpsc::channel`] panics for zero or values above /// [`Semaphore::MAX_PERMITS`], so validation must finish before channel creation. fn parse_capacity(ruby: &Ruby, args: &[Value]) -> Result { - let value = match args { - [] => return Ok(DEFAULT_CHANNEL_CAPACITY), - [value] => *value, - _ => { - return Err(Error::new( - ruby.exception_arg_error(), - format!( - "wrong number of arguments (given {}, expected 0..1)", - args.len() - ), - )); - } + let Some(value) = scan_args::<(), (Option,), (), (), (), ()>(args)? + .optional + .0 + else { + return Ok(DEFAULT_CHANNEL_CAPACITY); }; let integer = Integer::from_value(value) - .ok_or_else(|| Error::new(ruby.exception_type_error(), "capacity must be an Integer"))?; + .ok_or_else(|| type_error(ruby, "capacity must be an Integer"))?; let capacity = integer .to_i64() .ok() @@ -195,29 +202,12 @@ fn parse_capacity(ruby: &Ruby, args: &[Value]) -> Result { /// Build the synchronous Ruby error used for an invalid channel capacity. fn invalid_capacity_error(ruby: &Ruby) -> Error { - Error::new( - ruby.exception_arg_error(), + argument_error( + ruby, format!("capacity must be between 1 and {}", Semaphore::MAX_PERMITS), ) } -/// Move the receiving side into one request body. -/// -/// The sender remains available for concurrent producers until it is closed or -/// the returned stream is dropped. A second attachment returns `Wreq::MemoryError`. -impl TryFrom<&BodySender> for ReceiverStream { - type Error = magnus::Error; - - fn try_from(sender: &BodySender) -> Result { - sender - .write_inner()? - .rx - .take() - .map(ReceiverStream::new) - .ok_or_else(memory_error) - } -} - /// A wrapper around [`tokio::sync::mpsc::Receiver`] that implements [`Stream`]. pub struct ReceiverStream { inner: mpsc::Receiver, diff --git a/src/client/param.rs b/src/client/param.rs index 166b9bf..b4bc0b9 100644 --- a/src/client/param.rs +++ b/src/client/param.rs @@ -1,19 +1,19 @@ use ::serde::{Deserialize, Serialize}; use indexmap::IndexMap; -/// Represents HTTP parameters from Python as either a mapping or a sequence of key-value pairs. +/// HTTP parameters represented as an insertion-ordered Ruby mapping. pub type Params = IndexMap; -/// Represents a single parameter value that can be automatically converted from Python types. +/// A scalar Ruby value accepted in query-string and form mappings. #[derive(Serialize, Deserialize)] #[serde(untagged)] pub enum ParamValue { - /// A boolean value from Python `bool`. + /// A Ruby `true` or `false` value. Boolean(bool), - /// An integer value from Python `int`. + /// A Ruby Integer that fits in the native pointer-sized range. Number(isize), - /// A floating-point value from Python `float`. + /// A Ruby Float. Float64(f64), - /// A string value from Python `str`. + /// A Ruby String or Symbol. String(String), } diff --git a/src/client/req.rs b/src/client/req.rs index f08b4a6..0603d9e 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -7,14 +7,16 @@ use wreq::{Client, Proxy}; use super::body::{Body, form::Form, json::Json}; use crate::{ + arch::SUPPORTS_INTERFACE, client::{query::Query, resp::Response}, cookie::Cookies, emulate::Emulation, - error::wreq_error_to_magnus, + error::wreq_error, extractor::Extractor, header::{Headers, OrigHeaders}, http::{Method, Version}, - rt, serde, + options::{NativeOption, Options}, + rt, }; /// The parameters for a request. @@ -22,12 +24,12 @@ use crate::{ #[non_exhaustive] pub struct Request { /// The emulation option for the request. - #[serde(skip)] - emulation: Option, + #[serde(default)] + emulation: NativeOption, /// The proxy to use for the request. - #[serde(skip)] - proxy: Option, + #[serde(default)] + proxy: NativeOption, /// Bind to a local IP Address. local_address: Option, @@ -43,23 +45,23 @@ pub struct Request { read_timeout: Option, /// The HTTP version to use for the request. - #[serde(skip)] - version: Option, + #[serde(default)] + version: NativeOption, /// The option enables default headers. default_headers: Option, /// The headers to use for the request. - #[serde(skip)] - headers: Option, + #[serde(default)] + headers: NativeOption, /// The original headers to use for the request. - #[serde(skip)] - orig_headers: Option, + #[serde(default)] + orig_headers: NativeOption, /// The cookies to use for the request. - #[serde(skip)] - cookies: Option, + #[serde(default)] + cookies: NativeOption, /// Whether to allow redirects. allow_redirects: Option, @@ -95,62 +97,95 @@ pub struct Request { form: Option
, /// The JSON body to use for the request. - #[serde(skip)] - json: Option, + #[serde(default)] + json: NativeOption, /// The body to use for the request. - #[serde(skip)] - body: Option, + #[serde(default)] + body: NativeOption, } impl Request { /// Create a new [`Request`] from Ruby keyword arguments. + /// + /// # Errors + /// + /// Returns before network I/O for unknown, duplicate, unsupported, + /// conflicting, ineffective, or invalid option values. pub fn new(ruby: &magnus::Ruby, hash: RHash) -> Result { let keyword = hash.as_value(); - let mut builder: Self = serde::deserialize_ruby(ruby, keyword)?; - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(emulation))) { - let obj = Obj::::try_convert(v)?; - builder.emulation = Some((*obj).clone()); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(version))) { - builder.version = Some(Version::try_convert(v)?); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(headers))) { - builder.headers = Some(Headers::try_convert(v)?); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(orig_headers))) { - builder.orig_headers = Some(OrigHeaders::try_convert(v)?); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(cookies))) { - builder.cookies = Some(Cookies::try_convert(v)?); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(body))) { - builder.body = Some(Body::try_convert(v)?); - } - - if let Some(v) = hash.get(ruby.to_symbol(stringify!(json))) { - builder.json = Some(Json::try_convert(v)?); - } - - builder.proxy = Extractor::::try_convert(keyword)?.into_inner(); + let options = Options::new(ruby, hash); + let mut builder = Self::deserialize_options(&options)?; + options + .validator() + .require_when_present( + stringify!(max_redirects), + builder.max_redirects.is_some(), + builder.allow_redirects == Some(true), + ":allow_redirects to be true", + ) + .finish()?; + + extract_native_option!( + options, + builder, + emulation, + Obj => |value| (*value).clone() + ); + extract_native_option!(options, builder, version); + extract_native_option!(options, builder, headers); + extract_native_option!(options, builder, orig_headers); + extract_native_option!(options, builder, cookies); + extract_native_option!(options, builder, json, present); + builder + .proxy + .set(Extractor::::try_convert(keyword)?.into_inner()); + extract_native_option!(options, builder, body); Ok(builder) } + + /// Validate pre-conversion rules and deserialize the request options. + /// + /// # Errors + /// + /// Returns `ArgumentError` for failed rules or the Ruby conversion error + /// produced by an invalid option value. + fn deserialize_options(options: &Options<'_>) -> Result { + options + .validate_keys::()? + .validator() + .reject_unsupported(stringify!(interface), SUPPORTS_INTERFACE) + .reject_conflicts([ + (stringify!(body), options.is_non_nil(stringify!(body))), + (stringify!(form), options.is_non_nil(stringify!(form))), + (stringify!(json), options.is_present(stringify!(json))), + ]) + .reject_conflicts([ + (stringify!(auth), options.is_non_nil(stringify!(auth))), + ( + stringify!(bearer_auth), + options.is_non_nil(stringify!(bearer_auth)), + ), + ( + stringify!(basic_auth), + options.is_non_nil(stringify!(basic_auth)), + ), + ]) + .finish()? + .deserialize::() + } } pub fn execute_request>( + ruby: &magnus::Ruby, client: Client, method: Method, url: U, mut request: Request, ) -> Result { rt::try_block_on( + ruby, async move { let mut builder = client.request(method.into_ffi(), url.as_ref()); @@ -274,6 +309,6 @@ pub fn execute_request>( // Send request. builder.send().await.map(Response::new) }, - wreq_error_to_magnus, + wreq_error, ) } diff --git a/src/client/resp.rs b/src/client/resp.rs index 08c37c4..9fbc08c 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -11,8 +11,8 @@ use wreq::Uri; use crate::{ client::body::{json::Json, stream::BodyReceiver}, cookie::Cookie, - error::{memory_error, no_block_given_error, wreq_error_to_magnus}, - gvl::{self, nogvl}, + error::{memory_error, no_block_given_error, wreq_error}, + gvl, header::Headers, http::{StatusCode, Version}, rt, @@ -64,7 +64,7 @@ impl Response { } /// Internal method to get the wreq::Response, optionally streaming the body. - fn response(&self, stream: bool) -> Result { + fn response(&self, ruby: &Ruby, stream: bool) -> Result { let build_response = |body: wreq::Body| -> wreq::Response { let mut response = HttpResponse::new(body); *response.version_mut() = self.version.into_ffi(); @@ -81,8 +81,9 @@ impl Response { Ok(build_response(body)) } else { let bytes = rt::try_block_on( + ruby, BodyExt::collect(body).map_ok(|buf| buf.to_bytes()), - wreq_error_to_magnus, + wreq_error, )?; self.body @@ -103,7 +104,7 @@ impl Response { }; } - Err(memory_error()) + Err(memory_error(ruby)) } } @@ -167,44 +168,42 @@ impl Response { } /// Get the response body as bytes. - pub fn bytes(&self) -> Result { - let response = self.response(false)?; - rt::try_block_on(response.bytes(), wreq_error_to_magnus) + pub fn bytes(ruby: &Ruby, rb_self: &Self) -> Result { + let response = rb_self.response(ruby, false)?; + rt::try_block_on(ruby, response.bytes(), wreq_error) } /// Get the full response text given a specific encoding. - pub fn text(&self, args: &[Value]) -> Result { + pub fn text(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { let args = scan_args::<(), (Option,), (), (), (), ()>(args)?; - let response = self.response(false)?; + let response = rb_self.response(ruby, false)?; match args.optional.0 { Some(encoding) => { - rt::try_block_on(response.text_with_charset(encoding), wreq_error_to_magnus) + rt::try_block_on(ruby, response.text_with_charset(encoding), wreq_error) } - None => rt::try_block_on(response.text(), wreq_error_to_magnus), + None => rt::try_block_on(ruby, response.text(), wreq_error), } } /// Get the response body as JSON. pub fn json(ruby: &Ruby, rb_self: &Self) -> Result { - let response = rb_self.response(false)?; - let json = rt::try_block_on(response.json::(), wreq_error_to_magnus)?; + let response = rb_self.response(ruby, false)?; + let json = rt::try_block_on(ruby, response.json::(), wreq_error)?; crate::serde::serialize(ruby, &json) } /// Yield response body chunks to the given Ruby block. pub fn chunks(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> { if !ruby.block_given() { - return Err(no_block_given_error()); + return Err(no_block_given_error(ruby)); } - let receiver = nogvl(|| { - rb_self - .response(true) - .map(wreq::Response::bytes_stream) - .map(BodyReceiver::new) - })?; + let receiver = rb_self + .response(ruby, true) + .map(wreq::Response::bytes_stream) + .map(BodyReceiver::new)?; - while let Some(chunk) = receiver.next()? { + while let Some(chunk) = receiver.next(ruby)? { let _: Value = ruby.yield_value(chunk)?; } diff --git a/src/cookie.rs b/src/cookie.rs index 276ae5f..69f3cc7 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -8,7 +8,10 @@ use magnus::{ }; use wreq::header::{self, HeaderMap, HeaderValue}; -use crate::{error::header_value_error_to_magnus, gvl}; +use crate::{ + error::{header_value_error, type_error}, + gvl, +}; define_ruby_enum!( /// The Cookie SameSite attribute. @@ -206,13 +209,14 @@ impl fmt::Display for Cookie { impl TryConvert for Cookies { fn try_convert(value: magnus::Value) -> Result { + let ruby = Ruby::get_with(value); // try extract uncompressed cookies if let Some(rhash) = RHash::from_value(value) { let mut cookies = Vec::new(); rhash.foreach(|name: RString, value: RString| { let cookie = format!("{name}={value}"); let header_value = HeaderValue::from_maybe_shared(Bytes::from(cookie)) - .map_err(header_value_error_to_magnus)?; + .map_err(|err| header_value_error(&ruby, err))?; cookies.push(header_value); Ok(ForEach::Continue) })?; @@ -224,11 +228,11 @@ impl TryConvert for Cookies { if let Some(cookies) = RString::from_value(value) { return Ok(Self(vec![ HeaderValue::from_maybe_shared(cookies.to_bytes()) - .map_err(header_value_error_to_magnus)?, + .map_err(|err| header_value_error(&ruby, err))?, ])); } - Ok(Self::default()) + Err(type_error(&ruby, "cookies must be a Hash or String")) } } diff --git a/src/emulate.rs b/src/emulate.rs index 788202e..6c1713b 100644 --- a/src/emulate.rs +++ b/src/emulate.rs @@ -1,8 +1,46 @@ +use ::serde::Deserialize; use magnus::{ - Error, Module, Object, RHash, RModule, Ruby, TryConvert, Value, function, method, + Error, Module, Object, RModule, Ruby, Value, function, method, typed_data::{Inspect, Obj}, }; +use crate::options::{NativeOption, Options}; + +/// Keyword arguments accepted by `Wreq::Emulation.new`. +#[derive(Default, Deserialize)] +struct Builder { + /// The browser profile to emulate. + #[serde(default)] + profile: NativeOption>, + + /// The operating-system profile to emulate. + #[serde(default)] + platform: NativeOption>, + + /// Whether HTTP/2 settings are emulated. + http2: Option, + + /// Whether browser headers are emulated. + headers: Option, +} + +// ===== impl Builder ===== + +impl Builder { + /// Deserialize and convert one validated emulation options Hash. + /// + /// # Errors + /// + /// Returns `ArgumentError` for unknown or duplicate options and `TypeError` + /// for invalid option values. + fn from_options(options: Options<'_>) -> Result { + let mut builder = options.validate_keys::()?.deserialize::()?; + extract_native_option!(options, builder, profile); + extract_native_option!(options, builder, platform); + Ok(builder) + } +} + define_ruby_enum!( /// An emulation profile. const, @@ -186,32 +224,38 @@ impl Platform { // ===== impl Emulation ===== impl Emulation { + /// Create emulation settings from one optional Hash. + /// + /// Unknown keys, non-Hash arguments, and extra positional arguments are + /// rejected before the native emulation value is built. + /// + /// # Errors + /// + /// Returns `ArgumentError` for unknown keys or argument count and + /// `TypeError` for invalid option values. fn new(ruby: &Ruby, args: &[Value]) -> Result { - let mut profile = None; - let mut platform = None; - let mut http2 = None; - let mut headers = None; - - if let Some(hash) = args.first().and_then(|v| RHash::from_value(*v)) { - if let Some(v) = hash.get(ruby.to_symbol(stringify!(profile))) { - profile = Some(Obj::::try_convert(v)?); - } - if let Some(v) = hash.get(ruby.to_symbol(stringify!(platform))) { - platform = Some(Obj::::try_convert(v)?); - } - if let Some(v) = hash.get(ruby.to_symbol(stringify!(http2))) { - http2 = Some(bool::try_convert(v)?); - } - if let Some(v) = hash.get(ruby.to_symbol(stringify!(headers))) { - headers = Some(bool::try_convert(v)?); - } - } + let mut params = Options::from_args(ruby, args, "emulation")? + .map(Builder::from_options) + .transpose()? + .unwrap_or_default(); let emulation = wreq_util::Emulation::builder() - .profile(profile.map(|obj| obj.into_ffi()).unwrap_or_default()) - .platform(platform.map(|os| os.into_ffi()).unwrap_or_default()) - .http2(http2.unwrap_or(true)) - .headers(headers.unwrap_or(true)) + .profile( + params + .profile + .take() + .map(|obj| obj.into_ffi()) + .unwrap_or_default(), + ) + .platform( + params + .platform + .take() + .map(|os| os.into_ffi()) + .unwrap_or_default(), + ) + .http2(params.http2.unwrap_or(true)) + .headers(params.headers.unwrap_or(true)) .build(); Ok(Self(emulation)) diff --git a/src/error.rs b/src/error.rs index f6ca717..e3a36a2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,11 @@ -use std::cell::{BorrowError, BorrowMutError}; +use std::{ + cell::{BorrowError, BorrowMutError}, + fmt, +}; use magnus::{ - Error as MagnusError, RModule, Ruby, exception::ExceptionClass, prelude::*, value::Lazy, + Error as MagnusError, RModule, Ruby, error::ErrorType, exception::ExceptionClass, prelude::*, + value::Lazy, }; use tokio::sync::mpsc::error::SendError; @@ -19,18 +23,24 @@ Potential solutions: 3) Change the order of operations to reference the instance before borrowing it. "#; -static WREQ: Lazy = Lazy::new(|ruby| ruby.define_module(crate::RUBY_MODULE_NAME).unwrap()); - macro_rules! define_exception { ($name:ident, $ruby_name:literal, $parent_method:ident) => { static $name: Lazy = Lazy::new(|ruby| { - ruby.get_inner(&WREQ) - .define_error($ruby_name, ruby.$parent_method()) - .unwrap() + ruby.class_object() + .const_get::<_, RModule>(crate::RUBY_MODULE_NAME) + .and_then(|module| module.const_get::<_, ExceptionClass>($ruby_name)) + .unwrap_or_else(|_| ruby.$parent_method()) }); }; } +macro_rules! initialize_exception { + ($ruby:expr, $module:expr, $name:ident, $ruby_name:literal, $parent_method:ident) => {{ + $module.define_error($ruby_name, $ruby.$parent_method())?; + Lazy::force(&$name, $ruby); + }}; +} + macro_rules! map_wreq_error { ($ruby:expr, $err:expr, $msg:expr, $($check_method:ident => $exception:ident),* $(,)?) => { { @@ -75,89 +85,122 @@ define_exception!(DECODING_ERROR, "DecodingError", exception_runtime_error); define_exception!(BUILDER_ERROR, "BuilderError", exception_runtime_error); /// Memory error constant -pub fn memory_error() -> MagnusError { - MagnusError::new(ruby!().get_inner(&MEMORY), RACE_CONDITION_ERROR_MSG) +pub fn memory_error(ruby: &Ruby) -> MagnusError { + MagnusError::new(ruby.get_inner(&MEMORY), RACE_CONDITION_ERROR_MSG) } -/// Create a Ruby thread interruption error. -pub fn interrupt_error() -> MagnusError { - MagnusError::new(ruby!().exception_interrupt(), "request interrupted") +/// Create Ruby's standard thread interruption error. +pub fn interrupt_error(ruby: &Ruby) -> MagnusError { + MagnusError::new(ruby.exception_interrupt(), "request interrupted") } -/// LocalJumpError for methods that require a Ruby block. -pub fn no_block_given_error() -> MagnusError { +/// Map a Tokio runtime initialization failure to `Wreq::BuilderError`. +pub fn runtime_initialization_error(ruby: &Ruby, err: &std::io::Error) -> MagnusError { MagnusError::new( - ruby!().exception_local_jump_error(), - "no block given (yield)", + ruby.get_inner(&BUILDER_ERROR), + format!("failed to initialize Tokio runtime: {err}"), ) } +/// LocalJumpError for methods that require a Ruby block. +pub fn no_block_given_error(ruby: &Ruby) -> MagnusError { + MagnusError::new(ruby.exception_local_jump_error(), "no block given (yield)") +} + /// Build an `IOError` for writes to a closed request-body sender. -pub fn closed_body_sender_error() -> MagnusError { - MagnusError::new(ruby!().exception_io_error(), "closed body sender") +pub fn closed_body_sender_error(ruby: &Ruby) -> MagnusError { + MagnusError::new(ruby.exception_io_error(), "closed body sender") } /// Map a failed body-channel send to `IOError`. -pub fn body_sender_send_error_to_magnus(err: SendError) -> MagnusError { +pub fn body_sender_send_error(ruby: &Ruby, err: SendError) -> MagnusError { MagnusError::new( - ruby!().exception_io_error(), + ruby.exception_io_error(), format!("closed body sender: {err}"), ) } /// Map an immutable sender-state borrow failure to `Wreq::BodyError`. -pub fn body_sender_borrow_error_to_magnus(err: BorrowError) -> MagnusError { +pub fn body_sender_borrow_error(ruby: &Ruby, err: BorrowError) -> MagnusError { MagnusError::new( - ruby!().get_inner(&BODY_ERROR), + ruby.get_inner(&BODY_ERROR), format!("body sender state is unavailable: {err}"), ) } /// Map a mutable sender-state borrow failure to `Wreq::BodyError`. -pub fn body_sender_borrow_mut_error_to_magnus(err: BorrowMutError) -> MagnusError { +pub fn body_sender_borrow_mut_error(ruby: &Ruby, err: BorrowMutError) -> MagnusError { MagnusError::new( - ruby!().get_inner(&BODY_ERROR), + ruby.get_inner(&BODY_ERROR), format!("body sender state is unavailable: {err}"), ) } /// Map [`wreq::header::InvalidHeaderName`] to corresponding [`magnus::Error`] -pub fn header_name_error_to_magnus(err: wreq::header::InvalidHeaderName) -> MagnusError { +pub fn header_name_error(ruby: &Ruby, err: wreq::header::InvalidHeaderName) -> MagnusError { MagnusError::new( - ruby!().get_inner(&BUILDER_ERROR), + ruby.get_inner(&BUILDER_ERROR), format!("invalid header name: {err}"), ) } /// Map [`wreq::header::InvalidHeaderValue`] to corresponding [`magnus::Error`] -pub fn header_value_error_to_magnus(err: wreq::header::InvalidHeaderValue) -> MagnusError { +pub fn header_value_error(ruby: &Ruby, err: wreq::header::InvalidHeaderValue) -> MagnusError { MagnusError::new( - ruby!().get_inner(&BUILDER_ERROR), + ruby.get_inner(&BUILDER_ERROR), format!("invalid header value: {err}"), ) } -/// Map type/value errors to corresponding [`magnus::Error`] -pub fn type_value_error_to_magnus(err: &str) -> MagnusError { - MagnusError::new( - ruby!().get_inner(&BUILDER_ERROR), - format!("type error: {err}"), - ) +/// Build a `Wreq::BuilderError` for an invalid Ruby header structure. +pub fn header_type_error(ruby: &Ruby, err: &str) -> MagnusError { + MagnusError::new(ruby.get_inner(&BUILDER_ERROR), format!("type error: {err}")) } /// Build a `Wreq::BuilderError` for unsupported request JSON values. -pub fn json_serialization_error(err: MagnusError) -> MagnusError { +pub fn json_serialization_error(ruby: &Ruby, err: MagnusError) -> MagnusError { MagnusError::new( - ruby!().get_inner(&BUILDER_ERROR), + ruby.get_inner(&BUILDER_ERROR), format!("JSON serialization error: {err}"), ) } +/// Prefix a Magnus error while preserving its original Ruby exception class. +pub(crate) fn contextualize_magnus_error( + err: MagnusError, + context: fmt::Arguments<'_>, +) -> MagnusError { + match err.error_type() { + ErrorType::Error(class, message) => { + MagnusError::new(*class, format!("{context}: {message}")) + } + ErrorType::Exception(exception) => MagnusError::new( + exception.exception_class(), + format!("{context}: {exception}"), + ), + ErrorType::Jump(_) => err, + } +} + +/// Add an option name while preserving the original Ruby exception class. +pub fn option_value_error(option: &str, err: MagnusError) -> MagnusError { + contextualize_magnus_error(err, format_args!("invalid value for :{option}")) +} + +/// Build an `ArgumentError` from a validation message. +pub fn argument_error(ruby: &Ruby, message: impl Into) -> MagnusError { + MagnusError::new(ruby.exception_arg_error(), message.into()) +} + +/// Build a `TypeError` from a conversion message. +pub fn type_error(ruby: &Ruby, message: impl Into) -> MagnusError { + MagnusError::new(ruby.exception_type_error(), message.into()) +} /// Map [`wreq::Error`] to corresponding [`magnus::Error`] -pub fn wreq_error_to_magnus(err: wreq::Error) -> MagnusError { +pub fn wreq_error(ruby: &Ruby, err: wreq::Error) -> MagnusError { let error_msg = err.to_string(); map_wreq_error!( - ruby!(), + ruby, err, error_msg, is_builder => BUILDER_ERROR, @@ -174,17 +217,95 @@ pub fn wreq_error_to_magnus(err: wreq::Error) -> MagnusError { ) } -pub fn include(ruby: &Ruby) { - Lazy::force(&MEMORY, ruby); - Lazy::force(&CONNECTION_ERROR, ruby); - Lazy::force(&PROXY_CONNECTION_ERROR, ruby); - Lazy::force(&CONNECTION_RESET_ERROR, ruby); - Lazy::force(&TLS_ERROR, ruby); - Lazy::force(&REQUEST_ERROR, ruby); - Lazy::force(&STATUS_ERROR, ruby); - Lazy::force(&REDIRECT_ERROR, ruby); - Lazy::force(&TIMEOUT_ERROR, ruby); - Lazy::force(&BODY_ERROR, ruby); - Lazy::force(&DECODING_ERROR, ruby); - Lazy::force(&BUILDER_ERROR, ruby); +/// Define and retain the Ruby exception classes used by the binding. +/// +/// # Errors +/// +/// Returns the Ruby exception raised while defining an error class. +pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), MagnusError> { + initialize_exception!( + ruby, + gem_module, + MEMORY, + "MemoryError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + CONNECTION_ERROR, + "ConnectionError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + PROXY_CONNECTION_ERROR, + "ProxyConnectionError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + CONNECTION_RESET_ERROR, + "ConnectionResetError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + TLS_ERROR, + "TlsError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + REQUEST_ERROR, + "RequestError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + STATUS_ERROR, + "StatusError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + REDIRECT_ERROR, + "RedirectError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + TIMEOUT_ERROR, + "TimeoutError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + BODY_ERROR, + "BodyError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + DECODING_ERROR, + "DecodingError", + exception_runtime_error + ); + initialize_exception!( + ruby, + gem_module, + BUILDER_ERROR, + "BuilderError", + exception_runtime_error + ); + Ok(()) } diff --git a/src/extractor.rs b/src/extractor.rs index 8d3259d..ffa5d92 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,12 +1,8 @@ -use magnus::{RArray, RHash, RString, Ruby, TryConvert, r_hash::ForEach}; -use wreq::{ - Proxy, - header::{HeaderMap, HeaderName, HeaderValue, OrigHeaderMap}, -}; +use magnus::{RHash, RString, Ruby, TryConvert, value::ReprValue}; +use wreq::Proxy; -use crate::error::{ - header_name_error_to_magnus, header_value_error_to_magnus, wreq_error_to_magnus, -}; +use crate::error::{option_value_error, wreq_error}; +use crate::options; /// A trait that defines the parameter name for extraction. pub trait ExtractorName { @@ -32,65 +28,6 @@ where } } -// ===== impl Extractor ===== - -impl ExtractorName for HeaderMap { - const NAME: &str = "headers"; -} - -impl TryConvert for Extractor { - fn try_convert(value: magnus::Value) -> Result { - let ruby = Ruby::get_with(value); - let keyword = RHash::try_convert(value)?; - let mut headers = HeaderMap::new(); - - if let Some(hash) = keyword - .get(ruby.to_symbol(HeaderMap::NAME)) - .and_then(RHash::from_value) - { - hash.foreach(|name: RString, value: RString| { - let name = HeaderName::from_bytes(&name.to_bytes()) - .map_err(header_name_error_to_magnus)?; - let value = HeaderValue::from_maybe_shared(value.to_bytes()) - .map_err(header_value_error_to_magnus)?; - headers.insert(name, value); - - Ok(ForEach::Continue) - })?; - - return Ok(Extractor(Some(headers))); - } - - Ok(Extractor(None)) - } -} - -// ===== impl Extractor ===== - -impl ExtractorName for OrigHeaderMap { - const NAME: &str = "orig_headers"; -} - -impl TryConvert for Extractor { - fn try_convert(value: magnus::Value) -> Result { - let ruby = Ruby::get_with(value); - let keyword = RHash::try_convert(value)?; - - if let Some(orig_headers) = keyword - .get(ruby.to_symbol(OrigHeaderMap::NAME)) - .and_then(RArray::from_value) - { - let mut map = OrigHeaderMap::new(); - for value in orig_headers.into_iter().flat_map(RString::from_value) { - map.insert(value.to_bytes()); - } - return Ok(Extractor(Some(map))); - } - - Ok(Extractor(None)) - } -} - // ===== impl Extractor ===== impl ExtractorName for Proxy { @@ -102,16 +39,19 @@ impl TryConvert for Extractor { let ruby = Ruby::get_with(value); let rhash = RHash::try_convert(value)?; - if let Some(proxy) = rhash - .get(ruby.to_symbol(Proxy::NAME)) - .and_then(RString::from_value) - { - return Proxy::all(proxy.to_bytes().as_ref()) - .map(Some) - .map(Extractor) - .map_err(wreq_error_to_magnus); + let Some(value) = options::get(&ruby, rhash, Proxy::NAME) else { + return Ok(Extractor(None)); + }; + if value.is_nil() { + return Ok(Extractor(None)); } - Ok(Extractor(None)) + let proxy = + RString::try_convert(value).map_err(|error| option_value_error(Proxy::NAME, error))?; + let proxy = Proxy::all(proxy.to_bytes().as_ref()) + .map_err(|err| wreq_error(&ruby, err)) + .map_err(|error| option_value_error(Proxy::NAME, error))?; + + Ok(Extractor(Some(proxy))) } } diff --git a/src/header.rs b/src/header.rs index 9e73f8f..feee0d6 100644 --- a/src/header.rs +++ b/src/header.rs @@ -15,11 +15,12 @@ use http::{HeaderMap, HeaderValue}; use magnus::{ Error, RArray, RModule, RString, Ruby, TryConvert, Value, function, method, prelude::*, + scan_args::scan_args, typed_data::{Inspect, Obj}, }; use wreq::header::OrigHeaderMap; -use crate::error::{header_value_error_to_magnus, type_value_error_to_magnus}; +use crate::error::{header_type_error, header_value_error}; use self::helper::{ ensure_header_count, from_source, header_count_error, parse_header_name, parse_header_values, @@ -43,10 +44,11 @@ pub struct OrigHeaders(pub OrigHeaderMap); impl TryConvert for UserAgent { fn try_convert(value: Value) -> Result { + let ruby = Ruby::get_with(value); let s = RString::try_convert(value)?; HeaderValue::from_maybe_shared(s.to_bytes()) .map(Self) - .map_err(header_value_error_to_magnus) + .map_err(|err| header_value_error(&ruby, err)) } } @@ -76,11 +78,12 @@ impl Headers { /// A String stores one occurrence, while an Array stores each String as a /// separate occurrence. An empty Array removes the header. pub fn set(&self, name: Value, value: Value) -> Result<(), Error> { + let ruby = Ruby::get_with(name); let name = parse_header_name(name)?; let values = parse_header_values(value)?; let mut headers = self.0.borrow_mut(); let replaced = headers.get_all(&name).iter().count(); - ensure_header_count(headers.len(), replaced, values.len())?; + ensure_header_count(&ruby, headers.len(), replaced, values.len())?; let mut values = values.into_iter(); let Some(first) = values.next() else { @@ -90,11 +93,11 @@ impl Headers { headers .try_insert(name.clone(), first) - .map_err(|_| header_count_error())?; + .map_err(|_| header_count_error(&ruby))?; for value in values { headers .try_append(name.clone(), value) - .map_err(|_| header_count_error())?; + .map_err(|_| header_count_error(&ruby))?; } Ok(()) } @@ -103,15 +106,16 @@ impl Headers { /// /// Array elements are appended separately and are never comma-folded. pub fn append(&self, name: Value, value: Value) -> Result<(), Error> { + let ruby = Ruby::get_with(name); let name = parse_header_name(name)?; let values = parse_header_values(value)?; let mut headers = self.0.borrow_mut(); - ensure_header_count(headers.len(), 0, values.len())?; + ensure_header_count(&ruby, headers.len(), 0, values.len())?; for value in values { headers .try_append(name.clone(), value) - .map_err(|_| header_count_error())?; + .map_err(|_| header_count_error(&ruby))?; } Ok(()) } @@ -168,18 +172,13 @@ impl Headers { /// /// The optional source may be a Hash, another `Wreq::Headers`, or an /// Enumerable whose elements are name-value pairs. - pub fn new(ruby: &Ruby, args: &[Value]) -> Result { - match args { - [] => Ok(Self::default()), - [source] => from_source(*source), - _ => Err(Error::new( - ruby.exception_arg_error(), - format!( - "wrong number of arguments (given {}, expected 0..1)", - args.len() - ), - )), - } + pub fn new(args: &[Value]) -> Result { + scan_args::<(), (Option,), (), (), (), ()>(args)? + .optional + .0 + .map(from_source) + .transpose() + .map(Option::unwrap_or_default) } /// Return a value using Ruby collection semantics. @@ -266,12 +265,15 @@ impl TryConvert for Headers { impl TryConvert for OrigHeaders { fn try_convert(value: Value) -> Result { + let ruby = Ruby::get_with(value); let mut map = OrigHeaderMap::new(); let rarray = RArray::from_value(value) - .ok_or_else(|| type_value_error_to_magnus("Expected an array of strings"))?; + .ok_or_else(|| header_type_error(&ruby, "Expected an array of strings"))?; - for value in rarray.into_iter().flat_map(RString::from_value) { + for value in rarray { + let value = RString::try_convert(value) + .map_err(|_| header_type_error(&ruby, "Expected an array of strings"))?; map.insert(value.to_bytes()); } @@ -284,12 +286,12 @@ mod helper { use bytes::Bytes; use http::{HeaderName, HeaderValue}; - use magnus::{Error, RArray, RString, Symbol, TryConvert, Value, prelude::*, typed_data::Obj}; - - use crate::error::{ - header_name_error_to_magnus, header_value_error_to_magnus, type_value_error_to_magnus, + use magnus::{ + Error, RArray, RString, Ruby, Symbol, TryConvert, Value, prelude::*, typed_data::Obj, }; + use crate::error::{header_name_error, header_type_error, header_value_error}; + use super::Headers; /// Maximum number of field-value occurrences supported by `HeaderMap`. @@ -301,11 +303,13 @@ mod helper { /// contains two-element name-value pairs. Array values are delegated to /// [`Headers::append`] so each value remains a separate occurrence. pub(super) fn from_source(source: Value) -> Result { + let ruby = Ruby::get_with(source); if let Ok(headers) = Obj::::try_convert(source) { return Ok((*headers).clone()); } if !source.respond_to("to_a", false)? { - return Err(type_value_error_to_magnus( + return Err(header_type_error( + &ruby, "Expected Headers, a Hash, or an enumerable of pairs", )); } @@ -313,11 +317,11 @@ mod helper { let pairs: RArray = source.funcall_public("to_a", ())?; let headers = Headers::default(); for pair in pairs { - let pair = RArray::try_convert(pair).map_err(|_| { - type_value_error_to_magnus("Expected each header entry to be a pair") - })?; + let pair = RArray::try_convert(pair) + .map_err(|_| header_type_error(&ruby, "Expected each header entry to be a pair"))?; if pair.len() != 2 { - return Err(type_value_error_to_magnus( + return Err(header_type_error( + &ruby, "Expected each header entry to contain a name and value", )); } @@ -332,16 +336,18 @@ mod helper { /// Symbol underscores are changed to hyphens before [`HeaderName`] validates /// and normalizes the name. Other Ruby types produce `Wreq::BuilderError`. pub(super) fn parse_header_name(value: Value) -> Result { + let ruby = Ruby::get_with(value); let name = match (RString::from_value(value), Symbol::from_value(value)) { (Some(name), _) => name.to_bytes(), (None, Some(name)) => Bytes::from(name.name()?.replace('_', "-")), (None, None) => { - return Err(type_value_error_to_magnus( + return Err(header_type_error( + &ruby, "Expected a String or Symbol header name", )); } }; - HeaderName::from_bytes(name.as_ref()).map_err(header_name_error_to_magnus) + HeaderName::from_bytes(name.as_ref()).map_err(|err| header_name_error(&ruby, err)) } /// Convert a Ruby String or Array of Strings into validated header values. @@ -362,9 +368,11 @@ mod helper { /// Invalid Ruby types and bytes rejected by [`HeaderValue`] are mapped to /// `Wreq::BuilderError`. fn parse_header_value(value: Value) -> Result { + let ruby = Ruby::get_with(value); let value = RString::try_convert(value) - .map_err(|_| type_value_error_to_magnus("Expected a String header value"))?; - HeaderValue::from_maybe_shared(value.to_bytes()).map_err(header_value_error_to_magnus) + .map_err(|_| header_type_error(&ruby, "Expected a String header value"))?; + HeaderValue::from_maybe_shared(value.to_bytes()) + .map_err(|err| header_value_error(&ruby, err)) } /// Validate the resulting number of header occurrences before a mutation. @@ -375,6 +383,7 @@ mod helper { /// above the native [`HeaderMap`](http::HeaderMap) limit returns /// `Wreq::BuilderError` without mutating the collection. pub(super) fn ensure_header_count( + ruby: &Ruby, current: usize, replaced: usize, added: usize, @@ -385,13 +394,13 @@ mod helper { if count.is_some_and(|count| count <= MAX_HEADER_ENTRIES) { Ok(()) } else { - Err(header_count_error()) + Err(header_count_error(ruby)) } } /// Build the error returned when the native header map reaches its entry limit. - pub(super) fn header_count_error() -> Error { - type_value_error_to_magnus("Header collection exceeds 32,768 entries") + pub(super) fn header_count_error(ruby: &Ruby) -> Error { + header_type_error(ruby, "Header collection exceeds 32,768 entries") } } diff --git a/src/lib.rs b/src/lib.rs index f510a37..ae53f87 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,68 +12,72 @@ mod extractor; mod gvl; mod header; mod http; +mod options; mod rt; mod serde; use magnus::{Error, Module, Ruby, Value}; -use crate::client::{Client, resp::Response}; +use crate::{ + client::{Client, resp::Response}, + http::Method, +}; const RUBY_MODULE_NAME: &str = "Wreq"; const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Send a HTTP request. #[inline] -pub fn request(args: &[Value]) -> Result { - Client::request(&Client::default(), args) +pub fn request(ruby: &Ruby, args: &[Value]) -> Result { + Client::request_with_default_client(ruby, args) } /// Send a GET request. #[inline] -pub fn get(args: &[Value]) -> Result { - Client::get(&Client::default(), args) +pub fn get(ruby: &Ruby, args: &[Value]) -> Result { + Client::execute_with_default_client(ruby, Method::GET, args) } /// Send a POST request. #[inline] -pub fn post(args: &[Value]) -> Result { - Client::post(&Client::default(), args) +pub fn post(ruby: &Ruby, args: &[Value]) -> Result { + Client::execute_with_default_client(ruby, Method::POST, args) } /// Send a PUT request. #[inline] -pub fn put(args: &[Value]) -> Result { - Client::put(&Client::default(), args) +pub fn put(ruby: &Ruby, args: &[Value]) -> Result { + Client::execute_with_default_client(ruby, Method::PUT, args) } /// Send a DELETE request. #[inline] -pub fn delete(args: &[Value]) -> Result { - Client::delete(&Client::default(), args) +pub fn delete(ruby: &Ruby, args: &[Value]) -> Result { + Client::execute_with_default_client(ruby, Method::DELETE, args) } /// Send a HEAD request. #[inline] -pub fn head(args: &[Value]) -> Result { - Client::head(&Client::default(), args) +pub fn head(ruby: &Ruby, args: &[Value]) -> Result { + Client::execute_with_default_client(ruby, Method::HEAD, args) } /// Send an OPTIONS request. #[inline] -pub fn options(args: &[Value]) -> Result { - Client::options(&Client::default(), args) +pub fn options(ruby: &Ruby, args: &[Value]) -> Result { + Client::execute_with_default_client(ruby, Method::OPTIONS, args) } /// Send a TRACE request. #[inline] -pub fn trace(args: &[Value]) -> Result { - Client::trace(&Client::default(), args) +pub fn trace(ruby: &Ruby, args: &[Value]) -> Result { + Client::execute_with_default_client(ruby, Method::TRACE, args) } /// Send a PATCH request. #[inline] -pub fn patch(args: &[Value]) -> Result { - Client::patch(&Client::default(), args) +pub fn patch(ruby: &Ruby, args: &[Value]) -> Result { + Client::execute_with_default_client(ruby, Method::PATCH, args) } /// wreq ruby binding @@ -95,6 +99,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> { cookie::include(ruby, &gem_module)?; client::include(ruby, &gem_module)?; emulate::include(ruby, &gem_module)?; - error::include(ruby); + error::include(ruby, &gem_module)?; Ok(()) } diff --git a/src/macros.rs b/src/macros.rs index 14c04a8..1418b02 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -34,11 +34,23 @@ macro_rules! apply_option { $builder = $builder.$method(); } }; - (set_if_true_with, $builder:expr, $option:expr, $method:ident, $default:expr, $value:expr) => { - if $option.unwrap_or($default) { - $builder = $builder.$method($value); - } - }; +} + +/// Convert a Ruby-native option whose field name is also its keyword name. +macro_rules! extract_native_option { + ($options:expr, $target:expr, $field:ident) => {{ + $target.$field.set($options.convert(stringify!($field))?); + }}; + ($options:expr, $target:expr, $field:ident, present) => {{ + $target + .$field + .set($options.convert_present(stringify!($field))?); + }}; + ($options:expr, $target:expr, $field:ident, $source:ty => $map:expr) => {{ + $target + .$field + .set($options.convert::<$source>(stringify!($field))?.map($map)); + }}; } macro_rules! define_ruby_enum { @@ -117,17 +129,12 @@ macro_rules! define_ruby_enum { }; } -macro_rules! ruby { - () => { - magnus::Ruby::get().expect("Failed to get Ruby VM instance") - }; -} - macro_rules! extract_request { ($args:expr, $required:ty) => {{ let args = magnus::scan_args::scan_args::<$required, (), (), (), magnus::RHash, ()>($args)?; let required = args.required; - let request = crate::client::req::Request::new(&ruby!(), args.keywords)?; + let ruby = magnus::Ruby::get_with(args.keywords); + let request = crate::client::req::Request::new(&ruby, args.keywords)?; (required, request) }}; } diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..54095ec --- /dev/null +++ b/src/options.rs @@ -0,0 +1,284 @@ +//! Parsing and validation for Ruby option hashes. + +use std::{fmt, marker::PhantomData}; + +use ::serde::{ + Deserialize, Deserializer, + de::{DeserializeOwned, Visitor}, +}; +use magnus::{Error, RHash, Ruby, TryConvert, Value, value::ReprValue}; + +use crate::{ + error::{argument_error, option_value_error, type_error}, + serde, +}; + +/// Private Serde newtype used to recognize values converted by Magnus. +pub(crate) const NATIVE_OPTION_TOKEN: &str = "$wreq::private::NativeOption"; + +/// An option represented in the Serde schema but converted through Magnus. +/// +/// Serde records the field as accepted without traversing its Ruby value. Once +/// the complete option hash has been validated, [`Options::convert`] stores the +/// converted value here. +pub(crate) struct NativeOption(Option); + +impl NativeOption { + /// Replace the value after converting it through Magnus. + pub(crate) fn set(&mut self, value: Option) { + self.0 = value; + } + + /// Take the converted value. + pub(crate) fn take(&mut self) -> Option { + self.0.take() + } +} + +impl Default for NativeOption { + fn default() -> Self { + Self(None) + } +} + +impl<'de, T> Deserialize<'de> for NativeOption { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct NativeOptionVisitor(PhantomData T>); + + impl Visitor<'_> for NativeOptionVisitor { + type Value = NativeOption; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a Ruby-native option") + } + + fn visit_unit(self) -> Result { + Ok(NativeOption::default()) + } + } + + deserializer + .deserialize_newtype_struct(NATIVE_OPTION_TOKEN, NativeOptionVisitor(PhantomData)) + } +} + +/// A Ruby option hash parsed through its derived Serde schema. +pub(crate) struct Options<'ruby> { + ruby: &'ruby Ruby, + hash: RHash, +} + +impl<'ruby> Options<'ruby> { + /// Parse zero or one Ruby options Hash without copying it. + /// + /// # Errors + /// + /// Returns `ArgumentError` for extra positional arguments and `TypeError` + /// when the single argument is not a Hash. + pub(crate) fn from_args( + ruby: &'ruby Ruby, + args: &[Value], + owner: &str, + ) -> Result, Error> { + magnus::scan_args::scan_args::<(), (Option,), (), (), (), ()>(args)? + .optional + .0 + .map(|value| { + RHash::from_value(value) + .map(|hash| Self::new(ruby, hash)) + .ok_or_else(|| type_error(ruby, format!("{owner} options must be a Hash"))) + }) + .transpose() + } + + /// Wrap a Ruby Hash without copying its keys or values. + pub(crate) fn new(ruby: &'ruby Ruby, hash: RHash) -> Self { + Self { ruby, hash } + } + + /// Return the original Ruby Hash as a generic value. + pub(crate) fn as_value(&self) -> Value { + self.hash.as_value() + } + + /// Validate option keys without reading values, then borrow the options for chaining. + /// + /// # Errors + /// + /// Returns `ArgumentError` for unknown or duplicate keys and `TypeError` + /// when a key is neither a Ruby Symbol nor String. + pub(crate) fn validate_keys(&self) -> Result<&Self, Error> + where + T: DeserializeOwned, + { + serde::validate_option_keys::<_, T>(self.ruby, self.hash)?; + Ok(self) + } + + /// Deserialize validated option values with field-path error context. + /// + /// # Errors + /// + /// Returns the Ruby exception produced while converting a known value and + /// includes its option path in the message. + pub(crate) fn deserialize(&self) -> Result + where + T: DeserializeOwned, + { + serde::deserialize_options(self.ruby, self.hash) + } + + /// Return whether an option key is present, including a `nil` value. + pub(crate) fn is_present(&self, name: &str) -> bool { + get(self.ruby, self.hash, name).is_some() + } + + /// Return whether an option is present with a non-nil value. + pub(crate) fn is_non_nil(&self, name: &str) -> bool { + get(self.ruby, self.hash, name).is_some_and(|value| !value.is_nil()) + } + + /// Create a fluent validator for rules involving this option hash. + pub(crate) fn validator(&self) -> Validator<'_, 'ruby> { + Validator::new(self) + } + + /// Convert a present, non-nil option while retaining its name in errors. + /// + /// # Errors + /// + /// Returns the Ruby conversion error with the option name added. + pub(crate) fn convert(&self, name: &str) -> Result, Error> + where + T: TryConvert, + { + get(self.ruby, self.hash, name) + .filter(|value| !value.is_nil()) + .map(T::try_convert) + .transpose() + .map_err(|error| option_value_error(name, error)) + } + + /// Convert a present option including `nil`, which may be meaningful to `T`. + /// + /// # Errors + /// + /// Returns the Ruby conversion error with the option name added. + pub(crate) fn convert_present(&self, name: &str) -> Result, Error> + where + T: TryConvert, + { + get(self.ruby, self.hash, name) + .map(T::try_convert) + .transpose() + .map_err(|error| option_value_error(name, error)) + } +} + +/// A fluent collection of validation rules for one option hash. +/// +/// Rules run in declaration order and later rules become no-ops after the +/// first failure, preserving a deterministic error priority without building +/// errors for successful rules. +#[must_use = "call Validator::finish to observe validation errors"] +pub(crate) struct Validator<'options, 'ruby> { + options: &'options Options<'ruby>, + error: Option, +} + +impl<'options, 'ruby> Validator<'options, 'ruby> { + /// Create an empty validation chain. + fn new(options: &'options Options<'ruby>) -> Self { + Self { + options, + error: None, + } + } + + /// Run one rule unless an earlier rule has already failed. + fn check(mut self, validate: F) -> Self + where + F: FnOnce(&Options<'ruby>) -> Result<(), Error>, + { + if self.error.is_none() { + self.error = validate(self.options).err(); + } + self + } + + /// Reject a non-nil option when the current target does not support it. + pub(crate) fn reject_unsupported(self, name: &str, supported: bool) -> Self { + self.check(|options| { + if supported || !options.is_non_nil(name) { + Ok(()) + } else { + Err(argument_error( + options.ruby, + format!("option :{name} is not supported on this platform"), + )) + } + }) + } + + /// Reject a group when more than one option has an effective value. + pub(crate) fn reject_conflicts(self, options: [(&str, bool); N]) -> Self { + self.check(|state| { + if options.iter().filter(|(_, present)| *present).count() < 2 { + return Ok(()); + } + + let mut message = String::from("mutually exclusive options: "); + let mut separator = ""; + for (name, present) in options { + if present { + message.push_str(separator); + message.push(':'); + message.push_str(name); + separator = ", "; + } + } + + Err(argument_error(state.ruby, message)) + }) + } + + /// Require a companion setting when an option is present. + pub(crate) fn require_when_present( + self, + option: &str, + present: bool, + effective: bool, + requirement: &str, + ) -> Self { + self.check(|state| { + if !present || effective { + Ok(()) + } else { + Err(argument_error( + state.ruby, + format!("option :{option} requires {requirement}"), + )) + } + }) + } + + /// Complete validation and return the source options for further processing. + /// + /// # Errors + /// + /// Returns the error produced by the first failed rule. + pub(crate) fn finish(self) -> Result<&'options Options<'ruby>, Error> { + self.error.map_or(Ok(self.options), Err) + } +} + +/// Return an option by either its Symbol key or equivalent String key. +/// +/// The Serde schema rejects a hash containing both forms as a duplicate before +/// native values are converted. +pub(crate) fn get(ruby: &Ruby, hash: RHash, name: &str) -> Option { + hash.get(ruby.to_symbol(name)).or_else(|| hash.get(name)) +} diff --git a/src/rt.rs b/src/rt.rs index 9cc96cb..28feba9 100644 --- a/src/rt.rs +++ b/src/rt.rs @@ -1,16 +1,18 @@ use std::sync::LazyLock; +use magnus::Ruby; use tokio::runtime::{Builder, Runtime}; -use crate::{error::interrupt_error, gvl}; +use crate::{ + error::{interrupt_error, runtime_initialization_error}, + gvl, +}; -static RUNTIME: LazyLock = LazyLock::new(|| { +/// Initialize the global runtime lazily and preserve failures for Ruby. +static RUNTIME: LazyLock> = LazyLock::new(|| { let mut builder = Builder::new_multi_thread(); - builder - .enable_all() - .build() - .expect("Failed to initialize Tokio runtime") + builder.enable_all().build() }); enum BlockOnError { @@ -23,13 +25,22 @@ enum BlockOnError { /// The future runs without Ruby's GVL, so it must not construct Ruby objects or /// Ruby exceptions. Convert Rust errors back into Ruby errors after the GVL has /// been reacquired. -pub fn try_block_on(future: F, map_err: M) -> Result +/// +/// # Errors +/// +/// Returns `Wreq::BuilderError` if the Tokio runtime cannot be initialized, +/// Ruby's standard `Interrupt` if Ruby interrupts the request, or the error produced +/// by `map_err` if the future fails. +pub fn try_block_on(ruby: &Ruby, future: F, map_err: M) -> Result where F: Future>, - M: FnOnce(E) -> magnus::Error, + M: FnOnce(&Ruby, E) -> magnus::Error, { + let runtime = RUNTIME + .as_ref() + .map_err(|err| runtime_initialization_error(ruby, err))?; let result = gvl::nogvl_cancellable(|flag| { - RUNTIME.block_on(async move { + runtime.block_on(async move { tokio::select! { biased; _ = flag.cancelled() => Err(BlockOnError::Interrupted), @@ -40,7 +51,7 @@ where match result { Ok(value) => Ok(value), - Err(BlockOnError::Interrupted) => Err(interrupt_error()), - Err(BlockOnError::Future(err)) => Err(map_err(err)), + Err(BlockOnError::Interrupted) => Err(interrupt_error(ruby)), + Err(BlockOnError::Future(err)) => Err(map_err(ruby, err)), } } diff --git a/src/serde.rs b/src/serde.rs index 2f900d3..167313f 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -27,8 +27,9 @@ SOFTWARE. //! mode with arbitrary-size integer support, finite-float and object-key //! validation, insertion-order preservation, and bounded container nesting. //! The bridge also avoids the upstream `Ruby::get().unwrap()` error path, -//! checks iterator and map state explicitly, and supports `i128` and `u128` -//! in both directions for typed Rust values. +//! checks iterator and map state explicitly, reports unknown option fields and +//! conversion paths, and supports `i128` and `u128` in both directions for +//! typed Rust values. #![allow(unsafe_code)] mod de; @@ -37,8 +38,13 @@ mod ser; #[cfg(test)] mod tests; -use ::serde::{Deserialize, Serialize}; +use ::serde::{Deserialize, Serialize, de::DeserializeOwned}; +use indexmap::IndexSet; use magnus::{IntoValue, Ruby, TryConvert}; +use serde_ignored::Path; +use serde_path_to_error::{Deserializer as PathDeserializer, Track}; + +use crate::error::argument_error; pub(super) use error::Error; @@ -55,7 +61,14 @@ pub(super) const MAX_JSON_NESTING: usize = 100; /// Deserialize a Ruby value using native Ruby data model semantics. /// /// This preserves the public conversion behavior provided by the upstream -/// `serde_magnus::deserialize` function. +/// `serde_magnus::deserialize` function. JSON callers should use +/// [`deserialize_json`] when arbitrary-size integer precision is required. +/// +/// # Errors +/// +/// Returns the Ruby exception produced when the input cannot be represented by +/// `Output`. +#[allow(dead_code)] // Retained as part of the upstream-compatible Serde surface. pub(crate) fn deserialize_ruby<'de, Input, Output>( ruby: &Ruby, input: Input, @@ -71,6 +84,11 @@ where /// /// This preserves the public conversion behavior provided by the upstream /// `serde_magnus::serialize` function. +/// +/// # Errors +/// +/// Returns the Ruby exception produced while creating or converting the output +/// value. pub(crate) fn serialize(ruby: &Ruby, input: &Input) -> Result where Input: Serialize + ?Sized, @@ -81,6 +99,14 @@ where } /// Deserialize a Ruby value using JSON-specific conversion rules. +/// +/// Unlike [`deserialize_ruby`], this preserves arbitrary-size integers and +/// rejects non-finite floats, unsupported object keys, and excessive nesting. +/// +/// # Errors +/// +/// Returns the Ruby exception produced when the input cannot be represented by +/// `Output`. pub(crate) fn deserialize_json<'de, Input, Output>( ruby: &Ruby, input: Input, @@ -91,3 +117,86 @@ where { de::deserialize_json(ruby, input.into_value_with(ruby)).map_err(|error| error.into_magnus(ruby)) } + +/// Validate Ruby option keys without inspecting their values. +/// +/// The derived struct is the only list of accepted keys. Every option field is +/// optional, so this pass can collect unknown keys and duplicates without +/// converting or retaining any Ruby value. +/// +/// # Errors +/// +/// Returns `ArgumentError` for unknown or duplicate keys and `TypeError` for +/// keys that are neither Ruby Symbols nor Strings. +pub(crate) fn validate_option_keys( + ruby: &Ruby, + input: Input, +) -> Result<(), magnus::Error> +where + Input: IntoValue, + Output: DeserializeOwned, +{ + let value: magnus::Value = input.into_value_with(ruby); + let mut unknown = IndexSet::new(); + { + let mut callback = |path: Path<'_>| { + if let Path::Map { key, .. } = path { + unknown.insert(key); + } + }; + serde_ignored::deserialize::<_, _, Output>( + de::Deserializer::new_option_keys(ruby, value), + &mut callback, + ) + .map_err(|error| error.into_option_magnus(ruby, None))?; + } + + if unknown.is_empty() { + return Ok(()); + } + + let label = if unknown.len() == 1 { + "unknown option" + } else { + "unknown options" + }; + let mut message = String::from(label); + message.push(':'); + for (index, name) in unknown.into_iter().enumerate() { + if index > 0 { + message.push(','); + } + message.push(' '); + message.push(':'); + message.push_str(&name); + } + + Err(argument_error(ruby, message)) +} + +/// Deserialize validated Ruby options and retain the failing field path. +/// +/// # Errors +/// +/// Returns the Ruby conversion error with the failing option path attached. +pub(crate) fn deserialize_options( + ruby: &Ruby, + input: Input, +) -> Result +where + Input: IntoValue, + Output: DeserializeOwned, +{ + let value = input.into_value_with(ruby); + let mut track = Track::new(); + let result = Output::deserialize(PathDeserializer::new( + de::Deserializer::new_ruby(ruby, value), + &mut track, + )); + + result.map_err(|error| { + let path = track.path(); + let path = path.iter().next().is_some().then_some(&path); + error.into_option_magnus(ruby, path) + }) +} diff --git a/src/serde/de.rs b/src/serde/de.rs index 7e2e0ce..48a9e41 100644 --- a/src/serde/de.rs +++ b/src/serde/de.rs @@ -11,7 +11,8 @@ use magnus::{Ruby, Value}; use super::Error; use array_deserializer::ArrayDeserializer; -use deserializer::{Deserializer, Mode}; +pub(super) use deserializer::Deserializer; +use deserializer::Mode; use hash_deserializer::HashDeserializer; use variant_deserializer::VariantDeserializer; diff --git a/src/serde/de/deserializer.rs b/src/serde/de/deserializer.rs index ed0b560..a8db24e 100644 --- a/src/serde/de/deserializer.rs +++ b/src/serde/de/deserializer.rs @@ -9,6 +9,7 @@ use super::{ array_deserializer::ArrayDeserializer, enum_deserializer::EnumDeserializer, hash_deserializer::HashDeserializer, number_deserializer::NumberDeserializer, }; +use crate::options::NATIVE_OPTION_TOKEN; /// Implement typed Serde integer entry points with Magnus's checked conversions. macro_rules! impl_deserialize_integers { @@ -32,6 +33,8 @@ macro_rules! impl_deserialize_integers { pub(super) enum Mode { /// Preserve the native Ruby-to-Serde conversion behavior. Ruby, + /// Visit option names while skipping their values. + OptionKeys, /// Enforce the JSON data model and preserve arbitrary-size numbers. Json, } @@ -44,7 +47,7 @@ impl Mode { } /// Serde deserializer over Ruby values. -pub(super) struct Deserializer<'ruby> { +pub(in crate::serde) struct Deserializer<'ruby> { ruby: &'ruby Ruby, value: Value, depth: usize, @@ -53,10 +56,15 @@ pub(super) struct Deserializer<'ruby> { impl<'ruby> Deserializer<'ruby> { /// Create a deserializer with native `serde_magnus` Ruby behavior. - pub(super) fn new_ruby(ruby: &'ruby Ruby, value: Value) -> Self { + pub(in crate::serde) fn new_ruby(ruby: &'ruby Ruby, value: Value) -> Self { Self::with_mode(ruby, value, 0, Mode::Ruby) } + /// Create a deserializer that validates option keys without reading values. + pub(in crate::serde) fn new_option_keys(ruby: &'ruby Ruby, value: Value) -> Self { + Self::with_mode(ruby, value, 0, Mode::OptionKeys) + } + /// Create a JSON deserializer with validation and arbitrary precision. pub(super) fn new_json(ruby: &'ruby Ruby, value: Value) -> Self { Self::with_mode(ruby, value, 0, Mode::Json) @@ -181,7 +189,7 @@ impl<'de> ::serde::Deserializer<'de> for Deserializer<'_> { where Visitor: ::serde::de::Visitor<'de>, { - if self.value.is_nil() { + if matches!(self.mode, Mode::OptionKeys) || self.value.is_nil() { visitor.visit_none() } else { visitor.visit_some(self) @@ -233,13 +241,17 @@ impl<'de> ::serde::Deserializer<'de> for Deserializer<'_> { fn deserialize_newtype_struct( self, - _name: &'static str, + name: &'static str, visitor: Visitor, ) -> Result where Visitor: ::serde::de::Visitor<'de>, { - visitor.visit_newtype_struct(self) + if name == NATIVE_OPTION_TOKEN { + visitor.visit_unit() + } else { + visitor.visit_newtype_struct(self) + } } fn deserialize_ignored_any( @@ -252,6 +264,29 @@ impl<'de> ::serde::Deserializer<'de> for Deserializer<'_> { visitor.visit_unit() } + fn deserialize_identifier( + self, + visitor: Visitor, + ) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + if matches!(self.mode, Mode::OptionKeys) { + if let Some(value) = RString::from_value(self.value) { + return visitor.visit_string(value.to_string()?); + } + if let Some(value) = Symbol::from_value(self.value) { + return match value.name()? { + std::borrow::Cow::Borrowed(name) => visitor.visit_borrowed_str(name), + std::borrow::Cow::Owned(name) => visitor.visit_string(name), + }; + } + return Err(Error::type_error("option keys must be Symbols or Strings")); + } + + self.deserialize_any(visitor) + } + impl_deserialize_integers! { deserialize_i8 => (visit_i8, to_i8), deserialize_i16 => (visit_i16, to_i16), @@ -267,6 +302,6 @@ impl<'de> ::serde::Deserializer<'de> for Deserializer<'_> { forward_to_deserialize_any! { > - bool f32 f64 char str string unit unit_struct seq tuple tuple_struct map struct identifier + bool f32 f64 char str string unit unit_struct seq tuple tuple_struct map struct } } diff --git a/src/serde/de/hash_deserializer.rs b/src/serde/de/hash_deserializer.rs index 5a2dfe7..a45ab56 100644 --- a/src/serde/de/hash_deserializer.rs +++ b/src/serde/de/hash_deserializer.rs @@ -1,7 +1,7 @@ use std::iter::Peekable; use ::serde::de::{DeserializeSeed, MapAccess}; -use magnus::{RHash, RString, Ruby, Symbol, Value, value::ReprValue}; +use magnus::{RHash, RString, Ruby, Symbol, Value, r_hash::ForEach}; use super::{Deserializer, Mode, array_enumerator::ArrayEnumerator}; use crate::serde::Error; @@ -23,7 +23,11 @@ impl<'ruby> HashDeserializer<'ruby> { depth: usize, mode: Mode, ) -> Result { - let keys = hash.funcall("keys", ())?; + let keys = ruby.ary_new_capa(hash.len()); + hash.foreach(|key: Value, _value: Value| { + keys.push(key)?; + Ok(ForEach::Continue) + })?; Ok(Self { ruby, hash, diff --git a/src/serde/error.rs b/src/serde/error.rs index 881f36d..80cd103 100644 --- a/src/serde/error.rs +++ b/src/serde/error.rs @@ -1,10 +1,16 @@ use std::fmt; +use magnus::error::ErrorType; +use serde_path_to_error::Path; + +use crate::error::contextualize_magnus_error; + /// Error produced by the local Ruby and Serde bridge. #[derive(Debug)] pub(crate) enum Error { Runtime(String), Type(String), + DuplicateField(&'static str), Ruby(magnus::Error), } @@ -24,15 +30,77 @@ impl Error { match self { Self::Runtime(message) => magnus::Error::new(ruby.exception_runtime_error(), message), Self::Type(message) => magnus::Error::new(ruby.exception_type_error(), message), + Self::DuplicateField(field) => magnus::Error::new( + ruby.exception_runtime_error(), + format!("duplicate field `{field}`"), + ), Self::Ruby(error) => error, } } + + /// Convert an option value error into Ruby's normal argument categories. + pub(super) fn into_option_magnus( + self, + ruby: &magnus::Ruby, + path: Option<&Path>, + ) -> magnus::Error { + match self { + Self::Runtime(message) => magnus::Error::new( + ruby.exception_arg_error(), + contextualize_message(path, message), + ), + Self::Type(message) => magnus::Error::new( + ruby.exception_type_error(), + contextualize_message(path, message), + ), + Self::DuplicateField(field) => magnus::Error::new( + ruby.exception_arg_error(), + format!("duplicate option: :{field}"), + ), + Self::Ruby(error) if error.is_kind_of(ruby.exception_range_error()) => { + magnus::Error::new( + ruby.exception_arg_error(), + magnus_error_message(&error, path), + ) + } + Self::Ruby(error) => match path { + Some(path) => { + contextualize_magnus_error(error, format_args!("invalid value for :{path}")) + } + None => error, + }, + } + } +} + +/// Prefix an error message when Serde identified the failing option path. +fn contextualize_message(path: Option<&Path>, message: String) -> String { + match path { + Some(path) => format!("invalid value for :{path}: {message}"), + None => message, + } +} + +/// Build the final Ruby error message without an intermediate context String. +fn magnus_error_message(error: &magnus::Error, path: Option<&Path>) -> String { + match (path, error.error_type()) { + (Some(path), ErrorType::Error(_, message)) => { + format!("invalid value for :{path}: {message}") + } + (Some(path), ErrorType::Exception(exception)) => { + format!("invalid value for :{path}: {exception}") + } + (Some(path), ErrorType::Jump(_)) => format!("invalid value for :{path}: {error}"), + (None, ErrorType::Error(_, message)) => message.as_ref().to_owned(), + (None, _) => error.to_string(), + } } impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Runtime(message) | Self::Type(message) => formatter.write_str(message), + Self::DuplicateField(field) => write!(formatter, "duplicate field `{field}`"), Self::Ruby(error) => error.fmt(formatter), } } @@ -65,6 +133,10 @@ impl ::serde::de::Error for Error { "invalid type: expected {expected}, got {unexpected}" )) } + + fn duplicate_field(field: &'static str) -> Self { + Self::DuplicateField(field) + } } impl From for Error { diff --git a/test/option_validation_test.rb b/test/option_validation_test.rb new file mode 100644 index 0000000..fce6e06 --- /dev/null +++ b/test/option_validation_test.rb @@ -0,0 +1,311 @@ +require "test_helper" + +class OptionValidationTest < Minitest::Test + INVALID_URL = "not a url" + + def test_unknown_client_option_names_every_invalid_key_without_values + secret = "must-not-appear" + + error = assert_raises(ArgumentError) do + Wreq::Client.new(timout: secret, history: secret) + end + + assert_includes error.message, ":timout" + assert_includes error.message, ":history" + refute_includes error.message, secret + end + + def test_unknown_options_are_checked_before_known_values + secret = "must-not-appear" + + error = assert_raises(ArgumentError) do + Wreq::Client.new(timeout: secret, history: Object.new, timout: secret) + end + + assert_includes error.message, ":history" + assert_includes error.message, ":timout" + refute_includes error.message, secret + + conflict_error = assert_raises(ArgumentError) do + Wreq::Client.new(http1_only: true, http2_only: true, history: secret) + end + assert_includes conflict_error.message, ":history" + refute_includes conflict_error.message, secret + end + + def test_unknown_request_options_raise_for_client_and_module_methods + client_error = assert_raises(ArgumentError) do + Wreq::Client.new.get(INVALID_URL, history: true) + end + module_error = assert_raises(ArgumentError) do + Wreq.get(INVALID_URL, history: true) + end + + assert_includes client_error.message, ":history" + assert_includes module_error.message, ":history" + end + + def test_hash_expansion_uses_the_same_validation + valid = {timeout: 1, allow_redirects: true, max_redirects: 2} + invalid = {timout: 1} + + assert_instance_of Wreq::Client, Wreq::Client.new(**valid) + error = assert_raises(ArgumentError) { Wreq::Client.new(**invalid) } + assert_includes error.message, ":timout" + end + + def test_string_option_keys_are_supported_and_duplicate_forms_are_rejected + assert_instance_of Wreq::Client, Wreq::Client.new({"timeout" => 1}) + + error = assert_raises(ArgumentError) do + Wreq::Client.new({:timeout => 1, "timeout" => 2}) + end + assert_includes error.message, "duplicate option: :timeout" + end + + def test_non_string_or_symbol_option_key_raises_type_error + error = assert_raises(TypeError) { Wreq::Client.new({1 => true}) } + + assert_includes error.message, "option keys" + end + + def test_hash_subclass_cannot_hide_unknown_options + options_class = Class.new(Hash) do + def keys + [] + end + end + options = options_class.new + options[:timout] = 1 + + error = assert_raises(ArgumentError) { Wreq::Client.new(options) } + assert_includes error.message, ":timout" + end + + def test_invalid_known_values_name_the_option + timeout_error = assert_raises(TypeError) do + Wreq::Client.new(timeout: "slow") + end + address_error = assert_raises(ArgumentError) do + Wreq::Client.new(local_address: "not-an-address") + end + proxy_error = assert_raises(TypeError) do + Wreq::Client.new(proxy: Object.new) + end + proxy_uri_error = assert_raises(Wreq::BuilderError) do + Wreq::Client.new(proxy: "invalid://") + end + orig_headers_error = assert_raises(Wreq::BuilderError) do + Wreq.get(INVALID_URL, orig_headers: ["Accept", Object.new]) + end + + assert_includes timeout_error.message, ":timeout" + assert_includes address_error.message, ":local_address" + assert_includes proxy_error.message, ":proxy" + assert_includes proxy_uri_error.message, ":proxy" + assert_includes orig_headers_error.message, ":orig_headers" + end + + def test_consumed_body_sender_error_preserves_class_and_names_option + sender = Wreq::BodySender.new(1) + + assert_raises(Wreq::BuilderError) do + Wreq.post(INVALID_URL, body: sender) + end + error = assert_raises(Wreq::MemoryError) do + Wreq.post(INVALID_URL, body: sender) + end + + assert_includes error.message, ":body" + ensure + sender&.close + end + + def test_option_conversion_preserves_raised_exception_class + source = Object.new + source.define_singleton_method(:to_a) { raise IOError, "boom" } + + error = assert_raises(IOError) do + Wreq::Client.new(headers: source) + end + + assert_includes error.message, ":headers" + assert_includes error.message, "boom" + end + + def test_out_of_range_integer_names_the_option + error = assert_raises(ArgumentError) do + Wreq::Client.new(timeout: 2**256) + end + + assert_includes error.message, ":timeout" + end + + def test_invalid_request_value_names_the_option_before_network_io + error = assert_raises(TypeError) do + Wreq.get(INVALID_URL, cookies: Object.new) + end + + assert_includes error.message, ":cookies" + end + + def test_client_constructor_rejects_non_hash_and_extra_arguments + assert_raises(TypeError) { Wreq::Client.new("ignored") } + assert_raises(ArgumentError) { Wreq::Client.new({}, {}) } + end + + def test_emulation_constructor_rejects_invalid_arguments_and_options + assert_raises(TypeError) { Wreq::Emulation.new("ignored") } + assert_raises(ArgumentError) { Wreq::Emulation.new({}, {}) } + + error = assert_raises(ArgumentError) { Wreq::Emulation.new(unknown: true) } + assert_includes error.message, ":unknown" + end + + def test_invalid_emulation_values_name_the_option + http2_error = assert_raises(TypeError) do + Wreq::Emulation.new(http2: "yes") + end + profile_error = assert_raises(TypeError) do + Wreq::Emulation.new(profile: Object.new) + end + + assert_includes http2_error.message, ":http2" + assert_includes profile_error.message, ":profile" + end + + def test_body_options_are_mutually_exclusive + error = assert_raises(ArgumentError) do + Wreq.post(INVALID_URL, body: "raw", form: {a: 1}, json: {b: 2}) + end + + assert_includes error.message, ":body" + assert_includes error.message, ":form" + assert_includes error.message, ":json" + end + + def test_body_conflict_does_not_consume_sender + sender = Wreq::BodySender.new(1) + + assert_raises(ArgumentError) do + Wreq.post(INVALID_URL, body: sender, json: {value: true}) + end + assert_raises(Wreq::BuilderError) do + Wreq.post(INVALID_URL, body: sender) + end + ensure + sender&.close + end + + def test_dependent_option_error_does_not_consume_sender + sender = Wreq::BodySender.new(1) + + assert_raises(ArgumentError) do + Wreq.post(INVALID_URL, body: sender, max_redirects: 2) + end + assert_raises(Wreq::BuilderError) do + Wreq.post(INVALID_URL, body: sender) + end + ensure + sender&.close + end + + def test_authentication_options_are_mutually_exclusive + error = assert_raises(ArgumentError) do + Wreq.get( + INVALID_URL, + auth: "secret", + bearer_auth: "secret", + basic_auth: ["user", "secret"] + ) + end + + assert_includes error.message, ":auth" + assert_includes error.message, ":bearer_auth" + assert_includes error.message, ":basic_auth" + refute_includes error.message, "secret" + end + + def test_protocol_only_options_are_mutually_exclusive + error = assert_raises(ArgumentError) do + Wreq::Client.new(http1_only: true, http2_only: true) + end + + assert_includes error.message, ":http1_only" + assert_includes error.message, ":http2_only" + end + + def test_validation_reports_the_first_failed_rule + error = assert_raises(ArgumentError) do + Wreq::Client.new( + http1_only: true, + http2_only: true, + max_redirects: 2, + headers: Object.new + ) + end + + assert_includes error.message, ":http1_only" + assert_includes error.message, ":http2_only" + refute_includes error.message, ":max_redirects" + end + + def test_explicit_proxy_and_no_proxy_are_mutually_exclusive + error = assert_raises(ArgumentError) do + Wreq::Client.new(proxy: "http://127.0.0.1:8080", no_proxy: true) + end + + assert_includes error.message, ":proxy" + assert_includes error.message, ":no_proxy" + end + + def test_max_redirects_requires_redirects_to_be_enabled + client_error = assert_raises(ArgumentError) do + Wreq::Client.new(max_redirects: 2) + end + disabled_error = assert_raises(ArgumentError) do + Wreq::Client.new(allow_redirects: false, max_redirects: 2) + end + request_error = assert_raises(ArgumentError) do + Wreq.get(INVALID_URL, max_redirects: 2) + end + + [client_error, disabled_error, request_error].each do |error| + assert_includes error.message, ":max_redirects" + assert_includes error.message, ":allow_redirects" + end + end + + def test_unsupported_platform_option_raises_argument_error + return if RUBY_PLATFORM.match?(/linux|android|fuchsia/) + + error = assert_raises(ArgumentError) do + Wreq::Client.new(tcp_user_timeout: 1) + end + assert_instance_of ArgumentError, error + assert_includes error.message, ":tcp_user_timeout" + end + + def test_windows_rejects_interface_for_client_and_request + return unless Gem.win_platform? + + client_error = assert_raises(ArgumentError) do + Wreq::Client.new(interface: "Ethernet") + end + request_error = assert_raises(ArgumentError) do + Wreq.get(INVALID_URL, interface: "Ethernet") + end + + assert_instance_of ArgumentError, client_error + assert_instance_of ArgumentError, request_error + assert_includes client_error.message, ":interface" + assert_includes request_error.message, ":interface" + end + + def test_valid_nil_and_zero_option_construction + assert_instance_of Wreq::Client, Wreq::Client.new + assert_instance_of Wreq::Client, + Wreq::Client.new(proxy: nil, interface: nil, tcp_user_timeout: nil) + assert_instance_of Wreq::Emulation, Wreq::Emulation.new(profile: nil, http2: true) + end +end