From 404b8647709389d7f987f813994e6b165ab82e91 Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Thu, 16 Jul 2026 18:42:07 +0000 Subject: [PATCH 1/2] Give native HTTP value types Ruby value semantics --- src/cookie.rs | 26 ++++ src/emulate.rs | 18 +++ src/http.rs | 81 +++++++++++-- src/macros.rs | 36 ++++++ test/value_semantics_test.rb | 225 +++++++++++++++++++++++++++++++++++ 5 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 test/value_semantics_test.rb diff --git a/src/cookie.rs b/src/cookie.rs index 276ae5f..e4c2b91 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -21,6 +21,27 @@ define_ruby_enum!( None, ); +impl SameSite { + /// SameSite attribute as a string. + pub fn to_s(&self) -> &'static str { + match self { + SameSite::Strict => "Strict", + SameSite::Lax => "Lax", + SameSite::None => "None", + } + } + + /// SameSite attribute as a lowercase Ruby symbol. + pub fn to_sym(&self) -> magnus::Symbol { + let name = match self { + SameSite::Strict => "strict", + SameSite::Lax => "lax", + SameSite::None => "none", + }; + ruby!().to_symbol(name) + } +} + /// A single HTTP cookie. #[derive(Clone)] #[magnus::wrap(class = "Wreq::Cookie", free_immediately, size)] @@ -281,6 +302,11 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { // SameSite enum let same_site_class = gem_module.define_class("SameSite", ruby.class_object())?; SameSite::define_constants(same_site_class)?; + same_site_class.define_method("to_s", method!(SameSite::to_s, 0))?; + same_site_class.define_method("to_sym", method!(SameSite::to_sym, 0))?; + same_site_class.define_method("==", method!(SameSite::equals, 1))?; + same_site_class.define_method("eql?", method!(SameSite::is_eql, 1))?; + same_site_class.define_method("hash", method!(SameSite::hash_value, 0))?; // Cookie class let cookie_class = gem_module.define_class("Cookie", ruby.class_object())?; diff --git a/src/emulate.rs b/src/emulate.rs index 788202e..b337271 100644 --- a/src/emulate.rs +++ b/src/emulate.rs @@ -181,6 +181,17 @@ impl Platform { pub fn to_s(&self) -> String { self.into_ffi().inspect() } + + pub fn to_sym(&self) -> magnus::Symbol { + let name = match self { + Platform::Windows => "windows", + Platform::MacOS => "macos", + Platform::Linux => "linux", + Platform::Android => "android", + Platform::IOS => "ios", + }; + ruby!().to_symbol(name) + } } // ===== impl Emulation ===== @@ -222,11 +233,18 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { // Profile enum binding let profile = gem_module.define_class("Profile", ruby.class_object())?; profile.define_method("to_s", method!(Profile::to_s, 0))?; + profile.define_method("==", method!(Profile::equals, 1))?; + profile.define_method("eql?", method!(Profile::is_eql, 1))?; + profile.define_method("hash", method!(Profile::hash_value, 0))?; Profile::define_constants(profile)?; // Platform enum binding let platform = gem_module.define_class("Platform", ruby.class_object())?; platform.define_method("to_s", method!(Platform::to_s, 0))?; + platform.define_method("to_sym", method!(Platform::to_sym, 0))?; + platform.define_method("==", method!(Platform::equals, 1))?; + platform.define_method("eql?", method!(Platform::is_eql, 1))?; + platform.define_method("hash", method!(Platform::hash_value, 0))?; Platform::define_constants(platform)?; // Emulation class binding diff --git a/src/http.rs b/src/http.rs index 9b00008..2a9e5fe 100644 --- a/src/http.rs +++ b/src/http.rs @@ -28,6 +28,39 @@ define_ruby_enum!( PATCH, ); +impl Method { + /// HTTP method token as a string. + #[inline] + pub fn to_s(&self) -> &'static str { + match self { + Method::GET => "GET", + Method::HEAD => "HEAD", + Method::POST => "POST", + Method::PUT => "PUT", + Method::DELETE => "DELETE", + Method::OPTIONS => "OPTIONS", + Method::TRACE => "TRACE", + Method::PATCH => "PATCH", + } + } + + /// HTTP method as a lowercase Ruby symbol. + #[inline] + pub fn to_sym(&self) -> magnus::Symbol { + let name = match self { + Method::GET => "get", + Method::HEAD => "head", + Method::POST => "post", + Method::PUT => "put", + Method::DELETE => "delete", + Method::OPTIONS => "options", + Method::TRACE => "trace", + Method::PATCH => "patch", + }; + ruby!().to_symbol(name) + } +} + /// HTTP status code. #[derive(Clone, Copy, PartialEq, Eq, Hash)] #[magnus::wrap(class = "Wreq::StatusCode", free_immediately, size)] @@ -41,14 +74,6 @@ impl Version { pub fn to_s(&self) -> String { self.into_ffi().inspect() } - - /// Value-based equality for Ruby (`==`). - #[inline] - pub fn equals(&self, other: Value) -> bool { - <&Version>::try_convert(other) - .map(|other| *self == *other) - .unwrap_or(false) - } } impl TryConvert for Version { @@ -101,6 +126,35 @@ impl StatusCode { pub fn to_s(&self) -> String { self.0.to_string() } + + /// Value-based equality for Ruby (`==`). + #[inline] + pub fn equals(&self, other: Value) -> bool { + <&StatusCode>::try_convert(other) + .map(|other| *self == *other) + .unwrap_or(false) + } + + /// Strict equality for Ruby (`eql?`). + #[inline] + pub fn is_eql(&self, other: Value) -> bool { + self.equals(other) + } + + /// Hash value for Ruby (`hash`). + #[inline] + pub fn hash_value(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } + + /// Return the status code as an integer (Ruby `to_i`). + #[inline] + pub const fn to_i(&self) -> u16 { + self.0.as_u16() + } } impl From for StatusCode { @@ -112,11 +166,18 @@ impl From for StatusCode { pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { let method_class = gem_module.define_class("Method", ruby.class_object())?; Method::define_constants(method_class)?; + method_class.define_method("to_s", method!(Method::to_s, 0))?; + method_class.define_method("to_sym", method!(Method::to_sym, 0))?; + method_class.define_method("==", method!(Method::equals, 1))?; + method_class.define_method("eql?", method!(Method::is_eql, 1))?; + method_class.define_method("hash", method!(Method::hash_value, 0))?; let version_class = gem_module.define_class("Version", ruby.class_object())?; Version::define_constants(version_class)?; version_class.define_method("to_s", method!(Version::to_s, 0))?; version_class.define_method("==", method!(Version::equals, 1))?; + version_class.define_method("eql?", method!(Version::is_eql, 1))?; + version_class.define_method("hash", method!(Version::hash_value, 0))?; let status_code_class = gem_module.define_class("StatusCode", ruby.class_object())?; status_code_class.define_method("as_int", method!(StatusCode::as_int, 0))?; @@ -126,6 +187,10 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { status_code_class.define_method("client_error?", method!(StatusCode::is_client_error, 0))?; status_code_class.define_method("server_error?", method!(StatusCode::is_server_error, 0))?; status_code_class.define_method("to_s", method!(StatusCode::to_s, 0))?; + status_code_class.define_method("==", method!(StatusCode::equals, 1))?; + status_code_class.define_method("eql?", method!(StatusCode::is_eql, 1))?; + status_code_class.define_method("hash", method!(StatusCode::hash_value, 0))?; + status_code_class.define_method("to_i", method!(StatusCode::to_i, 0))?; Ok(()) } diff --git a/src/macros.rs b/src/macros.rs index 14c04a8..bf95006 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -80,6 +80,24 @@ macro_rules! define_ruby_enum { $(class.const_set(stringify!($rust_variant), <$enum_type>::$rust_variant)?;)* Ok(()) } + + pub fn equals(&self, other: magnus::Value) -> bool { + use magnus::TryConvert; + <&$enum_type>::try_convert(other) + .map(|other| *self == *other) + .unwrap_or(false) + } + + pub fn is_eql(&self, other: magnus::Value) -> bool { + self.equals(other) + } + + pub fn hash_value(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() + } } }; @@ -113,6 +131,24 @@ macro_rules! define_ruby_enum { $(class.const_set(stringify!($rust_variant), <$enum_type>::$rust_variant)?;)* Ok(()) } + + pub fn equals(&self, other: magnus::Value) -> bool { + use magnus::TryConvert; + <&$enum_type>::try_convert(other) + .map(|other| *self == *other) + .unwrap_or(false) + } + + pub fn is_eql(&self, other: magnus::Value) -> bool { + self.equals(other) + } + + pub fn hash_value(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() + } } }; } diff --git a/test/value_semantics_test.rb b/test/value_semantics_test.rb new file mode 100644 index 0000000..6acd872 --- /dev/null +++ b/test/value_semantics_test.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "test_helper" +require "set" + +class ValueSemanticsTest < Minitest::Test + # ---- StatusCode ---- + + def setup + @response = Wreq.get("#{HTTPBIN_URL}/status/201") + end + + def test_status_code_to_i + assert_equal 201, @response.status.to_i + end + + def test_status_code_as_int_still_works + assert_equal 201, @response.status.as_int + end + + def test_status_code_to_i_matches_as_int + assert_equal @response.status.as_int, @response.status.to_i + end + + def test_status_code_equality + a = @response.status + b = @response.status + assert_equal a, b + end + + def test_status_code_eql + a = @response.status + b = @response.status + assert a.eql?(b) + end + + def test_status_code_hash_consistent + a = @response.status + b = @response.status + assert_equal a.hash, b.hash + end + + def test_status_code_as_hash_key + status = @response.status + h = { status => "created" } + assert_equal "created", h[@response.status] + end + + def test_status_code_in_set + s = Set.new + s.add(@response.status) + assert_includes s, @response.status + end + + def test_status_code_not_equal_to_integer + refute_equal @response.status, 201 + refute @response.status.eql?(201) + end + + def test_status_code_different_values_not_equal + ok = Wreq.get("#{HTTPBIN_URL}/status/200").status + created = @response.status + refute_equal ok, created + refute ok.eql?(created) + end + + def test_response_code_still_returns_integer + assert_instance_of Integer, @response.code + assert_equal 201, @response.code + end + + # ---- Version ---- + + def test_version_equality + assert_equal Wreq::Version::HTTP_11, Wreq::Version::HTTP_11 + end + + def test_version_eql + v = Wreq::Version::HTTP_11 + assert v.eql?(Wreq::Version::HTTP_11) + end + + def test_version_hash_consistent + a = Wreq::Version::HTTP_11 + b = Wreq::Version::HTTP_11 + assert_equal a.hash, b.hash + end + + def test_version_different_values_not_equal + refute_equal Wreq::Version::HTTP_11, Wreq::Version::HTTP_2 + end + + def test_version_as_hash_key + v = Wreq::Version::HTTP_11 + h = { v => "http1.1" } + assert_equal "http1.1", h[Wreq::Version::HTTP_11] + end + + def test_version_in_set + s = Set.new([Wreq::Version::HTTP_11, Wreq::Version::HTTP_2]) + assert_includes s, Wreq::Version::HTTP_11 + assert_includes s, Wreq::Version::HTTP_2 + refute_includes s, Wreq::Version::HTTP_3 + end + + # ---- Method ---- + + def test_method_to_s + assert_equal "GET", Wreq::Method::GET.to_s + assert_equal "POST", Wreq::Method::POST.to_s + assert_equal "PUT", Wreq::Method::PUT.to_s + assert_equal "DELETE", Wreq::Method::DELETE.to_s + assert_equal "HEAD", Wreq::Method::HEAD.to_s + assert_equal "OPTIONS", Wreq::Method::OPTIONS.to_s + assert_equal "TRACE", Wreq::Method::TRACE.to_s + assert_equal "PATCH", Wreq::Method::PATCH.to_s + end + + def test_method_to_sym + assert_equal :get, Wreq::Method::GET.to_sym + assert_equal :post, Wreq::Method::POST.to_sym + assert_equal :put, Wreq::Method::PUT.to_sym + assert_equal :delete, Wreq::Method::DELETE.to_sym + assert_equal :head, Wreq::Method::HEAD.to_sym + assert_equal :options, Wreq::Method::OPTIONS.to_sym + assert_equal :trace, Wreq::Method::TRACE.to_sym + assert_equal :patch, Wreq::Method::PATCH.to_sym + end + + def test_method_equality + assert_equal Wreq::Method::GET, Wreq::Method::GET + refute_equal Wreq::Method::GET, Wreq::Method::POST + end + + def test_method_eql + assert Wreq::Method::GET.eql?(Wreq::Method::GET) + refute Wreq::Method::GET.eql?(Wreq::Method::POST) + end + + def test_method_hash_consistent + assert_equal Wreq::Method::GET.hash, Wreq::Method::GET.hash + refute_equal Wreq::Method::GET.hash, Wreq::Method::POST.hash + end + + def test_method_as_hash_key + h = { Wreq::Method::GET => "get it" } + assert_equal "get it", h[Wreq::Method::GET] + assert_nil h[Wreq::Method::POST] + end + + # ---- SameSite ---- + + def test_same_site_to_s + assert_equal "Strict", Wreq::SameSite::Strict.to_s + assert_equal "Lax", Wreq::SameSite::Lax.to_s + assert_equal "None", Wreq::SameSite::None.to_s + end + + def test_same_site_to_sym + assert_equal :strict, Wreq::SameSite::Strict.to_sym + assert_equal :lax, Wreq::SameSite::Lax.to_sym + assert_equal :none, Wreq::SameSite::None.to_sym + end + + def test_same_site_equality + assert_equal Wreq::SameSite::Lax, Wreq::SameSite::Lax + refute_equal Wreq::SameSite::Lax, Wreq::SameSite::Strict + end + + def test_same_site_eql_and_hash + assert Wreq::SameSite::Lax.eql?(Wreq::SameSite::Lax) + assert_equal Wreq::SameSite::Lax.hash, Wreq::SameSite::Lax.hash + end + + # ---- Profile ---- + + def test_profile_equality + assert_equal Wreq::Profile::Chrome134, Wreq::Profile::Chrome134 + refute_equal Wreq::Profile::Chrome134, Wreq::Profile::Chrome135 + end + + def test_profile_eql_and_hash + assert Wreq::Profile::Chrome134.eql?(Wreq::Profile::Chrome134) + assert_equal Wreq::Profile::Chrome134.hash, Wreq::Profile::Chrome134.hash + end + + def test_profile_as_hash_key + h = { Wreq::Profile::Chrome134 => "chrome" } + assert_equal "chrome", h[Wreq::Profile::Chrome134] + assert_nil h[Wreq::Profile::Chrome135] + end + + # ---- Platform ---- + + def test_platform_equality + assert_equal Wreq::Platform::Windows, Wreq::Platform::Windows + refute_equal Wreq::Platform::Windows, Wreq::Platform::Linux + end + + def test_platform_eql_and_hash + assert Wreq::Platform::Windows.eql?(Wreq::Platform::Windows) + assert_equal Wreq::Platform::Windows.hash, Wreq::Platform::Windows.hash + end + + def test_platform_to_sym + assert_equal :windows, Wreq::Platform::Windows.to_sym + assert_equal :macos, Wreq::Platform::MacOS.to_sym + assert_equal :linux, Wreq::Platform::Linux.to_sym + assert_equal :android, Wreq::Platform::Android.to_sym + assert_equal :ios, Wreq::Platform::IOS.to_sym + end + + # ---- Cross-type comparisons ---- + + def test_cross_type_not_equal + refute_equal Wreq::Method::GET, Wreq::Version::HTTP_11 + refute_equal Wreq::SameSite::Lax, Wreq::Method::GET + refute_equal Wreq::Platform::Windows, Wreq::Profile::Chrome134 + end + + def test_cross_type_eql_false + refute Wreq::Method::GET.eql?(Wreq::Version::HTTP_11) + refute Wreq::SameSite::Lax.eql?(Wreq::Method::GET) + end +end \ No newline at end of file From ff9a1bf29fbd09fbd90671296216c199fd1b25e0 Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Fri, 17 Jul 2026 06:02:05 +0000 Subject: [PATCH 2/2] add relevant ruby definitions --- lib/wreq_ruby/cookie.rb | 27 +++++++++++++++ lib/wreq_ruby/emulate.rb | 53 ++++++++++++++++++++++++++++ lib/wreq_ruby/http.rb | 74 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/lib/wreq_ruby/cookie.rb b/lib/wreq_ruby/cookie.rb index 96fdbb9..ca7f7a6 100644 --- a/lib/wreq_ruby/cookie.rb +++ b/lib/wreq_ruby/cookie.rb @@ -10,6 +10,33 @@ class SameSite Lax = nil # None same-site policy. None = nil + + # Returns the SameSite attribute name (e.g. "Strict", "Lax", "None"). + # @return [String] + def to_s + end + + # Returns the SameSite attribute as a lowercase symbol (e.g. :strict, :lax, :none). + # @return [Symbol] + def to_sym + end + + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end end # A single HTTP cookie. diff --git a/lib/wreq_ruby/emulate.rb b/lib/wreq_ruby/emulate.rb index 66fb744..4d9273b 100644 --- a/lib/wreq_ruby/emulate.rb +++ b/lib/wreq_ruby/emulate.rb @@ -163,6 +163,29 @@ class Profile def to_s end end + + unless method_defined?(:==) + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + end + + unless method_defined?(:eql?) + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + end + + unless method_defined?(:hash) + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end + end end # Operating system platform enumeration backed by Rust. @@ -194,6 +217,36 @@ class Platform def to_s end end + + unless method_defined?(:to_sym) + # Returns the platform as a lowercase symbol (e.g. :windows, :linux). + # @return [Symbol] + def to_sym + end + end + + unless method_defined?(:==) + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + end + + unless method_defined?(:eql?) + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + end + + unless method_defined?(:hash) + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end + end end # Emulation option wrapper. diff --git a/lib/wreq_ruby/http.rb b/lib/wreq_ruby/http.rb index 443f5eb..2bf701e 100644 --- a/lib/wreq_ruby/http.rb +++ b/lib/wreq_ruby/http.rb @@ -25,6 +25,43 @@ class Method TRACE = nil # @return [Wreq::Method] HTTP TRACE method PATCH = nil # @return [Wreq::Method] HTTP PATCH method end + + # Returns the HTTP method token (e.g. "GET", "POST"). + # @return [String] + unless method_defined?(:to_s) + def to_s + end + end + + # Returns the HTTP method as a lowercase symbol (e.g. :get, :post). + # @return [Symbol] + unless method_defined?(:to_sym) + def to_sym + end + end + + # Value-based equality. Returns true when both represent the same HTTP method. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:==) + def ==(other) + end + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:eql?) + def eql?(other) + end + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + unless method_defined?(:hash) + def hash + end + end end # HTTP version enumeration backed by Rust. @@ -63,6 +100,21 @@ def to_s def ==(other) end end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:eql?) + def eql?(other) + end + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + unless method_defined?(:hash) + def hash + end + end end # HTTP status code wrapper. @@ -141,6 +193,28 @@ def server_error? # @return [String] Status code as string def to_s end + + # Returns the status code as an integer. + # @return [Integer] the numeric HTTP status code + def to_i + end + + # Value-based equality. Only compares with other StatusCode instances. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end end end end