diff --git a/README.md b/README.md index 1f6db33f..3bd50048 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ your Schemas automatically. - [url][url] - [chrono][chrono] - [chrono-tz][chrono-tz] +- [time][time] - [bson][bson] ### Web Frameworks @@ -118,5 +119,6 @@ Juniper has not reached 1.0 yet, thus some API instability should be expected. [url]: https://crates.io/crates/url [chrono]: https://crates.io/crates/chrono [chrono-tz]: https://crates.io/crates/chrono-tz +[time]: https://crates.io/crates/time [bson]: https://crates.io/crates/bson [juniper-from-schema]: https://github.com/davidpdrsn/juniper-from-schema diff --git a/docs/book/content/types/scalars.md b/docs/book/content/types/scalars.md index 23afc7fd..c8382833 100644 --- a/docs/book/content/types/scalars.md +++ b/docs/book/content/types/scalars.md @@ -6,11 +6,11 @@ but this often requires coordination with the client library intended to consume the API you're building. Since any value going over the wire is eventually transformed into JSON, you're -also limited in the data types you can use. +also limited in the data types you can use. -There are two ways to define custom scalars. +There are two ways to define custom scalars. * For simple scalars that just wrap a primitive type, you can use the newtype pattern with -a custom derive. +a custom derive. * For more advanced use cases with custom validation, you can use the `graphql_scalar` proc macro. @@ -36,12 +36,13 @@ crates. They are enabled via features that are on by default. * uuid::Uuid * chrono::DateTime +* time::{Date, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset} * url::Url * bson::oid::ObjectId ## newtype pattern -Often, you might need a custom scalar that just wraps an existing type. +Often, you might need a custom scalar that just wraps an existing type. This can be done with the newtype pattern and a custom derive, similar to how serde supports this pattern with `#[serde(transparent)]`. @@ -82,15 +83,15 @@ pub struct UserId(i32); ## Custom scalars -For more complex situations where you also need custom parsing or validation, +For more complex situations where you also need custom parsing or validation, you can use the `graphql_scalar` proc macro. Typically, you represent your custom scalars as strings. The example below implements a custom scalar for a custom `Date` type. -Note: juniper already has built-in support for the `chrono::DateTime` type -via `chrono` feature, which is enabled by default and should be used for this +Note: juniper already has built-in support for the `chrono::DateTime` type +via `chrono` feature, which is enabled by default and should be used for this purpose. The example below is used just for illustration. @@ -101,9 +102,9 @@ The example below is used just for illustration. ```rust # extern crate juniper; -# mod date { -# pub struct Date; -# impl std::str::FromStr for Date{ +# mod date { +# pub struct Date; +# impl std::str::FromStr for Date { # type Err = String; fn from_str(_value: &str) -> Result { unimplemented!() } # } # // And we define how to represent date as a string. @@ -118,7 +119,7 @@ use juniper::{Value, ParseScalarResult, ParseScalarValue}; use date::Date; #[juniper::graphql_scalar(description = "Date")] -impl GraphQLScalar for Date +impl GraphQLScalar for Date where S: ScalarValue { diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md index c90fc5bc..8736f53f 100644 --- a/juniper/CHANGELOG.md +++ b/juniper/CHANGELOG.md @@ -19,6 +19,7 @@ - Use `null` in addition to `None` to create `Value::Null` in `graphql_value!` macro to mirror `serde_json::json!`. ([#996](https://github.com/graphql-rust/juniper/pull/996)) - Add `From` impls to `InputValue` mirroring the ones for `Value` and provide better support for `Option` handling. ([#996](https://github.com/graphql-rust/juniper/pull/996)) - Implement `graphql_input_value!` and `graphql_vars!` macros. ([#996](https://github.com/graphql-rust/juniper/pull/996)) +- Support [`time` crate](https://docs.rs/time) types as GraphQL scalars behind `time` feature. ([#1006](https://github.com/graphql-rust/juniper/pull/1006)) ## Fixes diff --git a/juniper/Cargo.toml b/juniper/Cargo.toml index c8812221..5262ad8d 100644 --- a/juniper/Cargo.toml +++ b/juniper/Cargo.toml @@ -22,6 +22,7 @@ travis-ci = { repository = "graphql-rust/juniper" } default = [ "bson", "chrono", + "time", "schema-language", "url", "uuid", @@ -48,6 +49,7 @@ serde = { version = "1.0.8", features = ["derive"], default-features = false } serde_json = { version = "1.0.2", default-features = false, optional = true } smartstring = "0.2.6" static_assertions = "1.1" +time = { version = "0.3", features = ["formatting", "macros", "parsing"], optional = true } url = { version = "2.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/juniper/src/integrations/chrono.rs b/juniper/src/integrations/chrono.rs index 9c0c5f64..55cfd50e 100644 --- a/juniper/src/integrations/chrono.rs +++ b/juniper/src/integrations/chrono.rs @@ -24,9 +24,6 @@ use crate::{ Value, }; -#[doc(hidden)] -pub static RFC3339_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z"; - #[crate::graphql_scalar(name = "DateTimeFixedOffset", description = "DateTime")] impl GraphQLScalar for DateTime where diff --git a/juniper/src/integrations/mod.rs b/juniper/src/integrations/mod.rs index 76ac28ba..d966997f 100644 --- a/juniper/src/integrations/mod.rs +++ b/juniper/src/integrations/mod.rs @@ -8,6 +8,8 @@ pub mod chrono; pub mod chrono_tz; #[doc(hidden)] pub mod serde; +#[cfg(feature = "time")] +pub mod time; #[cfg(feature = "url")] pub mod url; #[cfg(feature = "uuid")] diff --git a/juniper/src/integrations/time.rs b/juniper/src/integrations/time.rs new file mode 100644 index 00000000..abc11768 --- /dev/null +++ b/juniper/src/integrations/time.rs @@ -0,0 +1,721 @@ +//! GraphQL support for [`time`] crate types. +//! +//! # Supported types +//! +//! | Rust type | Format | GraphQL scalar | +//! |-----------------------|-----------------------|---------------------| +//! | [`Date`] | `yyyy-MM-dd` | [`Date`][s1] | +//! | [`Time`] | `HH:mm[:ss[.SSS]]` | [`LocalTime`][s2] | +//! | [`PrimitiveDateTime`] | `yyyy-MM-dd HH:mm:ss` | `LocalDateTime` | +//! | [`OffsetDateTime`] | [RFC 3339] string | [`DateTime`][s4] | +//! | [`UtcOffset`] | `±hh:mm` | [`UtcOffset`][s5] | +//! +//! [`Date`]: time::Date +//! [`OffsetDateTime`]: time::OffsetDateTime +//! [`PrimitiveDateTime`]: time::PrimitiveDateTime +//! [`Time`]: time::Time +//! [`UtcOffset`]: time::UtcOffset +//! [RFC 3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6 +//! [s1]: https://graphql-scalars.dev/docs/scalars/date +//! [s2]: https://graphql-scalars.dev/docs/scalars/local-time +//! [s4]: https://graphql-scalars.dev/docs/scalars/date-time +//! [s5]: https://graphql-scalars.dev/docs/scalars/utc-offset + +use time::{ + format_description::{well_known::Rfc3339, FormatItem}, + macros::format_description, +}; + +use crate::{ + graphql_scalar, + parser::{ParseError, ScalarToken, Token}, + value::ParseScalarResult, + Value, +}; + +pub use time::{ + Date, OffsetDateTime as DateTime, PrimitiveDateTime as LocalDateTime, Time as LocalTime, + UtcOffset, +}; + +/// Format of a [`Date` scalar][1]. +/// +/// [1]: https://graphql-scalars.dev/docs/scalars/date +const DATE_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day]"); + +#[graphql_scalar( + description = "Date in the proleptic Gregorian calendar (without time \ + zone).\ + \n\n\ + Represents a description of the date (as used for birthdays, + for example). It cannot represent an instant on the \ + time-line.\ + \n\n\ + [`Date` scalar][1] compliant.\ + \n\n\ + See also [`time::Date`][2] for details.\ + \n\n\ + [1]: https://graphql-scalars.dev/docs/scalars/date\n\ + [2]: https://docs.rs/time/*/time/struct.Date.html" +)] +impl GraphQLScalar for Date { + fn resolve(&self) -> Value { + Value::scalar( + self.format(DATE_FORMAT) + .unwrap_or_else(|e| panic!("Failed to format `Date`: {}", e)), + ) + } + + fn from_input_value(v: &InputValue) -> Result { + v.as_string_value() + .ok_or_else(|| format!("Expected `String`, found: {}", v)) + .and_then(|s| Self::parse(s, DATE_FORMAT).map_err(|e| format!("Invalid `Date`: {}", e))) + } + + fn from_str<'a>(value: ScalarToken<'a>) -> ParseScalarResult<'a, S> { + if let ScalarToken::String(s) = value { + Ok(S::from(s.to_owned())) + } else { + Err(ParseError::UnexpectedToken(Token::Scalar(value))) + } + } +} + +/// Full format of a [`LocalTime` scalar][1]. +/// +/// [1]: https://graphql-scalars.dev/docs/scalars/local-time +const LOCAL_TIME_FORMAT: &[FormatItem<'_>] = + format_description!("[hour]:[minute]:[second].[subsecond digits:3]"); + +/// Format of a [`LocalTime` scalar][1] without milliseconds. +/// +/// [1]: https://graphql-scalars.dev/docs/scalars/local-time +const LOCAL_TIME_FORMAT_NO_MILLIS: &[FormatItem<'_>] = + format_description!("[hour]:[minute]:[second]"); + +/// Format of a [`LocalTime` scalar][1] without seconds. +/// +/// [1]: https://graphql-scalars.dev/docs/scalars/local-time +const LOCAL_TIME_FORMAT_NO_SECS: &[FormatItem<'_>] = format_description!("[hour]:[minute]"); + +#[graphql_scalar(description = "Clock time within a given date (without time zone) in \ + `HH:mm[:ss[.SSS]]` format.\ + \n\n\ + All minutes are assumed to have exactly 60 seconds; no \ + attempt is made to handle leap seconds (either positive or \ + negative).\ + \n\n\ + [`LocalTime` scalar][1] compliant.\ + \n\n\ + See also [`time::Time`][2] for details.\ + \n\n\ + [1]: https://graphql-scalars.dev/docs/scalars/local-time\n\ + [2]: https://docs.rs/time/*/time/struct.Time.html")] +impl GraphQLScalar for LocalTime { + fn resolve(&self) -> Value { + Value::scalar( + if self.millisecond() == 0 { + self.format(LOCAL_TIME_FORMAT_NO_MILLIS) + } else { + self.format(LOCAL_TIME_FORMAT) + } + .unwrap_or_else(|e| panic!("Failed to format `LocalTime`: {}", e)), + ) + } + + fn from_input_value(v: &InputValue) -> Result { + v.as_string_value() + .ok_or_else(|| format!("Expected `String`, found: {}", v)) + .and_then(|s| { + // First, try to parse the most used format. + // At the end, try to parse the full format for the parsing + // error to be most informative. + Self::parse(s, LOCAL_TIME_FORMAT_NO_MILLIS) + .or_else(|_| Self::parse(s, LOCAL_TIME_FORMAT_NO_SECS)) + .or_else(|_| Self::parse(s, LOCAL_TIME_FORMAT)) + .map_err(|e| format!("Invalid `LocalTime`: {}", e)) + }) + } + + fn from_str<'a>(value: ScalarToken<'a>) -> ParseScalarResult<'a, S> { + if let ScalarToken::String(s) = value { + Ok(S::from(s.to_owned())) + } else { + Err(ParseError::UnexpectedToken(Token::Scalar(value))) + } + } +} + +/// Format of a [`LocalDateTime`] scalar. +const LOCAL_DATE_TIME_FORMAT: &[FormatItem<'_>] = + format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); + +#[graphql_scalar( + description = "Combined date and time (without time zone) in `yyyy-MM-dd \ + HH:mm:ss` format.\ + \n\n\ + See also [`time::PrimitiveDateTime`][2] for details.\ + \n\n\ + [2]: https://docs.rs/time/*/time/struct.PrimitiveDateTime.html" +)] +impl GraphQLScalar for LocalDateTime { + fn resolve(&self) -> Value { + Value::scalar( + self.format(LOCAL_DATE_TIME_FORMAT) + .unwrap_or_else(|e| panic!("Failed to format `LocalDateTime`: {}", e)), + ) + } + + fn from_input_value(v: &InputValue) -> Result { + v.as_string_value() + .ok_or_else(|| format!("Expected `String`, found: {}", v)) + .and_then(|s| { + Self::parse(s, LOCAL_DATE_TIME_FORMAT) + .map_err(|e| format!("Invalid `LocalDateTime`: {}", e)) + }) + } + + fn from_str<'a>(value: ScalarToken<'a>) -> ParseScalarResult<'a, S> { + if let ScalarToken::String(s) = value { + Ok(S::from(s.to_owned())) + } else { + Err(ParseError::UnexpectedToken(Token::Scalar(value))) + } + } +} + +#[graphql_scalar( + description = "Combined date and time (with time zone) in [RFC 3339][0] \ + format.\ + \n\n\ + Represents a description of an exact instant on the \ + time-line (such as the instant that a user account was \ + created).\ + \n\n\ + [`DateTime` scalar][1] compliant.\ + \n\n\ + See also [`time::OffsetDateTime`][2] for details.\ + \n\n\ + [0]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6\n\ + [1]: https://graphql-scalars.dev/docs/scalars/date-time\n\ + [2]: https://docs.rs/time/*/time/struct.OffsetDateTime.html" +)] +impl GraphQLScalar for DateTime { + fn resolve(&self) -> Value { + Value::scalar( + self.format(&Rfc3339) + .unwrap_or_else(|e| panic!("Failed to format `DateTime`: {}", e)), + ) + } + + fn from_input_value(v: &InputValue) -> Result { + v.as_string_value() + .ok_or_else(|| format!("Expected `String`, found: {}", v)) + .and_then(|s| { + Self::parse(s, &Rfc3339).map_err(|e| format!("Invalid `DateTime`: {}", e)) + }) + } + + fn from_str<'a>(value: ScalarToken<'a>) -> ParseScalarResult<'a, S> { + if let ScalarToken::String(s) = value { + Ok(S::from(s.to_owned())) + } else { + Err(ParseError::UnexpectedToken(Token::Scalar(value))) + } + } +} + +/// Format of a [`UtcOffset` scalar][1]. +/// +/// [1]: https://graphql-scalars.dev/docs/scalars/utc-offset +const UTC_OFFSET_FORMAT: &[FormatItem<'_>] = + format_description!("[offset_hour sign:mandatory]:[offset_minute]"); + +#[graphql_scalar( + description = "Offset from UTC in `±hh:mm` format. See [list of database \ + time zones][0].\ + \n\n\ + [`UtcOffset` scalar][1] compliant.\ + \n\n\ + See also [`time::UtcOffset`][2] for details.\ + \n\n\ + [0]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones\n\ + [1]: https://graphql-scalars.dev/docs/scalars/utc-offset\n\ + [2]: https://docs.rs/time/*/time/struct.UtcOffset.html" +)] +impl GraphQLScalar for UtcOffset { + fn resolve(&self) -> Value { + Value::scalar( + self.format(UTC_OFFSET_FORMAT) + .unwrap_or_else(|e| panic!("Failed to format `UtcOffset`: {}", e)), + ) + } + + fn from_input_value(v: &InputValue) -> Result { + v.as_string_value() + .ok_or_else(|| format!("Expected `String`, found: {}", v)) + .and_then(|s| { + Self::parse(s, UTC_OFFSET_FORMAT).map_err(|e| format!("Invalid `UtcOffset`: {}", e)) + }) + } + + fn from_str<'a>(value: ScalarToken<'a>) -> ParseScalarResult<'a, S> { + if let ScalarToken::String(s) = value { + Ok(S::from(s.to_owned())) + } else { + Err(ParseError::UnexpectedToken(Token::Scalar(value))) + } + } +} + +#[cfg(test)] +mod date_test { + use time::macros::date; + + use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _}; + + use super::Date; + + #[test] + fn parses_correct_input() { + for (raw, expected) in [ + ("1996-12-19", date!(1996 - 12 - 19)), + ("1564-01-30", date!(1564 - 01 - 30)), + ] { + let input: InputValue = graphql_input_value!((raw)); + let parsed = Date::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{}`: {}", + raw, + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {}", raw); + } + } + + #[test] + fn fails_on_invalid_input() { + for input in [ + graphql_input_value!("1996-13-19"), + graphql_input_value!("1564-01-61"), + graphql_input_value!("2021-11-31"), + graphql_input_value!("11-31"), + graphql_input_value!("2021-11"), + graphql_input_value!("2021"), + graphql_input_value!("31"), + graphql_input_value!("i'm not even a date"), + graphql_input_value!(2.32), + graphql_input_value!(1), + graphql_input_value!(null), + graphql_input_value!(false), + ] { + let input: InputValue = input; + let parsed = Date::from_input_value(&input); + + assert!(parsed.is_err(), "allows input: {:?}", input); + } + } + + #[test] + fn formats_correctly() { + for (val, expected) in [ + (date!(1996 - 12 - 19), graphql_input_value!("1996-12-19")), + (date!(1564 - 01 - 30), graphql_input_value!("1564-01-30")), + (date!(2020 - W 01 - 3), graphql_input_value!("2020-01-01")), + (date!(2020 - 001), graphql_input_value!("2020-01-01")), + ] { + let actual: InputValue = val.to_input_value(); + + assert_eq!(actual, expected, "on value: {}", val); + } + } +} + +#[cfg(test)] +mod local_time_test { + use time::macros::time; + + use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _}; + + use super::LocalTime; + + #[test] + fn parses_correct_input() { + for (raw, expected) in [ + ("14:23:43", time!(14:23:43)), + ("14:00:00", time!(14:00)), + ("14:00", time!(14:00)), + ("14:32", time!(14:32:00)), + ("14:00:00.000", time!(14:00)), + ("14:23:43.345", time!(14:23:43.345)), + ] { + let input: InputValue = graphql_input_value!((raw)); + let parsed = LocalTime::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{}`: {}", + raw, + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {}", raw); + } + } + + #[test] + fn fails_on_invalid_input() { + for input in [ + graphql_input_value!("12"), + graphql_input_value!("12:"), + graphql_input_value!("56:34:22"), + graphql_input_value!("23:78:43"), + graphql_input_value!("23:78:"), + graphql_input_value!("23:18:99"), + graphql_input_value!("23:18:22.4351"), + graphql_input_value!("23:18:22."), + graphql_input_value!("23:18:22.3"), + graphql_input_value!("23:18:22.03"), + graphql_input_value!("22.03"), + graphql_input_value!("24:00"), + graphql_input_value!("24:00:00"), + graphql_input_value!("24:00:00.000"), + graphql_input_value!("i'm not even a time"), + graphql_input_value!(2.32), + graphql_input_value!(1), + graphql_input_value!(null), + graphql_input_value!(false), + ] { + let input: InputValue = input; + let parsed = LocalTime::from_input_value(&input); + + assert!(parsed.is_err(), "allows input: {:?}", input); + } + } + + #[test] + fn formats_correctly() { + for (val, expected) in [ + (time!(1:02:03.004_005), graphql_input_value!("01:02:03.004")), + (time!(0:00), graphql_input_value!("00:00:00")), + (time!(12:00 pm), graphql_input_value!("12:00:00")), + (time!(1:02:03), graphql_input_value!("01:02:03")), + ] { + let actual: InputValue = val.to_input_value(); + + assert_eq!(actual, expected, "on value: {}", val); + } + } +} + +#[cfg(test)] +mod local_date_time_test { + use time::macros::datetime; + + use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _}; + + use super::LocalDateTime; + + #[test] + fn parses_correct_input() { + for (raw, expected) in [ + ("1996-12-19 14:23:43", datetime!(1996-12-19 14:23:43)), + ("1564-01-30 14:00:00", datetime!(1564-01-30 14:00)), + ] { + let input: InputValue = graphql_input_value!((raw)); + let parsed = LocalDateTime::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{}`: {}", + raw, + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {}", raw); + } + } + + #[test] + fn fails_on_invalid_input() { + for input in [ + graphql_input_value!("12"), + graphql_input_value!("12:"), + graphql_input_value!("56:34:22"), + graphql_input_value!("56:34:22.000"), + graphql_input_value!("1996-12-1914:23:43"), + graphql_input_value!("1996-12-19T14:23:43"), + graphql_input_value!("1996-12-19 14:23:43Z"), + graphql_input_value!("1996-12-19 14:23:43.543"), + graphql_input_value!("1996-12-19 14:23"), + graphql_input_value!("1996-12-19 14:23:1"), + graphql_input_value!("1996-12-19 14:23:"), + graphql_input_value!("1996-12-19 23:78:43"), + graphql_input_value!("1996-12-19 23:18:99"), + graphql_input_value!("1996-12-19 24:00:00"), + graphql_input_value!("1996-12-19 99:02:13"), + graphql_input_value!("i'm not even a datetime"), + graphql_input_value!(2.32), + graphql_input_value!(1), + graphql_input_value!(null), + graphql_input_value!(false), + ] { + let input: InputValue = input; + let parsed = LocalDateTime::from_input_value(&input); + + assert!(parsed.is_err(), "allows input: {:?}", input); + } + } + + #[test] + fn formats_correctly() { + for (val, expected) in [ + ( + datetime!(1996-12-19 12:00 am), + graphql_input_value!("1996-12-19 00:00:00"), + ), + ( + datetime!(1564-01-30 14:00), + graphql_input_value!("1564-01-30 14:00:00"), + ), + ] { + let actual: InputValue = val.to_input_value(); + + assert_eq!(actual, expected, "on value: {}", val); + } + } +} + +#[cfg(test)] +mod date_time_test { + use time::macros::datetime; + + use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _}; + + use super::DateTime; + + #[test] + fn parses_correct_input() { + for (raw, expected) in [ + ( + "2014-11-28T21:00:09+09:00", + datetime!(2014-11-28 21:00:09 +9), + ), + ("2014-11-28T21:00:09Z", datetime!(2014-11-28 21:00:09 +0)), + ( + "2014-11-28T21:00:09+00:00", + datetime!(2014-11-28 21:00:09 +0), + ), + ( + "2014-11-28T21:00:09.05+09:00", + datetime!(2014-11-28 21:00:09.05 +9), + ), + ] { + let input: InputValue = graphql_input_value!((raw)); + let parsed = DateTime::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{}`: {}", + raw, + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {}", raw); + } + } + + #[test] + fn fails_on_invalid_input() { + for input in [ + graphql_input_value!("12"), + graphql_input_value!("12:"), + graphql_input_value!("56:34:22"), + graphql_input_value!("56:34:22.000"), + graphql_input_value!("1996-12-1914:23:43"), + graphql_input_value!("1996-12-19 14:23:43Z"), + graphql_input_value!("1996-12-19T14:23:43"), + graphql_input_value!("1996-12-19T14:23:43ZZ"), + graphql_input_value!("1996-12-19T14:23:43.543"), + graphql_input_value!("1996-12-19T14:23"), + graphql_input_value!("1996-12-19T14:23:1"), + graphql_input_value!("1996-12-19T14:23:"), + graphql_input_value!("1996-12-19T23:78:43Z"), + graphql_input_value!("1996-12-19T23:18:99Z"), + graphql_input_value!("1996-12-19T24:00:00Z"), + graphql_input_value!("1996-12-19T99:02:13Z"), + graphql_input_value!("1996-12-19T99:02:13Z"), + graphql_input_value!("1996-12-19T12:02:13+4444444"), + graphql_input_value!("i'm not even a datetime"), + graphql_input_value!(2.32), + graphql_input_value!(1), + graphql_input_value!(null), + graphql_input_value!(false), + ] { + let input: InputValue = input; + let parsed = DateTime::from_input_value(&input); + + assert!(parsed.is_err(), "allows input: {:?}", input); + } + } + + #[test] + fn formats_correctly() { + for (val, expected) in [ + ( + datetime!(1996-12-19 12:00 am UTC), + graphql_input_value!("1996-12-19T00:00:00Z"), + ), + ( + datetime!(1564-01-30 14:00 +9), + graphql_input_value!("1564-01-30T14:00:00+09:00"), + ), + ] { + let actual: InputValue = val.to_input_value(); + + assert_eq!(actual, expected, "on value: {}", val); + } + } +} + +#[cfg(test)] +mod utc_offset_test { + use time::macros::offset; + + use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _}; + + use super::UtcOffset; + + #[test] + fn parses_correct_input() { + for (raw, expected) in [ + ("+00:00", offset!(+0)), + ("-00:00", offset!(-0)), + ("+10:00", offset!(+10)), + ("-07:30", offset!(-7:30)), + ("+14:00", offset!(+14)), + ("-12:00", offset!(-12)), + ] { + let input: InputValue = graphql_input_value!((raw)); + let parsed = UtcOffset::from_input_value(&input); + + assert!( + parsed.is_ok(), + "failed to parse `{}`: {}", + raw, + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {}", raw); + } + } + + #[test] + fn fails_on_invalid_input() { + for input in [ + graphql_input_value!("12"), + graphql_input_value!("12:"), + graphql_input_value!("12:00"), + graphql_input_value!("+12:"), + graphql_input_value!("+12:0"), + graphql_input_value!("+12:00:34"), + graphql_input_value!("+12"), + graphql_input_value!("-12"), + graphql_input_value!("-12:"), + graphql_input_value!("-12:0"), + graphql_input_value!("-12:00:32"), + graphql_input_value!("-999:00"), + graphql_input_value!("+999:00"), + graphql_input_value!("i'm not even an offset"), + graphql_input_value!(2.32), + graphql_input_value!(1), + graphql_input_value!(null), + graphql_input_value!(false), + ] { + let input: InputValue = input; + let parsed = UtcOffset::from_input_value(&input); + + assert!(parsed.is_err(), "allows input: {:?}", input); + } + } + + #[test] + fn formats_correctly() { + for (val, expected) in [ + (offset!(+1), graphql_input_value!("+01:00")), + (offset!(+0), graphql_input_value!("+00:00")), + (offset!(-2:30), graphql_input_value!("-02:30")), + ] { + let actual: InputValue = val.to_input_value(); + + assert_eq!(actual, expected, "on value: {}", val); + } + } +} + +#[cfg(test)] +mod integration_test { + use time::macros::{date, datetime, offset, time}; + + use crate::{ + execute, graphql_object, graphql_value, graphql_vars, + schema::model::RootNode, + types::scalars::{EmptyMutation, EmptySubscription}, + }; + + use super::{Date, DateTime, LocalDateTime, LocalTime, UtcOffset}; + + #[tokio::test] + async fn serializes() { + struct Root; + + #[graphql_object] + impl Root { + fn date() -> Date { + date!(2015 - 03 - 14) + } + + fn local_time() -> LocalTime { + time!(16:07:08) + } + + fn local_date_time() -> LocalDateTime { + datetime!(2016-07-08 09:10:11) + } + + fn date_time() -> DateTime { + datetime!(1996-12-19 16:39:57 -8) + } + + fn utc_offset() -> UtcOffset { + offset!(+11:30) + } + } + + const DOC: &str = r#"{ + date + localTime + localDateTime + dateTime, + utcOffset, + }"#; + + let schema = RootNode::new( + Root, + EmptyMutation::<()>::new(), + EmptySubscription::<()>::new(), + ); + + assert_eq!( + execute(DOC, None, &schema, &graphql_vars! {}, &()).await, + Ok(( + graphql_value!({ + "date": "2015-03-14", + "localTime": "16:07:08", + "localDateTime": "2016-07-08 09:10:11", + "dateTime": "1996-12-19T16:39:57-08:00", + "utcOffset": "+11:30", + }), + vec![], + )), + ); + } +} diff --git a/juniper/src/lib.rs b/juniper/src/lib.rs index a986d540..40687972 100644 --- a/juniper/src/lib.rs +++ b/juniper/src/lib.rs @@ -59,6 +59,7 @@ your Schemas automatically. * [uuid][uuid] * [url][url] * [chrono][chrono] +* [time][time] * [bson][bson] ### Web Frameworks @@ -87,6 +88,7 @@ Juniper has not reached 1.0 yet, thus some API instability should be expected. [uuid]: https://crates.io/crates/uuid [url]: https://crates.io/crates/url [chrono]: https://crates.io/crates/chrono +[time]: https://crates.io/crates/time [bson]: https://crates.io/crates/bson */