diff --git a/Cargo.lock b/Cargo.lock index 6ebfec1..9caa026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,6 +1007,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -1014,17 +1015,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_magnus" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ff64c88ddd26acdcad5a501f18bcc339927b77b69f4a03bfaf2a6fc5ba2ac4b" -dependencies = [ - "magnus", - "serde", - "tap", -] - [[package]] name = "shell-words" version = "1.1.1" @@ -1139,12 +1129,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "thiserror" version = "1.0.69" @@ -1550,7 +1534,7 @@ dependencies = [ "rb-sys", "rb-sys-env", "serde", - "serde_magnus", + "serde_json", "tokio", "wreq", "wreq-util", diff --git a/Cargo.toml b/Cargo.toml index 191d72b..b1bc6aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,10 @@ 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_magnus = "0.11.0" +serde_json = { version = "1.0.150", features = [ + "arbitrary_precision", + "preserve_order", +] } indexmap = { version = "2.14.0", features = ["serde"] } cookie = "0.18.1" bytes = "1.12.1" @@ -42,6 +45,9 @@ http = "1.4.1" http-body-util = "0.1.3" futures-util = { version = "0.3.32", default-features = false } +[dev-dependencies] +magnus = { version = "0.8.2", features = ["embed"] } + [build-dependencies] rb-sys-env = "0.2.2" diff --git a/lib/wreq.rb b/lib/wreq.rb index fcefb11..2ae520c 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -48,9 +48,10 @@ module Wreq # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.request(method, url, **options) end @@ -79,9 +80,10 @@ def self.request(method, url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.get(url, **options) end @@ -110,9 +112,10 @@ def self.get(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.head(url, **options) end @@ -141,9 +144,10 @@ def self.head(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.post(url, **options) end @@ -172,9 +176,10 @@ def self.post(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.put(url, **options) end @@ -203,9 +208,10 @@ def self.put(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.delete(url, **options) end @@ -234,9 +240,10 @@ def self.delete(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.options(url, **options) end @@ -265,9 +272,10 @@ def self.options(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.trace(url, **options) end @@ -296,9 +304,10 @@ def self.trace(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def self.patch(url, **options) end end diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index 4008ad0..6908165 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -260,9 +260,10 @@ def self.new(**options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def request(method, url, **options) end @@ -291,9 +292,10 @@ def request(method, url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def get(url, **options) end @@ -322,9 +324,10 @@ def get(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def head(url, **options) end @@ -353,9 +356,10 @@ def head(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def post(url, **options) end @@ -384,9 +388,10 @@ def post(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def put(url, **options) end @@ -415,9 +420,10 @@ def put(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def delete(url, **options) end @@ -446,9 +452,10 @@ def delete(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def options(url, **options) end @@ -477,9 +484,10 @@ def options(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def trace(url, **options) end @@ -508,9 +516,10 @@ def trace(url, **options) # @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 (will be serialized) + # @param json [Object, nil] JSON body; preserves arbitrary-precision Integer values # @param body [String, Wreq::BodySender, nil] Request body bytes or streaming body sender # @return [Wreq::Response] HTTP response + # @raise [Wreq::BuilderError] if json cannot be serialized before network I/O def patch(url, **options) end end diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index aa920cd..fc77eac 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -127,6 +127,9 @@ def text(default_encoding = "UTF-8") # Parse the response body as JSON. # + # Integral JSON numbers are returned as arbitrary-precision Ruby Integer + # values. Fractional and exponent-form numbers are returned as Float values. + # # @return [Object] Parsed JSON (Hash, Array, String, Integer, Float, Boolean, nil) # @raise [Wreq::DecodingError] if body is not valid JSON # @example diff --git a/script/build_windows_gnu.ps1 b/script/build_windows_gnu.ps1 index 047add7..7a3ad72 100644 --- a/script/build_windows_gnu.ps1 +++ b/script/build_windows_gnu.ps1 @@ -32,7 +32,12 @@ function Invoke-Step { ) Write-Host "==> $Name" + # Native command failures do not honor ErrorActionPreference in Windows PowerShell. + $global:LASTEXITCODE = 0 & $Command + if ($LASTEXITCODE -ne 0) { + throw "$Name failed with exit code $LASTEXITCODE." + } } function Get-MissingUcrtTools { @@ -122,6 +127,7 @@ Invoke-Step "Check MSYS2 UCRT build tools" { $stillMissing = @(Get-MissingUcrtTools) if ($stillMissing.Count -eq 0) { Write-Warning "pacman returned exit code $LASTEXITCODE, but all required tools are present; continuing." + $global:LASTEXITCODE = 0 return } diff --git a/src/arch.rs b/src/arch.rs index 7c17a01..d0888e3 100644 --- a/src/arch.rs +++ b/src/arch.rs @@ -4,6 +4,7 @@ //! ABI boundaries, or OS APIs used by the Rust extension. Normal HTTP client //! behavior should stay in the client/runtime modules so platform workarounds //! do not leak into the rest of the binding. +#![allow(unsafe_code)] #[cfg(all(target_os = "windows", target_env = "gnu"))] mod windows_gnu { diff --git a/src/client.rs b/src/client.rs index ac0bc4e..f93aa1e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,10 +6,10 @@ 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 serde::Deserialize; use wreq::Proxy; use crate::{ @@ -21,6 +21,7 @@ use crate::{ gvl, header::{Headers, OrigHeaders, UserAgent}, http::Method, + serde, }; /// A builder for `Client`. @@ -131,7 +132,7 @@ impl Builder { return Ok(Default::default()); }; - let mut builder: Self = serde_magnus::deserialize(ruby, hash)?; + 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()); diff --git a/src/client/body.rs b/src/client/body.rs index e8bbb4a..4b820e0 100644 --- a/src/client/body.rs +++ b/src/client/body.rs @@ -1,6 +1,6 @@ -mod form; -mod json; -mod stream; +pub mod form; +pub mod json; +pub mod stream; use bytes::Bytes; use futures_util::StreamExt; @@ -9,19 +9,13 @@ use magnus::{ typed_data::Obj, }; -pub use self::{ - form::Form, - json::Json, - stream::{BodyReceiver, BodySender, ReceiverStream}, -}; - /// Represents the body of an HTTP request. /// Supports text, bytes, and streaming bodies (Proc/Enumerator). pub enum Body { /// Static bytes body Bytes(Bytes), /// Streaming body - Stream(ReceiverStream), + Stream(stream::ReceiverStream), } impl TryConvert for Body { @@ -30,8 +24,8 @@ impl TryConvert for Body { return Ok(Body::Bytes(s.to_bytes())); } - let obj = Obj::::try_convert(val)?; - let stream = ReceiverStream::try_from(&*obj)?; + let obj = Obj::::try_convert(val)?; + let stream = stream::ReceiverStream::try_from(&*obj)?; Ok(Body::Stream(stream)) } } @@ -50,8 +44,8 @@ impl From for wreq::Body { pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { let sender_class = gem_module.define_class("BodySender", ruby.class_object())?; - sender_class.define_singleton_method("new", function!(BodySender::new, -1))?; - sender_class.define_method("push", method!(BodySender::push, 1))?; - sender_class.define_method("close", magnus::method!(BodySender::close, 0))?; + sender_class.define_singleton_method("new", function!(stream::BodySender::new, -1))?; + sender_class.define_method("push", method!(stream::BodySender::push, 1))?; + sender_class.define_method("close", magnus::method!(stream::BodySender::close, 0))?; Ok(()) } diff --git a/src/client/body/form.rs b/src/client/body/form.rs index 67b4c03..1fd4aac 100644 --- a/src/client/body/form.rs +++ b/src/client/body/form.rs @@ -1,2 +1,4 @@ +//! Form request body conversion. + /// Alias for form parameters. pub type Form = crate::client::param::Params; diff --git a/src/client/body/json.rs b/src/client/body/json.rs index fde14fc..98b4368 100644 --- a/src/client/body/json.rs +++ b/src/client/body/json.rs @@ -1,16 +1,49 @@ -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; +//! JSON conversion at the Ruby request and response boundary. +//! +//! Request values are converted from Ruby into an owned [`Json`] tree before +//! network I/O. Response bodies are deserialized into the same tree by wreq and +//! then converted back into Ruby values. +//! +//! The underlying `serde_json` configuration preserves object insertion order +//! and arbitrary-size integer tokens in both directions. -/// Represents a JSON value for HTTP requests. -/// Supports objects, arrays, numbers, strings, booleans, and null. -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum Json { - Object(IndexMap), - Boolean(bool), - Number(isize), - Float(f64), - String(String), - Null(Option), - Array(Vec), +use ::serde::{Deserialize, Serialize}; +use magnus::{Error, Ruby, TryConvert, Value}; + +use crate::{error::json_serialization_error, serde::deserialize_json}; + +/// An owned JSON tree shared by request and response conversion. +/// +/// This wrapper keeps the configured `serde_json::Value` representation private +/// so callers use the same precision and ordering behavior in both directions. +#[derive(Deserialize, Serialize)] +#[serde(transparent)] +pub struct Json(serde_json::Value); + +/// Convert supported Ruby request values into an owned JSON tree. +/// +/// Supported values are `Hash`, `Array`, `String`, `Symbol`, `Integer`, finite +/// `Float`, booleans, and `nil`. Hash keys must be strings or symbols. Arrays +/// and hashes are limited to 100 nesting levels, which also bounds cyclic input. +/// Unsupported values are reported as `Wreq::BuilderError` before network I/O. +impl TryConvert for Json { + fn try_convert(value: Value) -> Result { + let ruby = Ruby::get_with(value); + deserialize_json(&ruby, value) + .map(Self) + .map_err(json_serialization_error) + } +} + +#[cfg(test)] +mod tests { + use super::Json; + + #[test] + fn preserves_number_precision_and_object_order() { + let source = br#"{"second":115792089237316195423570985008687907853269984665640564039457584007913129639936,"first":1}"#; + let json: Json = serde_json::from_slice(source).unwrap(); + + assert_eq!(source, serde_json::to_vec(&json).unwrap().as_slice()); + } } diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 1146c36..e1780a6 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -1,3 +1,5 @@ +//! Streaming request and response body support. + use std::{ pin::Pin, sync::RwLock, diff --git a/src/client/param.rs b/src/client/param.rs index ef35c7e..166b9bf 100644 --- a/src/client/param.rs +++ b/src/client/param.rs @@ -1,5 +1,5 @@ +use ::serde::{Deserialize, Serialize}; use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; /// Represents HTTP parameters from Python as either a mapping or a sequence of key-value pairs. pub type Params = IndexMap; diff --git a/src/client/req.rs b/src/client/req.rs index ba7f6d5..f08b4a6 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -1,11 +1,11 @@ use std::{net::IpAddr, time::Duration}; +use ::serde::Deserialize; use http::header; use magnus::{RHash, TryConvert, typed_data::Obj, value::ReprValue}; -use serde::Deserialize; use wreq::{Client, Proxy}; -use super::body::{Body, Form, Json}; +use super::body::{Body, form::Form, json::Json}; use crate::{ client::{query::Query, resp::Response}, cookie::Cookies, @@ -14,7 +14,7 @@ use crate::{ extractor::Extractor, header::{Headers, OrigHeaders}, http::{Method, Version}, - rt, + rt, serde, }; /// The parameters for a request. @@ -95,6 +95,7 @@ pub struct Request { form: Option
, /// The JSON body to use for the request. + #[serde(skip)] json: Option, /// The body to use for the request. @@ -106,7 +107,7 @@ impl Request { /// Create a new [`Request`] from Ruby keyword arguments. pub fn new(ruby: &magnus::Ruby, hash: RHash) -> Result { let keyword = hash.as_value(); - let mut builder: Self = serde_magnus::deserialize(ruby, keyword)?; + 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)?; @@ -133,6 +134,10 @@ impl Request { 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(); Ok(builder) diff --git a/src/client/resp.rs b/src/client/resp.rs index ec28291..08c37c4 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -9,7 +9,7 @@ use magnus::{Error, Module, RArray, RModule, Ruby, Value, scan_args::scan_args}; use wreq::Uri; use crate::{ - client::body::{BodyReceiver, Json}, + client::body::{json::Json, stream::BodyReceiver}, cookie::Cookie, error::{memory_error, no_block_given_error, wreq_error_to_magnus}, gvl::{self, nogvl}, @@ -188,7 +188,7 @@ impl Response { 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)?; - serde_magnus::serialize(ruby, &json) + crate::serde::serialize(ruby, &json) } /// Yield response body chunks to the given Ruby block. diff --git a/src/error.rs b/src/error.rs index 3e6896e..4bde681 100644 --- a/src/error.rs +++ b/src/error.rs @@ -122,6 +122,14 @@ pub fn type_value_error_to_magnus(err: &str) -> MagnusError { ) } +/// Build a `Wreq::BuilderError` for unsupported request JSON values. +pub fn json_serialization_error(err: MagnusError) -> MagnusError { + MagnusError::new( + ruby!().get_inner(&BUILDER_ERROR), + format!("JSON serialization error: {err}"), + ) +} + /// Map [`wreq::Error`] to corresponding [`magnus::Error`] pub fn wreq_error_to_magnus(err: wreq::Error) -> MagnusError { let error_msg = err.to_string(); diff --git a/src/header.rs b/src/header.rs index 1d9e982..9e73f8f 100644 --- a/src/header.rs +++ b/src/header.rs @@ -8,8 +8,6 @@ //! //! [RFC 9110 section 5.1]: https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1 -mod helper; - use std::cell::RefCell; use bytes::Bytes; @@ -281,6 +279,122 @@ impl TryConvert for OrigHeaders { } } +mod helper { + //! Ruby value conversion helpers for `Wreq::Headers`. + + 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 super::Headers; + + /// Maximum number of field-value occurrences supported by `HeaderMap`. + const MAX_HEADER_ENTRIES: usize = 1 << 15; + + /// Build a header collection from a Ruby source object. + /// + /// Accepts another `Wreq::Headers`, a Hash, or any object whose `to_a` result + /// 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 { + 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( + "Expected Headers, a Hash, or an enumerable of pairs", + )); + } + + 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") + })?; + if pair.len() != 2 { + return Err(type_value_error_to_magnus( + "Expected each header entry to contain a name and value", + )); + } + + headers.append(pair.entry(0)?, pair.entry(1)?)?; + } + Ok(headers) + } + + /// Convert a Ruby String or Symbol into a normalized HTTP header name. + /// + /// 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 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( + "Expected a String or Symbol header name", + )); + } + }; + HeaderName::from_bytes(name.as_ref()).map_err(header_name_error_to_magnus) + } + + /// Convert a Ruby String or Array of Strings into validated header values. + /// + /// Each Array element becomes one [`HeaderValue`]. An empty Array therefore + /// produces no values, allowing `set` to remove a header and `append` to do + /// nothing. + pub(super) fn parse_header_values(value: Value) -> Result, Error> { + if let Some(values) = RArray::from_value(value) { + values.into_iter().map(parse_header_value).collect() + } else { + Ok(vec![parse_header_value(value)?]) + } + } + + /// Convert one Ruby String into a validated HTTP header value. + /// + /// Invalid Ruby types and bytes rejected by [`HeaderValue`] are mapped to + /// `Wreq::BuilderError`. + fn parse_header_value(value: Value) -> Result { + 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) + } + + /// Validate the resulting number of header occurrences before a mutation. + /// + /// `current` is the collection length, `replaced` is the number of existing + /// occurrences removed by `set`, and `added` is the incoming value count. + /// Checked arithmetic prevents overflow; an invalid calculation or a result + /// above the native [`HeaderMap`](http::HeaderMap) limit returns + /// `Wreq::BuilderError` without mutating the collection. + pub(super) fn ensure_header_count( + current: usize, + replaced: usize, + added: usize, + ) -> Result<(), Error> { + let count = current + .checked_sub(replaced) + .and_then(|count| count.checked_add(added)); + if count.is_some_and(|count| count <= MAX_HEADER_ENTRIES) { + Ok(()) + } else { + Err(header_count_error()) + } + } + + /// 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") + } +} + /// Register `Wreq::Headers` and its native methods with Ruby. pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { let headers_class = gem_module.define_class("Headers", ruby.class_object())?; diff --git a/src/header/helper.rs b/src/header/helper.rs deleted file mode 100644 index 099d989..0000000 --- a/src/header/helper.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Ruby value conversion helpers for `Wreq::Headers`. - -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 super::Headers; - -/// Maximum number of field-value occurrences supported by `HeaderMap`. -const MAX_HEADER_ENTRIES: usize = 1 << 15; - -/// Build a header collection from a Ruby source object. -/// -/// Accepts another `Wreq::Headers`, a Hash, or any object whose `to_a` result -/// 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 { - 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( - "Expected Headers, a Hash, or an enumerable of pairs", - )); - } - - 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"))?; - if pair.len() != 2 { - return Err(type_value_error_to_magnus( - "Expected each header entry to contain a name and value", - )); - } - - headers.append(pair.entry(0)?, pair.entry(1)?)?; - } - Ok(headers) -} - -/// Convert a Ruby String or Symbol into a normalized HTTP header name. -/// -/// 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 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( - "Expected a String or Symbol header name", - )); - } - }; - HeaderName::from_bytes(name.as_ref()).map_err(header_name_error_to_magnus) -} - -/// Convert a Ruby String or Array of Strings into validated header values. -/// -/// Each Array element becomes one [`HeaderValue`]. An empty Array therefore -/// produces no values, allowing `set` to remove a header and `append` to do -/// nothing. -pub(super) fn parse_header_values(value: Value) -> Result, Error> { - if let Some(values) = RArray::from_value(value) { - values.into_iter().map(parse_header_value).collect() - } else { - Ok(vec![parse_header_value(value)?]) - } -} - -/// Convert one Ruby String into a validated HTTP header value. -/// -/// Invalid Ruby types and bytes rejected by [`HeaderValue`] are mapped to -/// `Wreq::BuilderError`. -fn parse_header_value(value: Value) -> Result { - 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) -} - -/// Validate the resulting number of header occurrences before a mutation. -/// -/// `current` is the collection length, `replaced` is the number of existing -/// occurrences removed by `set`, and `added` is the incoming value count. -/// Checked arithmetic prevents overflow; an invalid calculation or a result -/// above the native [`HeaderMap`](http::HeaderMap) limit returns -/// `Wreq::BuilderError` without mutating the collection. -pub(super) fn ensure_header_count( - current: usize, - replaced: usize, - added: usize, -) -> Result<(), Error> { - let count = current - .checked_sub(replaced) - .and_then(|count| count.checked_add(added)); - if count.is_some_and(|count| count <= MAX_HEADER_ENTRIES) { - Ok(()) - } else { - Err(header_count_error()) - } -} - -/// 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") -} diff --git a/src/lib.rs b/src/lib.rs index 5d0db8f..f510a37 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +#![deny(unsafe_code)] #![allow(clippy::wrong_self_convention)] #[macro_use] @@ -12,6 +13,7 @@ mod gvl; mod header; mod http; mod rt; +mod serde; use magnus::{Error, Module, Ruby, Value}; diff --git a/src/serde.rs b/src/serde.rs new file mode 100644 index 0000000..2f900d3 --- /dev/null +++ b/src/serde.rs @@ -0,0 +1,93 @@ +/* +Copyright 2022 George Claghorn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +//! Serde integration for Magnus with JSON-specific conversion behavior. +//! +//! This module is adapted from `serde_magnus` 0.11.0 and retains its generic +//! Ruby serialization and deserialization surface. Local changes add a JSON +//! 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. +#![allow(unsafe_code)] + +mod de; +mod error; +mod ser; +#[cfg(test)] +mod tests; + +use ::serde::{Deserialize, Serialize}; +use magnus::{IntoValue, Ruby, TryConvert}; + +pub(super) use error::Error; + +/// Private map key used to carry arbitrary-precision numbers through Serde. +/// +/// This mirrors the representation used by `serde_json` when its +/// `arbitrary_precision` feature is enabled. The precision tests protect this +/// integration point when `serde_json` is updated. +pub(super) const JSON_NUMBER_TOKEN: &str = "$serde_json::private::Number"; + +/// Maximum nesting accepted while converting a Ruby request value. +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. +pub(crate) fn deserialize_ruby<'de, Input, Output>( + ruby: &Ruby, + input: Input, +) -> Result +where + Input: IntoValue, + Output: Deserialize<'de>, +{ + de::deserialize_ruby(ruby, input.into_value_with(ruby)).map_err(|error| error.into_magnus(ruby)) +} + +/// Serialize any Serde value into a Ruby value. +/// +/// This preserves the public conversion behavior provided by the upstream +/// `serde_magnus::serialize` function. +pub(crate) fn serialize(ruby: &Ruby, input: &Input) -> Result +where + Input: Serialize + ?Sized, + Output: TryConvert, +{ + let value = ser::serialize(ruby, input).map_err(|error| error.into_magnus(ruby))?; + Output::try_convert(value) +} + +/// Deserialize a Ruby value using JSON-specific conversion rules. +pub(crate) fn deserialize_json<'de, Input, Output>( + ruby: &Ruby, + input: Input, +) -> Result +where + Input: IntoValue, + Output: Deserialize<'de>, +{ + de::deserialize_json(ruby, input.into_value_with(ruby)).map_err(|error| error.into_magnus(ruby)) +} diff --git a/src/serde/de.rs b/src/serde/de.rs new file mode 100644 index 0000000..7e2e0ce --- /dev/null +++ b/src/serde/de.rs @@ -0,0 +1,32 @@ +mod array_deserializer; +mod array_enumerator; +mod deserializer; +mod enum_deserializer; +mod hash_deserializer; +mod number_deserializer; +mod variant_deserializer; + +use ::serde::Deserialize; +use magnus::{Ruby, Value}; + +use super::Error; +use array_deserializer::ArrayDeserializer; +use deserializer::{Deserializer, Mode}; +use hash_deserializer::HashDeserializer; +use variant_deserializer::VariantDeserializer; + +/// Deserialize one Ruby value using native Ruby data model semantics. +pub(super) fn deserialize_ruby<'de, Output>(ruby: &Ruby, value: Value) -> Result +where + Output: Deserialize<'de>, +{ + Output::deserialize(Deserializer::new_ruby(ruby, value)) +} + +/// Deserialize one Ruby value using JSON-specific conversion rules. +pub(super) fn deserialize_json<'de, Output>(ruby: &Ruby, value: Value) -> Result +where + Output: Deserialize<'de>, +{ + Output::deserialize(Deserializer::new_json(ruby, value)) +} diff --git a/src/serde/de/array_deserializer.rs b/src/serde/de/array_deserializer.rs new file mode 100644 index 0000000..fecb3e9 --- /dev/null +++ b/src/serde/de/array_deserializer.rs @@ -0,0 +1,48 @@ +use ::serde::de::{DeserializeSeed, SeqAccess}; +use magnus::{RArray, Ruby}; + +use super::{Deserializer, Mode, array_enumerator::ArrayEnumerator}; +use crate::serde::Error; + +/// Serde sequence access over a Ruby array. +pub(super) struct ArrayDeserializer<'ruby> { + ruby: &'ruby Ruby, + entries: ArrayEnumerator<'ruby>, + depth: usize, + mode: Mode, +} + +impl<'ruby> ArrayDeserializer<'ruby> { + /// Create sequence access at the supplied JSON nesting depth. + pub(super) fn new(ruby: &'ruby Ruby, array: RArray, depth: usize, mode: Mode) -> Self { + Self { + ruby, + entries: ArrayEnumerator::new(ruby, array), + depth, + mode, + } + } +} + +impl<'de> SeqAccess<'de> for ArrayDeserializer<'_> { + type Error = Error; + + fn next_element_seed(&mut self, seed: Seed) -> Result, Self::Error> + where + Seed: DeserializeSeed<'de>, + { + match self.entries.next() { + Some(Ok(entry)) => seed + .deserialize(Deserializer::with_mode( + self.ruby, entry, self.depth, self.mode, + )) + .map(Some), + Some(Err(error)) => Err(error), + None => Ok(None), + } + } + + fn size_hint(&self) -> Option { + Some(self.entries.remaining()) + } +} diff --git a/src/serde/de/array_enumerator.rs b/src/serde/de/array_enumerator.rs new file mode 100644 index 0000000..1f1d67f --- /dev/null +++ b/src/serde/de/array_enumerator.rs @@ -0,0 +1,59 @@ +use magnus::{RArray, Ruby, Value}; + +use super::super::Error; + +/// Index-based Ruby array iterator that avoids Ruby Enumerator fiber overhead. +pub(super) struct ArrayEnumerator<'ruby> { + ruby: &'ruby Ruby, + array: RArray, + index: usize, +} + +impl<'ruby> ArrayEnumerator<'ruby> { + /// Create an iterator over a Ruby array. + pub(super) fn new(ruby: &'ruby Ruby, array: RArray) -> Self { + Self { + ruby, + array, + index: 0, + } + } + + /// Return the number of entries that have not been yielded. + pub(super) fn remaining(&self) -> usize { + self.array.len().saturating_sub(self.index) + } + + /// Return the current array entry without advancing the iterator. + fn current(&self) -> Result, Error> { + if self.index >= self.array.len() { + return Ok(None); + } + + let index = isize::try_from(self.index).map_err(|_| { + Error::from(magnus::Error::new( + self.ruby.exception_range_error(), + "array index out of range", + )) + })?; + self.array.entry(index).map(Some).map_err(Into::into) + } +} + +impl Iterator for ArrayEnumerator<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + match self.current() { + Ok(Some(value)) => { + self.index = match self.index.checked_add(1) { + Some(index) => index, + None => return Some(Err(Error::message("array index overflow"))), + }; + Some(Ok(value)) + } + Ok(None) => None, + Err(error) => Some(Err(error)), + } + } +} diff --git a/src/serde/de/deserializer.rs b/src/serde/de/deserializer.rs new file mode 100644 index 0000000..ed0b560 --- /dev/null +++ b/src/serde/de/deserializer.rs @@ -0,0 +1,272 @@ +use ::serde::forward_to_deserialize_any; +use magnus::{ + Fixnum, Float, Integer, RArray, RBignum, RHash, RString, Ruby, Symbol, Value, + value::{Qfalse, Qtrue, ReprValue}, +}; + +use super::super::{Error, MAX_JSON_NESTING}; +use super::{ + array_deserializer::ArrayDeserializer, enum_deserializer::EnumDeserializer, + hash_deserializer::HashDeserializer, number_deserializer::NumberDeserializer, +}; + +/// Implement typed Serde integer entry points with Magnus's checked conversions. +macro_rules! impl_deserialize_integers { + ($($method:ident => ($visit:ident, $convert:ident)),+ $(,)?) => { + $( + fn $method(self, visitor: Visitor) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + match Integer::from_value(self.value) { + Some(value) => visitor.$visit(value.$convert()?), + None => self.deserialize_any(visitor), + } + } + )+ + }; +} + +/// Data model applied to a Ruby value during deserialization. +#[derive(Clone, Copy)] +pub(super) enum Mode { + /// Preserve the native Ruby-to-Serde conversion behavior. + Ruby, + /// Enforce the JSON data model and preserve arbitrary-size numbers. + Json, +} + +impl Mode { + /// Return whether JSON-specific validation is enabled. + fn is_json(self) -> bool { + matches!(self, Self::Json) + } +} + +/// Serde deserializer over Ruby values. +pub(super) struct Deserializer<'ruby> { + ruby: &'ruby Ruby, + value: Value, + depth: usize, + mode: Mode, +} + +impl<'ruby> Deserializer<'ruby> { + /// Create a deserializer with native `serde_magnus` Ruby behavior. + pub(super) fn new_ruby(ruby: &'ruby Ruby, value: Value) -> Self { + Self::with_mode(ruby, value, 0, Mode::Ruby) + } + + /// 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) + } + + /// Create a nested deserializer that inherits its conversion mode. + pub(super) fn with_mode(ruby: &'ruby Ruby, value: Value, depth: usize, mode: Mode) -> Self { + Self { + ruby, + value, + depth, + mode, + } + } + + /// Validate and return the depth used by a nested container. + pub(super) fn nested_depth(&self) -> Result { + let depth = self + .depth + .checked_add(1) + .ok_or_else(|| Error::message("JSON nesting depth overflow"))?; + if self.mode.is_json() && depth > MAX_JSON_NESTING { + Err(Error::message(format!( + "JSON nesting exceeds {MAX_JSON_NESTING} levels" + ))) + } else { + Ok(depth) + } + } +} + +impl<'de> ::serde::Deserializer<'de> for Deserializer<'_> { + type Error = Error; + + fn deserialize_any(self, visitor: Visitor) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + if self.value.is_nil() { + return visitor.visit_unit(); + } + + if let Some(value) = Qtrue::from_value(self.value) { + return visitor.visit_bool(value.to_bool()); + } + + if let Some(value) = Qfalse::from_value(self.value) { + return visitor.visit_bool(value.to_bool()); + } + + if let Some(value) = Fixnum::from_value(self.value) { + return visitor.visit_i64(value.to_i64()); + } + + if let Some(value) = RBignum::from_value(self.value) { + if self.mode.is_json() { + let source: String = value.funcall_public("to_s", ())?; + return visitor.visit_map(NumberDeserializer::new(source)); + } + + return visitor.visit_i64(value.to_i64()?); + } + + if let Some(value) = Float::from_value(self.value) { + let value = value.to_f64(); + if self.mode.is_json() && !value.is_finite() { + return Err(Error::message("non-finite Float values are not valid JSON")); + } + + return visitor.visit_f64(value); + } + + 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 visitor.visit_string(value.name()?.into_owned()); + } + + if let Some(value) = RArray::from_value(self.value) { + let depth = self.nested_depth()?; + return visitor.visit_seq(ArrayDeserializer::new(self.ruby, value, depth, self.mode)); + } + + if let Some(value) = RHash::from_value(self.value) { + let depth = self.nested_depth()?; + return visitor.visit_map(HashDeserializer::new(self.ruby, value, depth, self.mode)?); + } + + Err(Error::type_error(format!( + "can't deserialize {}", + // SAFETY: conversion runs while the Ruby GVL is held. + unsafe { self.value.classname() } + ))) + } + + fn deserialize_bytes(self, _visitor: Visitor) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + Err(Error::type_error("can't deserialize into byte slice")) + } + + fn deserialize_byte_buf(self, visitor: Visitor) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + if let Some(string) = RString::from_value(self.value) { + // SAFETY: the bytes are copied before any further Ruby API call. + visitor.visit_byte_buf(unsafe { string.as_slice() }.to_owned()) + } else { + Err(Error::type_error(format!( + "no implicit conversion of {} to String", + // SAFETY: conversion runs while the Ruby GVL is held. + unsafe { self.value.classname() } + ))) + } + } + + fn deserialize_option(self, visitor: Visitor) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + if self.value.is_nil() { + visitor.visit_none() + } else { + visitor.visit_some(self) + } + } + + fn deserialize_enum( + self, + _name: &'static str, + _variants: &'static [&'static str], + visitor: Visitor, + ) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + if let Some(variant) = RString::from_value(self.value) { + return visitor.visit_enum(EnumDeserializer::new( + self.ruby, + variant.to_string()?, + self.ruby.qnil().as_value(), + self.depth, + self.mode, + )); + } + + if let Some(hash) = RHash::from_value(self.value) { + if hash.len() == 1 { + let keys: RArray = hash.funcall("keys", ())?; + let key: String = keys.entry(0)?; + let value = hash + .get(key.as_str()) + .unwrap_or_else(|| self.ruby.qnil().as_value()); + return visitor.visit_enum(EnumDeserializer::new( + self.ruby, key, value, self.depth, self.mode, + )); + } + return Err(Error::type_error(format!( + "can't deserialize Hash of length {} to Enum", + hash.len() + ))); + } + + Err(Error::type_error(format!( + "can't deserialize {} to Enum", + // SAFETY: conversion runs while the Ruby GVL is held. + unsafe { self.value.classname() } + ))) + } + + fn deserialize_newtype_struct( + self, + _name: &'static str, + visitor: Visitor, + ) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + visitor.visit_newtype_struct(self) + } + + fn deserialize_ignored_any( + self, + visitor: Visitor, + ) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + visitor.visit_unit() + } + + impl_deserialize_integers! { + deserialize_i8 => (visit_i8, to_i8), + deserialize_i16 => (visit_i16, to_i16), + deserialize_i32 => (visit_i32, to_i32), + deserialize_i64 => (visit_i64, to_i64), + deserialize_i128 => (visit_i128, to_i128), + deserialize_u8 => (visit_u8, to_u8), + deserialize_u16 => (visit_u16, to_u16), + deserialize_u32 => (visit_u32, to_u32), + deserialize_u64 => (visit_u64, to_u64), + deserialize_u128 => (visit_u128, to_u128), + } + + forward_to_deserialize_any! { + > + bool f32 f64 char str string unit unit_struct seq tuple tuple_struct map struct identifier + } +} diff --git a/src/serde/de/enum_deserializer.rs b/src/serde/de/enum_deserializer.rs new file mode 100644 index 0000000..f759c04 --- /dev/null +++ b/src/serde/de/enum_deserializer.rs @@ -0,0 +1,47 @@ +use ::serde::de::{DeserializeSeed, EnumAccess, value::StringDeserializer}; +use magnus::{Ruby, Value}; + +use super::{Mode, VariantDeserializer}; +use crate::serde::Error; + +/// Serde enum access over a Ruby string or one-entry hash. +pub(super) struct EnumDeserializer<'ruby> { + ruby: &'ruby Ruby, + variant: String, + value: Value, + depth: usize, + mode: Mode, +} + +impl<'ruby> EnumDeserializer<'ruby> { + /// Create enum access for a variant and its associated Ruby value. + pub(super) fn new( + ruby: &'ruby Ruby, + variant: String, + value: Value, + depth: usize, + mode: Mode, + ) -> Self { + Self { + ruby, + variant, + value, + depth, + mode, + } + } +} + +impl<'ruby, 'de> EnumAccess<'de> for EnumDeserializer<'ruby> { + type Variant = VariantDeserializer<'ruby>; + type Error = Error; + + fn variant_seed(self, seed: Seed) -> Result<(Seed::Value, Self::Variant), Self::Error> + where + Seed: DeserializeSeed<'de>, + { + let variant = VariantDeserializer::new(self.ruby, self.value, self.depth, self.mode); + seed.deserialize(StringDeserializer::::new(self.variant)) + .map(|value| (value, variant)) + } +} diff --git a/src/serde/de/hash_deserializer.rs b/src/serde/de/hash_deserializer.rs new file mode 100644 index 0000000..5a2dfe7 --- /dev/null +++ b/src/serde/de/hash_deserializer.rs @@ -0,0 +1,85 @@ +use std::iter::Peekable; + +use ::serde::de::{DeserializeSeed, MapAccess}; +use magnus::{RHash, RString, Ruby, Symbol, Value, value::ReprValue}; + +use super::{Deserializer, Mode, array_enumerator::ArrayEnumerator}; +use crate::serde::Error; + +/// Serde map access over a Ruby hash. +pub(super) struct HashDeserializer<'ruby> { + ruby: &'ruby Ruby, + hash: RHash, + keys: Peekable>, + depth: usize, + mode: Mode, +} + +impl<'ruby> HashDeserializer<'ruby> { + /// Create map access while preserving Ruby hash insertion order. + pub(super) fn new( + ruby: &'ruby Ruby, + hash: RHash, + depth: usize, + mode: Mode, + ) -> Result { + let keys = hash.funcall("keys", ())?; + Ok(Self { + ruby, + hash, + keys: ArrayEnumerator::new(ruby, keys).peekable(), + depth, + mode, + }) + } + + /// Reject object keys that JSON cannot represent. + fn validate_key(key: Value) -> Result<(), Error> { + if RString::from_value(key).is_some() || Symbol::from_value(key).is_some() { + Ok(()) + } else { + Err(Error::message( + "JSON object keys must be String or Symbol values", + )) + } + } +} + +impl<'de> MapAccess<'de> for HashDeserializer<'_> { + type Error = Error; + + fn next_key_seed(&mut self, seed: Seed) -> Result, Self::Error> + where + Seed: DeserializeSeed<'de>, + { + match self.keys.peek() { + Some(Ok(key)) => { + if matches!(self.mode, Mode::Json) { + Self::validate_key(*key)?; + } + seed.deserialize(Deserializer::with_mode( + self.ruby, *key, self.depth, self.mode, + )) + .map(Some) + } + Some(Err(error)) => Err(Error::message(format!("failed to read map key: {error}"))), + None => Ok(None), + } + } + + fn next_value_seed(&mut self, seed: Seed) -> Result + where + Seed: DeserializeSeed<'de>, + { + match self.keys.next() { + Some(Ok(key)) => seed.deserialize(Deserializer::with_mode( + self.ruby, + self.hash.aref(key)?, + self.depth, + self.mode, + )), + Some(Err(error)) => Err(error), + None => Err(Error::message("map value has no matching key")), + } + } +} diff --git a/src/serde/de/number_deserializer.rs b/src/serde/de/number_deserializer.rs new file mode 100644 index 0000000..5df5067 --- /dev/null +++ b/src/serde/de/number_deserializer.rs @@ -0,0 +1,51 @@ +use ::serde::de::{ + DeserializeSeed, MapAccess, + value::{BorrowedStrDeserializer, StringDeserializer}, +}; + +use super::super::{Error, JSON_NUMBER_TOKEN}; + +/// Serde map representation used by `serde_json` for arbitrary-precision numbers. +pub(super) struct NumberDeserializer { + source: Option, +} + +impl NumberDeserializer { + /// Create a one-entry number map from a Ruby Integer decimal string. + pub(super) fn new(source: String) -> Self { + Self { + source: Some(source), + } + } +} + +impl<'de> MapAccess<'de> for NumberDeserializer { + type Error = Error; + + fn next_key_seed(&mut self, seed: Seed) -> Result, Self::Error> + where + Seed: DeserializeSeed<'de>, + { + if self.source.is_none() { + return Ok(None); + } + + seed.deserialize(BorrowedStrDeserializer::::new(JSON_NUMBER_TOKEN)) + .map(Some) + } + + fn next_value_seed(&mut self, seed: Seed) -> Result + where + Seed: DeserializeSeed<'de>, + { + let source = self + .source + .take() + .ok_or_else(|| Error::message("JSON number value is missing"))?; + seed.deserialize(StringDeserializer::::new(source)) + } + + fn size_hint(&self) -> Option { + Some(usize::from(self.source.is_some())) + } +} diff --git a/src/serde/de/variant_deserializer.rs b/src/serde/de/variant_deserializer.rs new file mode 100644 index 0000000..c0e841a --- /dev/null +++ b/src/serde/de/variant_deserializer.rs @@ -0,0 +1,101 @@ +use ::serde::de::{DeserializeSeed, Unexpected, VariantAccess}; +use magnus::{RArray, RHash, Ruby, Value, value::ReprValue}; + +use super::{ArrayDeserializer, Deserializer, HashDeserializer, Mode}; +use crate::serde::Error; + +/// Serde access to the payload of a Ruby enum representation. +pub(super) struct VariantDeserializer<'ruby> { + ruby: &'ruby Ruby, + value: Value, + depth: usize, + mode: Mode, +} + +impl<'ruby> VariantDeserializer<'ruby> { + /// Create variant access for a Ruby payload. + pub(super) fn new(ruby: &'ruby Ruby, value: Value, depth: usize, mode: Mode) -> Self { + Self { + ruby, + value, + depth, + mode, + } + } + + /// Return the depth assigned to a container payload. + fn nested_depth(&self) -> Result { + Deserializer::with_mode(self.ruby, self.value, self.depth, self.mode).nested_depth() + } +} + +impl<'de> VariantAccess<'de> for VariantDeserializer<'_> { + type Error = Error; + + fn unit_variant(self) -> Result<(), Self::Error> { + if self.value.is_nil() { + Ok(()) + } else { + Err(::serde::de::Error::invalid_type( + Unexpected::Other( + // SAFETY: conversion runs while the Ruby GVL is held. + &unsafe { self.value.classname() }, + ), + &"unit variant", + )) + } + } + + fn newtype_variant_seed(self, seed: Seed) -> Result + where + Seed: DeserializeSeed<'de>, + { + seed.deserialize(Deserializer::with_mode( + self.ruby, self.value, self.depth, self.mode, + )) + } + + fn tuple_variant( + self, + _len: usize, + visitor: Visitor, + ) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + if let Some(array) = RArray::from_value(self.value) { + let depth = self.nested_depth()?; + visitor.visit_seq(ArrayDeserializer::new(self.ruby, array, depth, self.mode)) + } else { + Err(::serde::de::Error::invalid_type( + Unexpected::Other( + // SAFETY: conversion runs while the Ruby GVL is held. + &unsafe { self.value.classname() }, + ), + &"tuple variant", + )) + } + } + + fn struct_variant( + self, + _fields: &'static [&'static str], + visitor: Visitor, + ) -> Result + where + Visitor: ::serde::de::Visitor<'de>, + { + if let Some(hash) = RHash::from_value(self.value) { + let depth = self.nested_depth()?; + visitor.visit_map(HashDeserializer::new(self.ruby, hash, depth, self.mode)?) + } else { + Err(::serde::de::Error::invalid_type( + Unexpected::Other( + // SAFETY: conversion runs while the Ruby GVL is held. + &unsafe { self.value.classname() }, + ), + &"struct variant", + )) + } + } +} diff --git a/src/serde/error.rs b/src/serde/error.rs new file mode 100644 index 0000000..881f36d --- /dev/null +++ b/src/serde/error.rs @@ -0,0 +1,74 @@ +use std::fmt; + +/// Error produced by the local Ruby and Serde bridge. +#[derive(Debug)] +pub(crate) enum Error { + Runtime(String), + Type(String), + Ruby(magnus::Error), +} + +impl Error { + /// Create a bridge error from a human-readable message. + pub(super) fn message(message: impl Into) -> Self { + Self::Runtime(message.into()) + } + + /// Create a type mismatch error. + pub(super) fn type_error(message: impl Into) -> Self { + Self::Type(message.into()) + } + + /// Convert this bridge error into the matching Ruby exception. + pub(super) fn into_magnus(self, ruby: &magnus::Ruby) -> magnus::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::Ruby(error) => error, + } + } +} + +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::Ruby(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for Error {} + +impl ::serde::ser::Error for Error { + fn custom(message: T) -> Self + where + T: fmt::Display, + { + Self::message(message.to_string()) + } +} + +impl ::serde::de::Error for Error { + fn custom(message: T) -> Self + where + T: fmt::Display, + { + Self::message(message.to_string()) + } + + fn invalid_type( + unexpected: ::serde::de::Unexpected<'_>, + expected: &dyn ::serde::de::Expected, + ) -> Self { + Self::type_error(format!( + "invalid type: expected {expected}, got {unexpected}" + )) + } +} + +impl From for Error { + fn from(error: magnus::Error) -> Self { + Self::Ruby(error) + } +} diff --git a/src/serde/ser.rs b/src/serde/ser.rs new file mode 100644 index 0000000..8d92909 --- /dev/null +++ b/src/serde/ser.rs @@ -0,0 +1,18 @@ +mod enums; +mod map_serializer; +mod seq_serializer; +mod serializer; +mod struct_serializer; +mod struct_variant_serializer; +mod tuple_variant_serializer; + +use ::serde::Serialize; +use magnus::{Ruby, Value}; + +use super::Error; +use serializer::Serializer; + +/// Serialize any Serde value into Ruby values. +pub(super) fn serialize(ruby: &Ruby, value: &(impl Serialize + ?Sized)) -> Result { + value.serialize(Serializer::new(ruby)) +} diff --git a/src/serde/ser/enums.rs b/src/serde/ser/enums.rs new file mode 100644 index 0000000..abae6f2 --- /dev/null +++ b/src/serde/ser/enums.rs @@ -0,0 +1,14 @@ +use magnus::{IntoValue, Ruby, Value}; + +use crate::serde::Error; + +/// Wrap serialized enum data in the one-entry hash used by `serde_magnus`. +pub(super) fn nest( + ruby: &Ruby, + variant: &'static str, + data: impl IntoValue, +) -> Result { + let hash = ruby.hash_new(); + hash.aset(variant, data)?; + Ok(hash.into_value_with(ruby)) +} diff --git a/src/serde/ser/map_serializer.rs b/src/serde/ser/map_serializer.rs new file mode 100644 index 0000000..9923bc9 --- /dev/null +++ b/src/serde/ser/map_serializer.rs @@ -0,0 +1,56 @@ +use ::serde::{Serialize, ser::SerializeMap}; +use magnus::{IntoValue, RHash, Ruby, Value}; + +use super::Serializer; +use crate::serde::Error; + +/// Serde map serializer backed by a Ruby hash. +pub(super) struct MapSerializer<'ruby> { + ruby: &'ruby Ruby, + hash: RHash, + key: Option, +} + +impl<'ruby> MapSerializer<'ruby> { + /// Create a map serializer for an allocated Ruby hash. + pub(super) fn new(ruby: &'ruby Ruby, hash: RHash) -> Self { + Self { + ruby, + hash, + key: None, + } + } +} + +impl SerializeMap for MapSerializer<'_> { + type Ok = Value; + type Error = Error; + + fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + self.key = Some(key.serialize(Serializer::new(self.ruby))?); + Ok(()) + } + + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + let key = self + .key + .take() + .ok_or_else(|| Error::message("map value has no matching key"))?; + self.hash + .aset(key, value.serialize(Serializer::new(self.ruby))?) + .map_err(Into::into) + } + + fn end(self) -> Result { + if self.key.is_some() { + return Err(Error::message("map key has no matching value")); + } + Ok(self.hash.into_value_with(self.ruby)) + } +} diff --git a/src/serde/ser/seq_serializer.rs b/src/serde/ser/seq_serializer.rs new file mode 100644 index 0000000..08a8418 --- /dev/null +++ b/src/serde/ser/seq_serializer.rs @@ -0,0 +1,71 @@ +use ::serde::{ + Serialize, + ser::{SerializeSeq, SerializeTuple, SerializeTupleStruct}, +}; +use magnus::{IntoValue, RArray, Ruby, Value}; + +use super::Serializer; +use crate::serde::Error; + +/// Serde sequence serializer backed by a Ruby array. +pub(super) struct SeqSerializer<'ruby> { + ruby: &'ruby Ruby, + array: RArray, +} + +impl<'ruby> SeqSerializer<'ruby> { + /// Create a sequence serializer for an allocated Ruby array. + pub(super) fn new(ruby: &'ruby Ruby, array: RArray) -> Self { + Self { ruby, array } + } +} + +impl SerializeSeq for SeqSerializer<'_> { + type Ok = Value; + type Error = Error; + + fn serialize_element(&mut self, element: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + self.array + .push(element.serialize(Serializer::new(self.ruby))?) + .map_err(Into::into) + } + + fn end(self) -> Result { + Ok(self.array.into_value_with(self.ruby)) + } +} + +impl SerializeTuple for SeqSerializer<'_> { + type Ok = Value; + type Error = Error; + + fn serialize_element(&mut self, element: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + SerializeSeq::serialize_element(self, element) + } + + fn end(self) -> Result { + SerializeSeq::end(self) + } +} + +impl SerializeTupleStruct for SeqSerializer<'_> { + type Ok = Value; + type Error = Error; + + fn serialize_field(&mut self, field: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + SerializeSeq::serialize_element(self, field) + } + + fn end(self) -> Result { + SerializeSeq::end(self) + } +} diff --git a/src/serde/ser/serializer.rs b/src/serde/ser/serializer.rs new file mode 100644 index 0000000..f0559fa --- /dev/null +++ b/src/serde/ser/serializer.rs @@ -0,0 +1,194 @@ +use ::serde::Serialize; +use magnus::{IntoValue, Ruby, Value}; + +use super::super::Error; +use super::{ + enums::nest, map_serializer::MapSerializer, seq_serializer::SeqSerializer, + struct_serializer::StructSerializer, struct_variant_serializer::StructVariantSerializer, + tuple_variant_serializer::TupleVariantSerializer, +}; + +/// Implement primitive numeric serialization through Magnus's `IntoValue`. +macro_rules! impl_serialize_numbers { + ($($method:ident => $type:ty),+ $(,)?) => { + $( + fn $method(self, value: $type) -> Result { + Ok(value.into_value_with(self.ruby)) + } + )+ + }; +} + +/// Serde serializer that creates Ruby values. +pub(super) struct Serializer<'ruby> { + ruby: &'ruby Ruby, +} + +impl<'ruby> Serializer<'ruby> { + /// Create a serializer with upstream `serde_magnus` behavior. + pub(super) fn new(ruby: &'ruby Ruby) -> Self { + Self { ruby } + } +} + +impl<'ruby> ::serde::Serializer for Serializer<'ruby> { + type Ok = Value; + type Error = Error; + + type SerializeSeq = SeqSerializer<'ruby>; + type SerializeTuple = SeqSerializer<'ruby>; + type SerializeTupleStruct = SeqSerializer<'ruby>; + type SerializeTupleVariant = TupleVariantSerializer<'ruby>; + type SerializeMap = MapSerializer<'ruby>; + type SerializeStruct = StructSerializer<'ruby>; + type SerializeStructVariant = StructVariantSerializer<'ruby>; + + fn serialize_bool(self, value: bool) -> Result { + Ok(value.into_value_with(self.ruby)) + } + + impl_serialize_numbers! { + serialize_i8 => i8, + serialize_i16 => i16, + serialize_i32 => i32, + serialize_i64 => i64, + serialize_i128 => i128, + serialize_u8 => u8, + serialize_u16 => u16, + serialize_u32 => u32, + serialize_u64 => u64, + serialize_u128 => u128, + serialize_f32 => f32, + serialize_f64 => f64, + } + + fn serialize_char(self, value: char) -> Result { + Ok(value.into_value_with(self.ruby)) + } + + fn serialize_str(self, value: &str) -> Result { + Ok(value.into_value_with(self.ruby)) + } + + fn serialize_bytes(self, value: &[u8]) -> Result { + Ok(self.ruby.str_from_slice(value).into_value_with(self.ruby)) + } + + fn serialize_none(self) -> Result { + self.serialize_unit() + } + + fn serialize_some(self, value: &T) -> Result + where + T: Serialize + ?Sized, + { + value.serialize(self) + } + + fn serialize_unit(self) -> Result { + Ok(().into_value_with(self.ruby)) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + self.serialize_unit() + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _index: u32, + variant: &'static str, + ) -> Result { + self.serialize_str(variant) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result + where + T: Serialize + ?Sized, + { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _index: u32, + variant: &'static str, + value: &T, + ) -> Result + where + T: Serialize + ?Sized, + { + nest( + self.ruby, + variant, + value.serialize(Serializer::new(self.ruby))?, + ) + } + + fn serialize_seq(self, len: Option) -> Result { + Ok(SeqSerializer::new( + self.ruby, + self.ruby.ary_new_capa(len.unwrap_or(0)), + )) + } + + fn serialize_tuple(self, len: usize) -> Result { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + len: usize, + ) -> Result { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _index: u32, + variant: &'static str, + len: usize, + ) -> Result { + Ok(TupleVariantSerializer::new( + self.ruby, + variant, + self.ruby.ary_new_capa(len), + )) + } + + fn serialize_map(self, len: Option) -> Result { + Ok(MapSerializer::new( + self.ruby, + self.ruby.hash_new_capa(len.unwrap_or(0)), + )) + } + + fn serialize_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + Ok(StructSerializer::new(self.ruby, name, len)) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _index: u32, + variant: &'static str, + len: usize, + ) -> Result { + Ok(StructVariantSerializer::new( + self.ruby, + variant, + self.ruby.hash_new_capa(len), + )) + } +} diff --git a/src/serde/ser/struct_serializer.rs b/src/serde/ser/struct_serializer.rs new file mode 100644 index 0000000..94d0ec4 --- /dev/null +++ b/src/serde/ser/struct_serializer.rs @@ -0,0 +1,106 @@ +use ::serde::{Serialize, ser::SerializeStruct}; +use magnus::{Integer, IntoValue, RHash, RString, Ruby, Value, value::ReprValue}; + +use super::Serializer; +use crate::serde::{Error, JSON_NUMBER_TOKEN}; + +/// Serde struct serializer, including `serde_json` arbitrary-precision numbers. +pub(super) enum StructSerializer<'ruby> { + Map { + ruby: &'ruby Ruby, + hash: RHash, + }, + Number { + ruby: &'ruby Ruby, + value: Option, + }, +} + +impl<'ruby> StructSerializer<'ruby> { + /// Create the serializer selected by the Serde struct name. + pub(super) fn new(ruby: &'ruby Ruby, name: &'static str, len: usize) -> Self { + if name == JSON_NUMBER_TOKEN { + Self::Number { ruby, value: None } + } else { + Self::Map { + ruby, + hash: ruby.hash_new_capa(len), + } + } + } +} + +impl SerializeStruct for StructSerializer<'_> { + type Ok = Value; + type Error = Error; + + fn serialize_field(&mut self, name: &'static str, value: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + match self { + Self::Map { ruby, hash } => hash + .aset( + ruby.to_symbol(name), + value.serialize(Serializer::new(ruby))?, + ) + .map_err(Into::into), + Self::Number { + ruby, + value: output, + } => { + if name != JSON_NUMBER_TOKEN { + return Err(Error::message("invalid arbitrary-precision number field")); + } + + if output.is_some() { + return Err(Error::message( + "arbitrary-precision number has duplicate fields", + )); + } + + let source = value.serialize(Serializer::new(ruby))?; + let source = RString::from_value(source) + .ok_or_else(|| Error::message("JSON number token must be a String"))? + .to_string()?; + *output = Some(number_to_ruby(ruby, &source)?); + Ok(()) + } + } + } + + fn end(self) -> Result { + match self { + Self::Map { ruby, hash } => Ok(hash.into_value_with(ruby)), + Self::Number { value, .. } => { + value.ok_or_else(|| Error::message("JSON number token is missing")) + } + } + } +} + +/// Convert a validated JSON number token into a Ruby Integer or Float. +fn number_to_ruby(ruby: &Ruby, source: &str) -> Result { + if is_integral_number(source) { + integer_to_ruby(ruby, source) + } else { + let value = source.parse::().map_err(|error| { + Error::message(format!("failed to convert JSON number {source}: {error}")) + })?; + Ok(ruby.float_from_f64(value).as_value()) + } +} + +/// Convert a decimal integer token into an arbitrary-precision Ruby Integer. +fn integer_to_ruby(ruby: &Ruby, source: &str) -> Result { + let source = ruby.str_new(source); + let value: Integer = ruby.module_kernel().funcall("Integer", (source, 10))?; + Ok(value.as_value()) +} + +/// Return whether a validated JSON number token is integral. +fn is_integral_number(source: &str) -> bool { + !source + .bytes() + .any(|byte| matches!(byte, b'.' | b'e' | b'E')) +} diff --git a/src/serde/ser/struct_variant_serializer.rs b/src/serde/ser/struct_variant_serializer.rs new file mode 100644 index 0000000..4557615 --- /dev/null +++ b/src/serde/ser/struct_variant_serializer.rs @@ -0,0 +1,44 @@ +use ::serde::{Serialize, ser::SerializeStructVariant}; +use magnus::{RHash, Ruby, Value}; + +use super::{Serializer, enums::nest}; +use crate::serde::Error; + +/// Serde struct-variant serializer backed by a Ruby hash. +pub(super) struct StructVariantSerializer<'ruby> { + ruby: &'ruby Ruby, + variant: &'static str, + hash: RHash, +} + +impl<'ruby> StructVariantSerializer<'ruby> { + /// Create a struct-variant serializer for an allocated Ruby hash. + pub(super) fn new(ruby: &'ruby Ruby, variant: &'static str, hash: RHash) -> Self { + Self { + ruby, + variant, + hash, + } + } +} + +impl SerializeStructVariant for StructVariantSerializer<'_> { + type Ok = Value; + type Error = Error; + + fn serialize_field(&mut self, name: &'static str, value: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + self.hash + .aset( + self.ruby.to_symbol(name), + value.serialize(Serializer::new(self.ruby))?, + ) + .map_err(Into::into) + } + + fn end(self) -> Result { + nest(self.ruby, self.variant, self.hash) + } +} diff --git a/src/serde/ser/tuple_variant_serializer.rs b/src/serde/ser/tuple_variant_serializer.rs new file mode 100644 index 0000000..5c36f24 --- /dev/null +++ b/src/serde/ser/tuple_variant_serializer.rs @@ -0,0 +1,41 @@ +use ::serde::{Serialize, ser::SerializeTupleVariant}; +use magnus::{RArray, Ruby, Value}; + +use super::{Serializer, enums::nest}; +use crate::serde::Error; + +/// Serde tuple-variant serializer backed by a Ruby array. +pub(super) struct TupleVariantSerializer<'ruby> { + ruby: &'ruby Ruby, + variant: &'static str, + array: RArray, +} + +impl<'ruby> TupleVariantSerializer<'ruby> { + /// Create a tuple-variant serializer for an allocated Ruby array. + pub(super) fn new(ruby: &'ruby Ruby, variant: &'static str, array: RArray) -> Self { + Self { + ruby, + variant, + array, + } + } +} + +impl SerializeTupleVariant for TupleVariantSerializer<'_> { + type Ok = Value; + type Error = Error; + + fn serialize_field(&mut self, field: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + self.array + .push(field.serialize(Serializer::new(self.ruby))?) + .map_err(Into::into) + } + + fn end(self) -> Result { + nest(self.ruby, self.variant, self.array) + } +} diff --git a/src/serde/tests.rs b/src/serde/tests.rs new file mode 100644 index 0000000..485ac2b --- /dev/null +++ b/src/serde/tests.rs @@ -0,0 +1,237 @@ +use std::{collections::BTreeMap, fmt}; + +use ::serde::{Deserialize, Serialize, de::Visitor}; +use magnus::{RArray, RHash, RString, Ruby, Value, encoding::EncodingCapable, value::ReprValue}; + +use super::{deserialize_json, deserialize_ruby, serialize}; + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +struct Record { + count: u64, + enabled: bool, + tags: Vec, + note: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +struct UnitRecord; + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +struct NewtypeRecord(u64); + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +struct TupleRecord(u64, bool, String); + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +enum State { + Ready, + Count(u64), + Progress(u64, bool), + Failed { message: String }, +} + +/// Byte sequence that exercises Serde's owned byte-buffer entry points. +#[derive(Debug, PartialEq)] +struct ByteBuffer(Vec); + +impl Serialize for ByteBuffer { + fn serialize( + &self, + serializer: Serializer, + ) -> Result + where + Serializer: ::serde::Serializer, + { + serializer.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for ByteBuffer { + fn deserialize(deserializer: Deserializer) -> Result + where + Deserializer: ::serde::Deserializer<'de>, + { + struct ByteBufferVisitor; + + impl<'de> Visitor<'de> for ByteBufferVisitor { + type Value = ByteBuffer; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an owned byte buffer") + } + + fn visit_bytes(self, value: &[u8]) -> Result + where + Error: ::serde::de::Error, + { + Ok(ByteBuffer(value.to_vec())) + } + + fn visit_byte_buf(self, value: Vec) -> Result + where + Error: ::serde::de::Error, + { + Ok(ByteBuffer(value)) + } + } + + deserializer.deserialize_byte_buf(ByteBufferVisitor) + } +} + +/// Assert that a supported Serde value survives conversion through Ruby. +fn assert_ruby_round_trip(ruby: &Ruby, input: Input) -> Result<(), magnus::Error> +where + Input: fmt::Debug + PartialEq + Serialize + for<'de> Deserialize<'de>, +{ + let value: Value = serialize(ruby, &input)?; + let output: Input = deserialize_ruby(ruby, value)?; + assert_eq!(input, output); + Ok(()) +} + +/// Assert that a failed conversion preserves Ruby's `TypeError` classification. +fn assert_type_error(error: magnus::Error, message: &str) { + let error = error.to_string(); + assert!(error.starts_with("TypeError: "), "{error}"); + assert!(error.contains(message), "{error}"); +} + +/// Verify scalar, option, result, and byte-string conversion behavior. +fn assert_scalar_conversions(ruby: &Ruby) -> Result<(), magnus::Error> { + assert_ruby_round_trip(ruby, true)?; + assert_ruby_round_trip(ruby, 1.25_f32)?; + assert_ruby_round_trip(ruby, 1.25_f64)?; + assert_ruby_round_trip(ruby, Option::::None)?; + assert_ruby_round_trip(ruby, Some(123_u64))?; + assert_ruby_round_trip(ruby, Result::::Ok(1234))?; + assert_ruby_round_trip(ruby, Result::::Err("failed".to_owned()))?; + + let character: RString = serialize(ruby, &'\u{2603}')?; + assert_eq!("\u{2603}", character.to_string()?); + assert!(character.enc_get() == ruby.utf8_encindex()); + let output: char = deserialize_ruby(ruby, character)?; + assert_eq!('\u{2603}', output); + + let string: RString = serialize(ruby, &"Hello, world!")?; + assert_eq!("Hello, world!", string.to_string()?); + assert!(string.enc_get() == ruby.utf8_encindex()); + assert_eq!( + "Hello, world!", + deserialize_ruby::<_, String>(ruby, string)? + ); + + let bytes = ByteBuffer(b"\0binary\xff".to_vec()); + let value: RString = serialize(ruby, &bytes)?; + assert_eq!(bytes.0.as_slice(), value.to_bytes().as_ref()); + assert!(value.enc_get() == ruby.ascii8bit_encindex()); + assert_eq!(bytes, deserialize_ruby(ruby, value)?); + + let none: Value = serialize(ruby, &Option::::None)?; + assert!(none.is_nil()); + + let ok: RHash = serialize(ruby, &Result::::Ok(1234))?; + let value: u64 = ok.aref("Ok")?; + assert_eq!(1234, value); + + let error: RHash = serialize(ruby, &Result::::Err("failed".to_owned()))?; + assert_eq!("failed", error.aref::<_, String>("Err")?); + Ok(()) +} + +/// Verify collection, tuple, struct, and enum conversion behavior. +fn assert_composite_conversions(ruby: &Ruby) -> Result<(), magnus::Error> { + assert_ruby_round_trip(ruby, ())?; + assert_ruby_round_trip(ruby, [1_i64, 2, 3])?; + assert_ruby_round_trip(ruby, (123_i64, true, "tuple".to_owned()))?; + assert_ruby_round_trip(ruby, UnitRecord)?; + assert_ruby_round_trip(ruby, NewtypeRecord(123))?; + assert_ruby_round_trip(ruby, TupleRecord(123, true, "tuple struct".to_owned()))?; + + let record = Record { + count: 42, + enabled: true, + tags: vec!["ruby".into(), "rust".into()], + note: Some("present".into()), + }; + let value: RHash = serialize(ruby, &record)?; + let count: u64 = value.aref(ruby.to_symbol("count"))?; + assert_eq!(record.count, count); + assert_eq!(record, deserialize_ruby(ruby, value)?); + + let map = BTreeMap::from([("first".to_owned(), 1_u64), ("second".to_owned(), 2)]); + assert_ruby_round_trip(ruby, map)?; + + let tuple = (123_i64, true, "tuple".to_owned()); + let value: RArray = serialize(ruby, &tuple)?; + assert_eq!(3, value.len()); + let first: i64 = value.entry(0)?; + assert_eq!(123, first); + + for state in [ + State::Ready, + State::Count(2), + State::Progress(3, false), + State::Failed { + message: "failed".into(), + }, + ] { + assert_ruby_round_trip(ruby, state)?; + } + + let count: RHash = serialize(ruby, &State::Count(7))?; + let value: u64 = count.aref("Count")?; + assert_eq!(7, value); + Ok(()) +} + +/// Verify exact typed integer conversion in both deserialization modes. +fn assert_integer_conversions(ruby: &Ruby) -> Result<(), magnus::Error> { + let value: Value = serialize(ruby, &i128::MIN)?; + let decimal: String = value.funcall("to_s", ())?; + assert_eq!(i128::MIN.to_string(), decimal); + assert_eq!(i128::MIN, deserialize_ruby::<_, i128>(ruby, value)?); + assert_eq!(i128::MIN, deserialize_json::<_, i128>(ruby, value)?); + + let value: Value = serialize(ruby, &u128::MAX)?; + let decimal: String = value.funcall("to_s", ())?; + assert_eq!(u128::MAX.to_string(), decimal); + assert_eq!(u128::MAX, deserialize_ruby::<_, u128>(ruby, value)?); + assert_eq!(u128::MAX, deserialize_json::<_, u128>(ruby, value)?); + + let value: Value = serialize(ruby, &u64::MAX)?; + assert_eq!(u64::MAX, deserialize_ruby::<_, u64>(ruby, value)?); + assert_eq!(u64::MAX, deserialize_json::<_, u64>(ruby, value)?); + Ok(()) +} + +/// Verify unsupported borrowed values and malformed enum shapes return errors. +fn assert_conversion_errors(ruby: &Ruby) -> Result<(), magnus::Error> { + let error = deserialize_ruby::<_, &str>(ruby, ruby.str_new("borrowed")) + .expect_err("borrowed strings must not outlive their Ruby value"); + assert_type_error(error, "expected a borrowed string"); + + let error = deserialize_ruby::<_, &[u8]>(ruby, ruby.str_new("borrowed")) + .expect_err("borrowed byte slices must not outlive their Ruby value"); + assert_type_error(error, "can't deserialize into byte slice"); + + let variants = ruby.hash_new(); + variants.aset("Ready", ruby.qnil())?; + variants.aset("Count", 1)?; + let error = deserialize_ruby::<_, State>(ruby, variants) + .expect_err("an enum hash must contain exactly one variant"); + assert_type_error(error, "Hash of length 2"); + Ok(()) +} + +#[test] +fn retains_ruby_serde_conversion_surface() -> Result<(), magnus::Error> { + // SAFETY: this is the only test that initializes the embedded Ruby VM. + let ruby = unsafe { magnus::embed::init() }; + + assert_scalar_conversions(&ruby)?; + assert_composite_conversions(&ruby)?; + assert_integer_conversions(&ruby)?; + assert_conversion_errors(&ruby)?; + Ok(()) +} diff --git a/test/json_precision_test.rb b/test/json_precision_test.rb new file mode 100644 index 0000000..233195e --- /dev/null +++ b/test/json_precision_test.rb @@ -0,0 +1,209 @@ +require "test_helper" +require "json" +require "socket" + +class JsonPrecisionTest < Minitest::Test + INTEGER_BOUNDARIES = [ + (2**53) - 1, + 2**53, + (2**63) - 1, + 2**63, + (2**64) - 1, + -((2**53) - 1), + -(2**53), + -((2**63) - 1), + -(2**63), + -(2**63) - 1, + -((2**64) - 1), + -(2**64), + 2**100, + -(2**100), + 2**256, + -(2**256) + ].freeze + + def test_response_json_matches_json_parse_for_large_integers + payload = precision_payload + + with_json_server(response_body: JSON.generate(payload)) do |url, _requests| + response = Wreq.get(url) + expected = JSON.parse(response.bytes) + actual = response.json + + assert_equal payload, expected + assert_equal expected, actual + assert actual.fetch("integers").all? { |value| value.instance_of?(Integer) } + assert_instance_of Float, actual.fetch("fraction") + assert_instance_of Float, actual.fetch("exponent") + end + end + + def test_request_json_preserves_large_integers_and_nested_values + payload = precision_payload + + with_json_server do |url, requests| + response = Wreq.post(url, json: payload) + request = requests.pop + + assert_equal "application/json", request.fetch(:headers).fetch("content-type") + assert_equal JSON.generate(payload), request.fetch(:body) + assert_equal payload, response.json + end + end + + def test_request_json_nil_is_serialized_as_null + with_json_server do |url, requests| + response = Wreq.post(url, json: nil) + request = requests.pop + + assert_equal "null", request.fetch(:body) + assert_nil response.json + end + end + + def test_request_json_accepts_symbols_and_preserves_object_order + payload = {second: 2**100, first: :value} + + with_json_server do |url, requests| + response = Wreq.post(url, json: payload) + request = requests.pop + actual = response.json + + assert_equal JSON.generate(payload), request.fetch(:body) + assert_equal ["second", "first"], actual.keys + assert_equal({"second" => 2**100, "first" => "value"}, actual) + end + end + + def test_request_json_enforces_documented_nesting_limit + accepted = 0 + 100.times { accepted = [accepted] } + + with_json_server do |url, requests| + Wreq.post(url, json: accepted) + + assert_equal JSON.generate(accepted), requests.pop.fetch(:body) + end + + error = assert_raises(Wreq::BuilderError) do + Wreq.post("http://127.0.0.1:1/", json: [accepted]) + end + assert_match(/nesting exceeds 100 levels/, error.message) + end + + def test_unsupported_request_json_raises_before_socket_io + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + + error = assert_raises(Wreq::BuilderError) do + Wreq.post("http://127.0.0.1:#{port}/", json: {"value" => Float::NAN}) + end + + cyclic = [] + cyclic << cyclic + nesting_error = assert_raises(Wreq::BuilderError) do + Wreq.post("http://127.0.0.1:#{port}/", json: cyclic) + end + + cyclic_hash = {} + cyclic_hash["self"] = cyclic_hash + hash_nesting_error = assert_raises(Wreq::BuilderError) do + Wreq.post("http://127.0.0.1:#{port}/", json: cyclic_hash) + end + + key_error = assert_raises(Wreq::BuilderError) do + Wreq.post("http://127.0.0.1:#{port}/", json: {1 => "value"}) + end + + assert_match(/non-finite/, error.message) + assert_match(/nesting/, nesting_error.message) + assert_match(/nesting/, hash_nesting_error.message) + assert_match(/keys/, key_error.message) + assert_equal :wait_readable, server.accept_nonblock(exception: false) + ensure + server&.close unless server&.closed? + end + + def test_invalid_response_json_raises_decoding_error + with_json_server(response_body: '{"id":') do |url, _requests| + response = Wreq.get(url) + + assert_raises(Wreq::DecodingError) { response.json } + end + end + + def test_fractional_and_large_exponent_numbers_match_json_parse + source = '{"fraction":0.12345678901234567890123456789,"exponent":1e400}' + + with_json_server(response_body: source) do |url, _requests| + response = Wreq.get(url) + expected = JSON.parse(response.bytes) + actual = response.json + + assert_equal expected, actual + assert_instance_of Float, actual.fetch("fraction") + assert_equal Float::INFINITY, actual.fetch("exponent") + end + end + + private + + def precision_payload + { + "integers" => INTEGER_BOUNDARIES, + "nested" => { + "array" => [2**100, {"negative" => -(2**100)}], + "object" => {"unsigned_64_max" => (2**64) - 1} + }, + "shapes" => ["text", true, false, nil], + "fraction" => 1.25, + "exponent" => 1.0e40 + } + end + + def with_json_server(response_body: nil) + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + requests = Queue.new + thread = Thread.new do + socket = server.accept + begin + request_line = socket.gets + headers = read_headers(socket) + content_length = headers.fetch("content-length", "0").to_i + body = content_length.zero? ? "" : socket.read(content_length) + requests << {request_line: request_line, headers: headers, body: body} + + response = response_body.nil? ? body : response_body + socket.write "HTTP/1.1 200 OK\r\n" + socket.write "Content-Type: application/json\r\n" + socket.write "Content-Length: #{response.bytesize}\r\n" + socket.write "Connection: close\r\n\r\n" + socket.write response + ensure + socket.close unless socket.closed? + end + rescue IOError, SystemCallError + nil + ensure + server.close unless server.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{port}/", requests + ensure + server&.close unless server&.closed? + thread&.join(5) + end + + def read_headers(socket) + headers = {} + while (line = socket.gets) + break if line == "\r\n" + + name, value = line.split(":", 2) + headers[name.downcase] = value.strip + end + headers + end +end