diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc93ab74a..f349ed0bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,7 @@ jobs: - { feature: chrono-tz, crate: juniper } - { feature: expose-test-schema, crate: juniper } - { feature: jiff, crate: juniper } + - { feature: ruint, crate: juniper } - { feature: rust_decimal, crate: juniper } - { feature: schema-language, crate: juniper } - { feature: time, crate: juniper } diff --git a/book/src/types/scalars.md b/book/src/types/scalars.md index 95690a955..4d81b0961 100644 --- a/book/src/types/scalars.md +++ b/book/src/types/scalars.md @@ -439,6 +439,12 @@ mod date_scalar { | [`chrono::NaiveDateTime`] | [`LocalDateTime`] | [`chrono`] | | [`chrono::DateTime`] | [`DateTime`] | [`chrono`] | | [`chrono_tz::Tz`] | [`TimeZone`] | [`chrono-tz`] | +| [`ruint::aliases::U8`] | `U8` | [`ruint`] | +| [`ruint::aliases::U16`] | `U16` | [`ruint`] | +| [`ruint::aliases::U32`] | `U32` | [`ruint`] | +| [`ruint::aliases::U64`] | `U64` | [`ruint`] | +| [`ruint::aliases::U128`] | `U128` | [`ruint`] | +| [`ruint::aliases::U256`] | `U256` | [`ruint`] | | [`rust_decimal::Decimal`] | `Decimal` | [`rust_decimal`] | | [`jiff::civil::Date`] | [`LocalDate`] | [`jiff`] | | [`jiff::civil::Time`] | [`LocalTime`] | [`jiff`] | @@ -489,6 +495,13 @@ mod date_scalar { [`LocalDateTime`]: https://graphql-scalars.dev/docs/scalars/local-date-time [`LocalTime`]: https://graphql-scalars.dev/docs/scalars/local-time [`ObjectID`]: https://the-guild.dev/graphql/scalars/docs/scalars/object-id +[`ruint`]: https://docs.rs/ruint +[`ruint::aliases::U8`]: https://docs.rs/ruint/latest/ruint/aliases/type.U8.html +[`ruint::aliases::U16`]: https://docs.rs/ruint/latest/ruint/aliases/type.U16.html +[`ruint::aliases::U32`]: https://docs.rs/ruint/latest/ruint/aliases/type.U32.html +[`ruint::aliases::U64`]: https://docs.rs/ruint/latest/ruint/aliases/type.U64.html +[`ruint::aliases::U128`]: https://docs.rs/ruint/latest/ruint/aliases/type.U128.html +[`ruint::aliases::U256`]: https://docs.rs/ruint/latest/ruint/aliases/type.U256.html [`rust_decimal`]: https://docs.rs/rust_decimal [`rust_decimal::Decimal`]: https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html [`ScalarValue`]: https://docs.rs/juniper/0.17.0/juniper/trait.ScalarValue.html diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md index ad02e14b6..b7a19c4e3 100644 --- a/juniper/CHANGELOG.md +++ b/juniper/CHANGELOG.md @@ -42,6 +42,14 @@ All user visible changes to `juniper` crate will be documented in this file. Thi - Support parsing descriptions on operations, fragments and variable definitions. ([#1349], [graphql/graphql-spec#1170]) - Support for [block strings][0180-1]. ([#1349]) - Support of `#[graphql(rename_all = "snake_case")]` attribute in macros. ([#1354]) +- [`ruint` crate] integration behind `ruint` [Cargo feature]: ([#1355], [#1350]) + - `ruint::aliases::U8` as `U8` scalar. + - `ruint::aliases::U16` as `U16` scalar. + - `ruint::aliases::U32` as `U32` scalar. + - `ruint::aliases::U64` as `U64` scalar. + - `ruint::aliases::U128` as `U128` scalar. + - `ruint::aliases::U256` as `U256` scalar. + - `integrations::ruint::unit_scalar` module for declaring custom-sized `ruint::Unit` scalars. ### Changed @@ -60,8 +68,10 @@ All user visible changes to `juniper` crate will be documented in this file. Thi [#1347]: /../../issues/1347 [#1348]: /../../pull/1348 [#1349]: /../../pull/1349 +[#1350]: /../../issues/1350 [#1353]: /../../pull/1353 [#1354]: /../../pull/1354 +[#1355]: /../../pull/1355 [graphql/graphql-spec#525]: https://github.com/graphql/graphql-spec/pull/525 [graphql/graphql-spec#687]: https://github.com/graphql/graphql-spec/issues/687 [graphql/graphql-spec#805]: https://github.com/graphql/graphql-spec/pull/805 diff --git a/juniper/Cargo.toml b/juniper/Cargo.toml index cda6c2564..4f8a30e5f 100644 --- a/juniper/Cargo.toml +++ b/juniper/Cargo.toml @@ -35,6 +35,7 @@ chrono-tz = ["dep:chrono-tz", "dep:regex"] expose-test-schema = ["dep:anyhow", "dep:serde_json"] jiff = ["dep:jiff"] js = ["chrono?/wasmbind", "time?/wasm-bindgen", "uuid?/js"] +ruint = ["dep:ruint"] rust_decimal = ["dep:rust_decimal"] schema-language = ["dep:graphql-parser", "dep:void"] time = ["dep:time"] @@ -60,6 +61,7 @@ itertools = "0.14" jiff = { version = "0.2", features = ["std"], default-features = false, optional = true } juniper_codegen = { version = "0.17.0", path = "../juniper_codegen" } ref-cast = "1.0" +ruint = { version = "1.10", optional = true } rust_decimal = { version = "1.20", default-features = false, optional = true } ryu = { version = "1.0", optional = true } serde = { version = "1.0.122", features = ["derive"] } diff --git a/juniper/README.md b/juniper/README.md index 4154d99d9..7a91a2599 100644 --- a/juniper/README.md +++ b/juniper/README.md @@ -49,6 +49,7 @@ As an exception to other [GraphQL] libraries for other languages, [Juniper] buil - [`bson`] - [`chrono`], [`chrono-tz`] - [`jiff`] +- [`ruint`] - [`rust_decimal`] - [`time`] - [`url`] @@ -94,6 +95,7 @@ This project is licensed under [BSD 2-Clause License](https://github.com/graphql [`juniper_warp`]: https://docs.rs/juniper_warp [`hyper`]: https://docs.rs/hyper [`rocket`]: https://docs.rs/rocket +[`ruint`]: https://docs.rs/ruint [`rust_decimal`]: https://docs.rs/rust_decimal [`time`]: https://docs.rs/time [`url`]: https://docs.rs/url diff --git a/juniper/src/integrations/mod.rs b/juniper/src/integrations/mod.rs index 3f16037e6..942524a7b 100644 --- a/juniper/src/integrations/mod.rs +++ b/juniper/src/integrations/mod.rs @@ -12,6 +12,8 @@ pub mod chrono; pub mod chrono_tz; #[cfg(feature = "jiff")] pub mod jiff; +#[cfg(feature = "ruint")] +pub mod ruint; #[cfg(feature = "rust_decimal")] pub mod rust_decimal; #[doc(hidden)] diff --git a/juniper/src/integrations/ruint.rs b/juniper/src/integrations/ruint.rs new file mode 100644 index 000000000..3ded63824 --- /dev/null +++ b/juniper/src/integrations/ruint.rs @@ -0,0 +1,383 @@ +//! GraphQL support for [`ruint`] crate types. +//! +//! # Supported types +//! +//! | Rust type | GraphQL scalar | +//! |----------------|----------------| +//! | [`U8`] | `U8` | +//! | [`U16`] | `U16` | +//! | [`U32`] | `U32` | +//! | [`U64`] | `U64` | +//! | [`U128`] | `U128` | +//! | [`U256`] | `U256` | +//! +//! # Custom-sized type +//! +//! Any custom variation of the [`ruint::Uint`] type could be made into a [GraphQL scalar][0] by +//! reusing the [`integrations::ruint::uint_scalar`] module. +//! +//! However, to satisfy [orphan rules], a local [`ScalarValue`] implementation should be provided: +//! ```rust +//! # use derive_more::{Display, From, TryInto}; +//! # use juniper::{ScalarValue, graphql_scalar}; +//! # use serde::{Deserialize, Serialize}; +//! # +//! #[derive(Clone, Debug, Deserialize, Display, From, PartialEq, ScalarValue, Serialize, TryInto)] +//! #[serde(untagged)] +//! enum CustomScalarValue { +//! #[value(to_float, to_int)] +//! Int(i32), +//! #[value(to_float)] +//! Float(f64), +//! #[value(as_str, to_string)] +//! String(String), +//! #[value(to_bool)] +//! Boolean(bool), +//! } +//! +//! #[graphql_scalar] +//! #[graphql( +//! with = juniper::integrations::ruint::uint_scalar, +//! specified_by_url = "https://docs.rs/ruint", +//! scalar = CustomScalarValue, +//! )] +//! type U512 = ruint::Uint<512, 8>; +//! ``` +//! +//! [`ScalarValue`]: trait@crate::ScalarValue +//! [`U256`]: ruint::aliases::U256 +//! [`U128`]: ruint::aliases::U128 +//! [`U64`]: ruint::aliases::U64 +//! [orphan rules]: https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules +//! [0]: https://spec.graphql.org/October2021#sec-Scalars + +use crate::graphql_scalar; + +/// Unsigned integer type representing the ring of numbers modulo 28. +/// +/// Always serializes as `String` in decimal notation. But may be deserialized both from `Int` and +/// `String` values with standard Rust syntax for decimal, hexadecimal, binary and octal notation +/// using prefixes `0x`, `0b` and `0o`. +/// +/// See also [`ruint`] crate for details. +/// +/// [`ruint`]: https://docs.rs/ruint +#[graphql_scalar] +#[graphql(with = uint_scalar, specified_by_url = "https://docs.rs/ruint")] +pub type U8 = ruint::aliases::U8; + +/// Unsigned integer type representing the ring of numbers modulo 216. +/// +/// Always serializes as `String` in decimal notation. But may be deserialized both from `Int` and +/// `String` values with standard Rust syntax for decimal, hexadecimal, binary and octal notation +/// using prefixes `0x`, `0b` and `0o`. +/// +/// See also [`ruint`] crate for details. +/// +/// [`ruint`]: https://docs.rs/ruint +#[graphql_scalar] +#[graphql(with = uint_scalar, specified_by_url = "https://docs.rs/ruint")] +pub type U16 = ruint::aliases::U16; + +/// Unsigned integer type representing the ring of numbers modulo 232. +/// +/// Always serializes as `String` in decimal notation. But may be deserialized both from `Int` and +/// `String` values with standard Rust syntax for decimal, hexadecimal, binary and octal notation +/// using prefixes `0x`, `0b` and `0o`. +/// +/// See also [`ruint`] crate for details. +/// +/// [`ruint`]: https://docs.rs/ruint +#[graphql_scalar] +#[graphql(with = uint_scalar, specified_by_url = "https://docs.rs/ruint")] +pub type U32 = ruint::aliases::U32; + +/// Unsigned integer type representing the ring of numbers modulo 264. +/// +/// Always serializes as `String` in decimal notation. But may be deserialized both from `Int` and +/// `String` values with standard Rust syntax for decimal, hexadecimal, binary and octal notation +/// using prefixes `0x`, `0b` and `0o`. +/// +/// See also [`ruint`] crate for details. +/// +/// [`ruint`]: https://docs.rs/ruint +#[graphql_scalar] +#[graphql(with = uint_scalar, specified_by_url = "https://docs.rs/ruint")] +pub type U64 = ruint::aliases::U64; + +/// Unsigned integer type representing the ring of numbers modulo 2128. +/// +/// Always serializes as `String` in decimal notation. But may be deserialized both from `Int` and +/// `String` values with standard Rust syntax for decimal, hexadecimal, binary and octal notation +/// using prefixes `0x`, `0b` and `0o`. +/// +/// See also [`ruint`] crate for details. +/// +/// [`ruint`]: https://docs.rs/ruint +#[graphql_scalar] +#[graphql(with = uint_scalar, specified_by_url = "https://docs.rs/ruint")] +pub type U128 = ruint::aliases::U128; + +/// Unsigned integer type representing the ring of numbers modulo 2256. +/// +/// Always serializes as `String` in decimal notation. But may be deserialized both from `Int` and +/// `String` values with standard Rust syntax for decimal, hexadecimal, binary and octal notation +/// using prefixes `0x`, `0b` and `0o`. +/// +/// See also [`ruint`] crate for details. +/// +/// [`ruint`]: https://docs.rs/ruint +#[graphql_scalar] +#[graphql(with = uint_scalar, specified_by_url = "https://docs.rs/ruint")] +pub type U256 = ruint::aliases::U256; + +pub mod uint_scalar { + //! [GraphQL scalar][0] implementation for [`ruint::Uint`] type, suitable for specifying into + //! the `with` argument of the `#[graphql_scalar]`][1] macro. + //! + //! [0]: https://spec.graphql.org/October2021#sec-Scalars + //! [1]: macro@crate::graphql_scalar + + use crate::{ParseScalarResult, ParseScalarValue, Scalar, ScalarToken, ScalarValue}; + + /// Parses an arbitrary [`ruint::Uint`] value from the provided [`ScalarValue`]. + /// + /// Expects either `String` or `Int` GraphQL scalars as input, with standard Rust syntax for + /// decimal, hexadecimal, binary and octal notation using prefixes `0x`, `0b` and `0o`. + /// + /// # Errors + /// + /// If the [`ruint::Uint`] value cannot be parsed from the provided [`ScalarValue`]. + pub fn from_input( + value: &Scalar, + ) -> Result, Box> { + if let Some(int) = value.try_to_int() { + return ruint::Uint::try_from(int).map_err(|e| { + format!("Failed to parse `ruint::Uint<{B}, {L}>` from `Int`: {e}").into() + }); + } + + let Some(s) = value.try_as_str() else { + return Err(format!( + "Failed to parse `ruint::Uint<{B}, {L}>`: input is neither `String` nor `Int`" + ) + .into()); + }; + // TODO: Remove once recmo/uint#348 is resolved and released: + // https://github.com/recmo/uint/issues/348 + if s.is_empty() { + return Err(format!( + "Failed to parse `ruint::Uint<{B}, {L}>` from `String`: cannot be empty", + ) + .into()); + } + s.parse().map_err(|e| { + format!("Failed to parse `ruint::Uint<{B}, {L}>` from `String`: {e}").into() + }) + } + + // ERGONOMICS: This method is intentionally placed here to allow omitting specifying another + // `to_output_with = ScalarValue::from_displayable` macro argument in the user code + // once the `with = juniper::integrations::ruint::uint_scalar` is specified already. + /// Converts the provided arbitrary [`ruint::Uint`] value into a [`ScalarValue`]. + /// + /// Always serializes as GraphQL `String` in decimal notation. + pub fn to_output(int: &ruint::Uint) -> S { + S::from_displayable(int) + } + + // ERGONOMICS: This method is intentionally placed here to allow omitting specifying another + // `parse_token(i32, String)` macro argument in the user code once the + // `with = juniper::integrations::ruint::uint_scalar` is specified already. + /// Parses a [`ScalarValue`] from the provided [`ScalarToken`] as the [`ruint::Uint`] requires. + /// + /// # Errors + /// + /// If the provided [`ScalarToken`] represents neither `String` nor `Int` GraphQL scalar. + pub fn parse_token(token: ScalarToken<'_>) -> ParseScalarResult { + >::from_str(token) + .or_else(|_| >::from_str(token)) + } +} + +#[cfg(test)] +mod test { + use super::{U8, U16, U32, U64, U128, U256}; + use crate::{FromInputValue as _, InputValue, ToInputValue as _, graphql}; + + #[test] + fn parses_correct_input_8() { + for (input, expected) in [ + (graphql::input_value!(0), U8::ZERO), + (graphql::input_value!(123), U8::from(123)), + (graphql::input_value!("0"), U8::ZERO), + (graphql::input_value!("42"), U8::from(42)), + (graphql::input_value!("0xbe"), U8::from(0xbe)), + ] { + let input: InputValue = input; + let parsed = U8::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{input:?}`: {:?}", + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {input:?}"); + } + } + + #[test] + fn parses_correct_input_16() { + for (input, expected) in [ + (graphql::input_value!(0), U16::ZERO), + (graphql::input_value!(123), U16::from(123)), + (graphql::input_value!("0"), U16::ZERO), + (graphql::input_value!("42"), U16::from(42)), + (graphql::input_value!("0xbeef"), U16::from(0xbeef)), + ] { + let input: InputValue = input; + let parsed = U16::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{input:?}`: {:?}", + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {input:?}"); + } + } + + #[test] + fn parses_correct_input_32() { + for (input, expected) in [ + (graphql::input_value!(0), U32::ZERO), + (graphql::input_value!(123), U32::from(123)), + (graphql::input_value!("0"), U32::ZERO), + (graphql::input_value!("42"), U32::from(42)), + ( + graphql::input_value!("0xdeadbeef"), + U32::from(3735928559u32), + ), + ] { + let input: InputValue = input; + let parsed = U32::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{input:?}`: {:?}", + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {input:?}"); + } + } + + #[test] + fn parses_correct_input_64() { + for (input, expected) in [ + (graphql::input_value!(0), U64::ZERO), + (graphql::input_value!(123), U64::from(123)), + (graphql::input_value!("0"), U64::ZERO), + (graphql::input_value!("42"), U64::from(42)), + ( + graphql::input_value!("0xdeadbeef"), + U64::from(3735928559u64), + ), + ] { + let input: InputValue = input; + let parsed = U64::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{input:?}`: {:?}", + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {input:?}"); + } + } + + #[test] + fn parses_correct_input_128() { + for (input, expected) in [ + (graphql::input_value!(0), U128::ZERO), + (graphql::input_value!(123), U128::from(123)), + (graphql::input_value!("0"), U128::ZERO), + (graphql::input_value!("42"), U128::from(42)), + ( + graphql::input_value!("0xdeadbeef"), + U128::from(3735928559u64), + ), + ] { + let input: InputValue = input; + let parsed = U128::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{input:?}`: {:?}", + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {input:?}"); + } + } + + #[test] + fn parses_correct_input_256() { + for (input, expected) in [ + (graphql::input_value!(0), U256::ZERO), + (graphql::input_value!(123), U256::from(123)), + (graphql::input_value!("0"), U256::ZERO), + (graphql::input_value!("42"), U256::from(42)), + (graphql::input_value!("0o10"), U256::from(8)), + ( + graphql::input_value!("0xdeadbeef"), + U256::from(3735928559u64), + ), + ] { + let input: InputValue = input; + let parsed = U256::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{input:?}`: {:?}", + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {input:?}"); + } + } + + #[test] + fn fails_on_invalid_input() { + for input in [ + graphql::input_value!(""), + graphql::input_value!("0,0"), + graphql::input_value!("12,"), + graphql::input_value!("1996-12-19T14:23:43"), + graphql::input_value!("i'm not even a number"), + graphql::input_value!(null), + graphql::input_value!(false), + graphql::input_value!(-123), + ] { + let input: InputValue = input; + let parsed = U256::from_input_value(&input); + + assert!( + parsed.is_err(), + "allows input: {input:?} {}", + parsed.unwrap(), + ); + } + } + + #[test] + fn formats_correctly() { + for (raw, expected) in [ + ("0", "0"), + ("87553378877997984345", "87553378877997984345"), + ("123", "123"), + ("0x42", "66"), + ] { + let actual: InputValue = raw.parse::().unwrap().to_input_value(); + + assert_eq!(actual, graphql::input_value!((expected)), "on value: {raw}"); + } + } +}