Repository: graphql-rust/juniper (language: rust)

Issue:
Provide support for Jiff date/time types
## Description

This is a proposal to add first-class support for the data types from [Jiff](https://crates.io/crates/jiff). First-class support is necessary, and an external crate would be much more inconvenient to use, because of Rust's orphan rule as outlined in https://github.com/graphql-rust/juniper/issues/87#issuecomment-325918618.

This should cover the following date/time types:

### Timestamps

- [`Timestamp`](https://docs.rs/jiff/0.1.4/jiff/struct.Timestamp.html), serialized as RFC 3339 (ISO 8601) date/time with UTC time zone, GraphQL scalar [`DateTime`](https://the-guild.dev/graphql/scalars/docs/scalars/date-time), e.g. `2024-06-19T19:22:45Z`
- [`Zoned`](https://docs.rs/jiff/0.1.4/jiff/struct.Zoned.html), serialized as RFC 9557 date/time with time zone, GraphQL scalar `ZonedDateTime`[^1], e.g. `2024-07-04T08:39:00-04:00[America/New_York]`

[^1]: This always includes the time zone specifier and uses RFC ~8536~ 9557. This format seems not widely used yet. It is also not a valid input for GraphQL scalar `DateTime`, therefore we serialize it as the GraphQL scalar `ZonedDateTime` which has not been stabilized, or even defined elsewhere, yet.

### Wall-clocked

- [`Date`](https://docs.rs/jiff/0.1.4/jiff/civil/struct.Date.html), serialized as date, without time zone, GraphQL scalar [`LocalDate`](https://the-guild.dev/graphql/scalars/docs/scalars/local-date), e.g. `2024-06-19`
- [`Time`](https://docs.rs/jiff/0.1.4/jiff/civil/struct.Time.html), serialized as time, without time zone, GraphQL scalar [`LocalTime`](https://the-guild.dev/graphql/scalars/docs/scalars/local-time), e.g. `15:22:45`
- [`DateTime`](https://docs.rs/jiff/0.1.4/jiff/civil/struct.DateTime.html), serialized as date/time, without time zone, GraphQL scalar [`LocalDateTime`](https://the-guild.dev/graphql/scalars/docs/scalars/local-date-time), e.g. `2024-06-19T15:22:45`

### Durations

- [`Span`](https://docs.rs/jiff/0.1.4/jiff/struct.Span.html), serialized as ISO 8601 duration, GraphQL scalar [`Duration`](https://the-guild.dev/graphql/scalars/docs/scalars/duration), e.g. `P5dT8h1m`

### Time zones

- [`TimeZone`](https://docs.rs/jiff/0.1.5/jiff/tz/struct.TimeZone.html), serialized as IANA time zone identifier, GraphQL scalar [`TimeZone`](https://the-guild.dev/graphql/scalars/docs/scalars/time-zone), e.g. `America/New_York`

Where types overlap, the resulting serialization would be identical to the one for the [`chrono`](https://crates.io/crates/chrono) and [`time`](https://crates.io/crates/time) crates, with the difference of using GraphQL scalar `LocalDate` instead of [`Date`](https://the-guild.dev/graphql/scalars/docs/scalars/date).[^2]

[^2]: `LocalDate` seems to be the more appropriate type. I am not sure why `chrono` and `time` integration chose to use `Date` instead.

I am aware that Jiff is a very new library but I think it has great potential in the ecosystem. Having dealt with date/time intricacies in several projects, I regard it as one of the top-contenders for following or eventually even superseding `chrono`/`time`.

~I am willing to provide a PR if this proposal is accepted.~ See #1271 for a PR that implements these features.

See #1272 for a PR that adds support for the types `Zoned` and `TimeZone`.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 762058149..12c8e3e4d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,6 +107,7 @@ jobs:
           - { feature: chrono-clock, crate: juniper }
           - { feature: chrono-tz, crate: juniper }
           - { feature: expose-test-schema, crate: juniper }
+          - { feature: jiff, crate: juniper }
           - { feature: rust_decimal, crate: juniper }
           - { feature: schema-language, crate: juniper }
           - { feature: time, crate: juniper }
diff --git a/README.md b/README.md
index 3f8f41008..2845a4022 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,7 @@ your Schemas automatically.
 - [url][url]
 - [chrono][chrono]
 - [chrono-tz][chrono-tz]
+- [jiff][jiff]
 - [time][time]
 - [bson][bson]
 
@@ -119,6 +120,7 @@ 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
+[jiff]: https://crates.io/crates/jiff
 [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/book/src/introduction.md b/book/src/introduction.md
index c23e773c1..6d0cf218f 100644
--- a/book/src/introduction.md
+++ b/book/src/introduction.md
@@ -31,6 +31,7 @@ Introduction
 - [`bigdecimal`]
 - [`bson`]
 - [`chrono`], [`chrono-tz`]
+- [`jiff`]
 - [`rust_decimal`]
 - [`time`]
 - [`url`]
@@ -63,6 +64,7 @@ Introduction
 [`bson`]: https://docs.rs/bson
 [`chrono`]: https://docs.rs/chrono
 [`chrono-tz`]: https://docs.rs/chrono-tz
+[`jiff`]: https://docs.rs/jiff
 [`juniper`]: https://docs.rs/juniper
 [`juniper_actix`]: https://docs.rs/juniper_actix
 [`juniper_axum`]: https://docs.rs/juniper_axum
diff --git a/book/src/types/scalars.md b/book/src/types/scalars.md
index ca1f27067..000f68568 100644
--- a/book/src/types/scalars.md
+++ b/book/src/types/scalars.md
@@ -396,6 +396,11 @@ mod date_scalar {
 | [`chrono::DateTime`]        | [`DateTime`]     | [`chrono`]       |
 | [`chrono_tz::Tz`]           | `TimeZone`       | [`chrono-tz`]    |
 | [`Decimal`]                 | `Decimal`        | [`rust_decimal`] |
+| [`jiff::civil::Date`]       | [`LocalDate`]    | [`jiff`]         |
+| [`jiff::civil::Time`]       | [`LocalTime`]    | [`jiff`]         |
+| [`jiff::civil::DateTime`]   | `LocalDateTime`  | [`jiff`]         |
+| [`jiff::Timestamp`]         | [`DateTime`]     | [`jiff`]         |
+| [`jiff::Span`]              | [`Duration`]     | [`jiff`]         |
 | [`time::Date`]              | [`Date`]         | [`time`]         |
 | [`time::Time`]              | [`LocalTime`]    | [`time`]         |
 | [`time::PrimitiveDateTime`] | `LocalDateTime`  | [`time`]         |
@@ -422,7 +427,15 @@ mod date_scalar {
 [`Date`]: https://graphql-scalars.dev/docs/scalars/date
 [`DateTime`]: https://graphql-scalars.dev/docs/scalars/date-time
 [`Decimal`]: https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html
+[`Duration`]: https://graphql-scalars.dev/docs/scalars/duration
 [`ID`]: https://spec.graphql.org/October2021#sec-ID
+[`jiff`]: https://docs.rs/jiff
+[`jiff::civil::Date`]: https://docs.rs/jiff/latest/jiff/civil/struct.Date.html
+[`jiff::civil::DateTime`]: https://docs.rs/jiff/latest/jiff/civil/struct.DateTime.html
+[`jiff::civil::Time`]: https://docs.rs/jiff/latest/jiff/civil/struct.Time.html
+[`jiff::Span`]: https://docs.rs/jiff/latest/jiff/struct.Span.html
+[`jiff::Timestamp`]: https://docs.rs/jiff/latest/jiff/struct.Timestamp.html
+[`LocalDate`]: https://graphql-scalars.dev/docs/scalars/local-date
 [`LocalTime`]: https://graphql-scalars.dev/docs/scalars/local-time
 [`rust_decimal`]: https://docs.rs/rust_decimal
 [`ScalarValue`]: https://docs.rs/juniper/0.16.1/juniper/trait.ScalarValue.html
diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md
index cc133bf49..e3d75e65b 100644
--- a/juniper/CHANGELOG.md
+++ b/juniper/CHANGELOG.md
@@ -15,11 +15,16 @@ All user visible changes to `juniper` crate will be documented in this file. Thi
 - Upgraded [`chrono-tz` crate] integration to [0.9 version](https://github.com/chronotope/chrono-tz/releases/tag/v0.9.0). ([#1252])
 - Bumped up [MSRV] to 1.75. ([#1272])
 
+### Added
+
+- [`jiff` crate] integration behind `jiff` [Cargo feature]. ([#1271])
+
 ### Changed
 
 - Updated [GraphiQL] to [3.5.0 version](https://github.com/graphql/graphiql/blob/graphiql%403.5.0/packages/graphiql/CHANGELOG.md#350). ([#1274])
 
 [#1252]: /../../pull/1252
+[#1271]: /../../pull/1271
 [#1272]: /../../pull/1272
 [#1274]: /../../pull/1274
 
@@ -222,6 +227,7 @@ See [old CHANGELOG](/../../blob/juniper-v0.15.12/juniper/CHANGELOG.md).
 [`bson` crate]: https://docs.rs/bson
 [`chrono` crate]: https://docs.rs/chrono
 [`chrono-tz` crate]: https://docs.rs/chrono-tz
+[`jiff` crate]: https://docs.rs/jiff
 [`time` crate]: https://docs.rs/time
 [Cargo feature]: https://doc.rust-lang.org/cargo/reference/features.html
 [`graphql-transport-ws` GraphQL over WebSocket Protocol]: https://github.com/enisdenjo/graphql-ws/v5.14.0/PROTOCOL.md 
diff --git a/juniper/Cargo.toml b/juniper/Cargo.toml
index d7f8155f1..9f0401a28 100644
--- a/juniper/Cargo.toml
+++ b/juniper/Cargo.toml
@@ -33,6 +33,7 @@ chrono = ["dep:chrono"]
 chrono-clock = ["chrono", "chrono/clock"]
 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"]
 rust_decimal = ["dep:rust_decimal"]
 schema-language = ["dep:graphql-parser", "dep:void"]
@@ -52,6 +53,7 @@ fnv = "1.0.5"
 futures = { version = "0.3.22", features = ["alloc"], default-features = false }
 graphql-parser = { version = "0.4", optional = true }
 indexmap = { version = "2.0", features = ["serde"] }
+jiff = { version = "0.1.5", features = ["alloc"], default-features = false, optional = true }
 juniper_codegen = { version = "0.16.0", path = "../juniper_codegen" }
 rust_decimal = { version = "1.20", default-features = false, optional = true }
 ryu = { version = "1.0", optional = true }
diff --git a/juniper/README.md b/juniper/README.md
index eed7abcae..307fa053a 100644
--- a/juniper/README.md
+++ b/juniper/README.md
@@ -48,6 +48,7 @@ As an exception to other [GraphQL] libraries for other languages, [Juniper] buil
 - [`bigdecimal`]
 - [`bson`]
 - [`chrono`], [`chrono-tz`]
+- [`jiff`]
 - [`rust_decimal`]
 - [`time`]
 - [`url`]
@@ -85,6 +86,7 @@ This project is licensed under [BSD 2-Clause License](https://github.com/graphql
 [`bson`]: https://docs.rs/bson
 [`chrono`]: https://docs.rs/chrono
 [`chrono-tz`]: https://docs.rs/chrono-tz
+[`jiff`]: https://docs.rs/jiff
 [`juniper_actix`]: https://docs.rs/juniper_actix
 [`juniper_axum`]: https://docs.rs/juniper_axum
 [`juniper_hyper`]: https://docs.rs/juniper_hyper
diff --git a/juniper/src/integrations/jiff.rs b/juniper/src/integrations/jiff.rs
new file mode 100644
index 000000000..197454d11
--- /dev/null
+++ b/juniper/src/integrations/jiff.rs
@@ -0,0 +1,733 @@
+//! GraphQL support for [`jiff`] crate types.
+//!
+//! # Supported types
+//!
+//! | Rust type           | Format                | GraphQL scalar        |
+//! |---------------------|-----------------------|-----------------------|
+//! | [`civil::Date`]     | `yyyy-MM-dd`          | [`LocalDate`][s1]     |
+//! | [`civil::Time`]     | `HH:mm[:ss[.SSS]]`    | [`LocalTime`][s2]     |
+//! | [`civil::DateTime`] | `yyyy-MM-ddTHH:mm:ss` | [`LocalDateTime`][s3] |
+//! | [`Timestamp`]       | [RFC 3339] string     | [`DateTime`][s4]      |
+//! | [`Span`]            | [ISO 8601] duration   | [`Duration`][s5]      |
+//!
+//! # Unsupported types
+//!
+//! [`Zoned`] is not supported because the GraphQL scalar [`DateTime`][s4] only supports time zone
+//! offsets but no IANA time zone names (as in `2024-08-10T23:14:00-04:00[America/New_York]`, cf.
+//! [RFC 9557]). Serializing such values would incur a loss of information with unexpected and
+//! subtle consequences (a fixed offset would only _seem_ to work in most cases).
+//!
+//! [`civil::Date`]: jiff::civil::Date
+//! [`civil::DateTime`]: jiff::civil::DateTime
+//! [`civil::Time`]: jiff::civil::Time
+//! [`Span`]: jiff::Span
+//! [`Timestamp`]: jiff::Timestamp
+//! [`Zoned`]: jiff::Zoned
+//! [ISO 8601]: https://en.wikipedia.org/wiki/ISO_8601#Durations
+//! [RFC 3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
+//! [RFC 9557]: https://datatracker.ietf.org/doc/html/rfc9557#section-4.1
+//! [s1]: https://graphql-scalars.dev/docs/scalars/local-date
+//! [s2]: https://graphql-scalars.dev/docs/scalars/local-time
+//! [s3]: https://graphql-scalars.dev/docs/scalars/local-date-time
+//! [s4]: https://graphql-scalars.dev/docs/scalars/date-time
+//! [s5]: https://graphql-scalars.dev/docs/scalars/duration
+
+use crate::{graphql_scalar, InputValue, ScalarValue, Value};
+
+/// Representation of a civil date in the Gregorian calendar.
+///
+/// Corresponds to a triple of year, month and day. Every value is guaranteed to be a valid
+/// Gregorian calendar date. For example, both `2023-02-29` and `2023-11-31` are invalid and cannot
+/// be represented.
+///
+/// [`LocalDate` scalar][1] compliant.
+///
+/// See also [`jiff::civil::Date`][2] for details.
+///
+/// [1]: https://graphql-scalars.dev/docs/scalars/local-date
+/// [2]: https://docs.rs/jiff/latest/jiff/civil/struct.Date.html
+#[graphql_scalar(
+    with = local_date,
+    parse_token(String),
+    specified_by_url = "https://graphql-scalars.dev/docs/scalars/local-date",
+)]
+pub type LocalDate = jiff::civil::Date;
+
+mod local_date {
+    use super::*;
+
+    /// Format of a [`LocalDate` scalar][1].
+    ///
+    /// [1]: https://graphql-scalars.dev/docs/scalars/local-date
+    const FORMAT: &str = "%Y-%m-%d";
+
+    pub(super) fn to_output<S>(v: &LocalDate) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(v.strftime(FORMAT).to_string())
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<LocalDate, String>
+    where
+        S: ScalarValue,
+    {
+        v.as_string_value()
+            .ok_or_else(|| format!("Expected `String`, found: {v}"))
+            .and_then(|s| {
+                LocalDate::strptime(FORMAT, s).map_err(|e| format!("Invalid `LocalDate`: {e}"))
+            })
+    }
+}
+
+/// Representation of a civil "wall clock" time.
+///
+/// Conceptually, corresponds to the typical hours and minutes that you might see on a clock. This
+/// type also contains the second and fractional subsecond (to nanosecond precision) associated with
+/// a time.
+///
+/// [`LocalTime` scalar][1] compliant.
+///
+/// See also [`jiff::civil::Time`][2] for details.
+///
+/// [1]: https://graphql-scalars.dev/docs/scalars/local-time
+/// [2]: https://docs.rs/jiff/latest/jiff/civil/struct.Time.html
+#[graphql_scalar(
+    with = local_time,
+    parse_token(String),
+    specified_by_url = "https://graphql-scalars.dev/docs/scalars/local-time",
+)]
+pub type LocalTime = jiff::civil::Time;
+
+mod local_time {
+    use super::*;
+
+    /// Full format of a [`LocalTime` scalar][1].
+    ///
+    /// [1]: https://graphql-scalars.dev/docs/scalars/local-time
+    const FORMAT: &str = "%H:%M:%S%.3f";
+
+    /// Format of a [`LocalTime` scalar][1] without milliseconds.
+    ///
+    /// [1]: https://graphql-scalars.dev/docs/scalars/local-time
+    const FORMAT_NO_MILLIS: &str = "%H:%M:%S";
+
+    /// Format of a [`LocalTime` scalar][1] without seconds.
+    ///
+    /// [1]: https://graphql-scalars.dev/docs/scalars/local-time
+    const FORMAT_NO_SECS: &str = "%H:%M";
+
+    pub(super) fn to_output<S>(v: &LocalTime) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(
+            if v.subsec_nanosecond() == 0 {
+                v.strftime(FORMAT_NO_MILLIS)
+            } else {
+                v.strftime(FORMAT)
+            }
+            .to_string(),
+        )
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<LocalTime, String>
+    where
+        S: ScalarValue,
+    {
+        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.
+                LocalTime::strptime(FORMAT_NO_MILLIS, s)
+                    .or_else(|_| LocalTime::strptime(FORMAT_NO_SECS, s))
+                    .or_else(|_| LocalTime::strptime(FORMAT, s))
+                    .map_err(|e| format!("Invalid `LocalTime`: {e}"))
+            })
+    }
+}
+
+/// Representation of a civil datetime in the Gregorian calendar.
+///
+/// Corresponds to a pair of a `LocalDate` and a `LocalTime`. That is, a datetime contains a year,
+/// month, day, hour, minute, second and the fractional number of nanoseconds.
+///
+/// Value is guaranteed to contain a valid date and time. For example, neither `2023-02-29T00:00:00`
+/// nor `2015-06-30T23:59:60` are valid.
+///
+/// [`LocalDateTime` scalar][1] compliant.
+///
+/// See also [`jiff::civil::DateTime`][2] for details.
+///
+/// [1]: https://graphql-scalars.dev/docs/scalars/local-date-time
+/// [2]: https://docs.rs/jiff/latest/jiff/civil/struct.DateTime.html
+#[graphql_scalar(
+    with = local_date_time,
+    parse_token(String),
+    specified_by_url = "https://graphql-scalars.dev/docs/scalars/local-date-time",
+)]
+pub type LocalDateTime = jiff::civil::DateTime;
+
+mod local_date_time {
+    use super::*;
+
+    /// Format of a [`LocalDateTime` scalar][1].
+    ///
+    /// [1]: https://graphql-scalars.dev/docs/scalars/local-date-time
+    const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
+
+    pub(super) fn to_output<S>(v: &LocalDateTime) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(v.strftime(FORMAT).to_string())
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<LocalDateTime, String>
+    where
+        S: ScalarValue,
+    {
+        v.as_string_value()
+            .ok_or_else(|| format!("Expected `String`, found: {v}"))
+            .and_then(|s| {
+                LocalDateTime::strptime(FORMAT, s)
+                    .map_err(|e| format!("Invalid `LocalDateTime`: {e}"))
+            })
+    }
+}
+
+/// Instant in time represented as the number of nanoseconds since the Unix epoch.
+///
+/// Always in UTC.
+///
+/// [`DateTime` scalar][1] compliant.
+///
+/// See also [`jiff::Timestamp`][2] for details.
+///
+/// [1]: https://graphql-scalars.dev/docs/scalars/date-time
+/// [2]: https://docs.rs/jiff/latest/jiff/struct.Timestamp.html
+#[graphql_scalar(
+    with = date_time,
+    parse_token(String),
+    specified_by_url = "https://graphql-scalars.dev/docs/scalars/date-time",
+)]
+pub type DateTime = jiff::Timestamp;
+
+mod date_time {
+    use std::str::FromStr as _;
+
+    use super::*;
+
+    /// Format of a [`DateTime` scalar][1].
+    ///
+    /// [1]: https://graphql-scalars.dev/docs/scalars/date-time
+    const FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.fZ";
+
+    pub(super) fn to_output<S>(v: &DateTime) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(v.strftime(FORMAT).to_string())
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<DateTime, String>
+    where
+        S: ScalarValue,
+    {
+        v.as_string_value()
+            .ok_or_else(|| format!("Expected `String`, found: {v}"))
+            .and_then(|s| DateTime::from_str(s).map_err(|e| format!("Invalid `DateTime`: {e}")))
+    }
+}
+
+/// Span of time represented via a mixture of calendar and clock units.
+///
+/// Represents a duration of time in units of years, months, weeks, days, hours, minutes, seconds,
+/// milliseconds, microseconds and nanoseconds.
+///
+/// [`Duration` scalar][1] compliant.
+///
+/// See also [`jiff::Span`][2] for details.
+///
+/// [1]: https://graphql-scalars.dev/docs/scalars/duration
+/// [2]: https://docs.rs/jiff/latest/jiff/struct.Span.html
+#[graphql_scalar(
+    with = duration,
+    parse_token(String),
+    specified_by_url = "https://graphql-scalars.dev/docs/scalars/duration",
+)]
+pub type Duration = jiff::Span;
+
+mod duration {
+    use std::str::FromStr as _;
+
+    use super::*;
+
+    pub(super) fn to_output<S>(v: &Duration) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(v.to_string())
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<Duration, String>
+    where
+        S: ScalarValue,
+    {
+        v.as_string_value()
+            .ok_or_else(|| format!("Expected `String`, found: {v}"))
+            .and_then(|s| Duration::from_str(s).map_err(|e| format!("Invalid `Duration`: {e}")))
+    }
+}
+
+#[cfg(test)]
+mod local_date_test {
+    use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
+
+    use super::LocalDate;
+
+    #[test]
+    fn parses_correct_input() {
+        for (raw, expected) in [
+            ("1996-12-19", LocalDate::constant(1996, 12, 19)),
+            ("1564-01-30", LocalDate::constant(1564, 01, 30)),
+        ] {
+            let input: InputValue = graphql_input_value!((raw));
+            let parsed = LocalDate::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 = LocalDate::from_input_value(&input);
+
+            assert!(parsed.is_err(), "allows input: {input:?}");
+        }
+    }
+
+    #[test]
+    fn formats_correctly() {
+        for (val, expected) in [
+            (
+                LocalDate::constant(1996, 12, 19),
+                graphql_input_value!("1996-12-19"),
+            ),
+            (
+                LocalDate::constant(1564, 01, 30),
+                graphql_input_value!("1564-01-30"),
+            ),
+            (
+                LocalDate::constant(2020, 01, 01),
+                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 crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
+
+    use super::LocalTime;
+
+    #[test]
+    fn parses_correct_input() {
+        for (raw, expected) in [
+            ("14:23:43", LocalTime::constant(14, 23, 43, 000_000_000)),
+            ("14:00:00", LocalTime::constant(14, 00, 00, 000_000_000)),
+            ("14:00", LocalTime::constant(14, 00, 00, 000_000_000)),
+            ("14:32", LocalTime::constant(14, 32, 00, 000_000_000)),
+            ("14:00:00.000", LocalTime::constant(14, 00, 00, 000_000_000)),
+            ("14:23:43.345", LocalTime::constant(14, 23, 43, 345_000_000)),
+        ] {
+            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."),
+            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 [
+            (
+                LocalTime::constant(1, 2, 3, 4_005_000),
+                graphql_input_value!("01:02:03.004"),
+            ),
+            (
+                LocalTime::constant(0, 0, 0, 0),
+                graphql_input_value!("00:00:00"),
+            ),
+            (
+                LocalTime::constant(12, 0, 0, 0),
+                graphql_input_value!("12:00:00"),
+            ),
+            (
+                LocalTime::constant(1, 2, 3, 0),
+                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 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",
+                LocalDateTime::constant(1996, 12, 19, 14, 23, 43, 0),
+            ),
+            (
+                "1564-01-30 14:00:00",
+                LocalDateTime::constant(1564, 1, 30, 14, 00, 00, 0),
+            ),
+        ] {
+            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-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:"),
+            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 [
+            (
+                LocalDateTime::constant(1996, 12, 19, 0, 0, 0, 0),
+                graphql_input_value!("1996-12-19 00:00:00"),
+            ),
+            (
+                LocalDateTime::constant(1564, 1, 30, 14, 0, 0, 0),
+                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 jiff::{civil, tz::TimeZone};
+
+    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",
+                civil::DateTime::constant(2014, 11, 28, 12, 0, 9, 0)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap()
+                    .timestamp(),
+            ),
+            (
+                "2014-11-28T21:00:09Z",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap()
+                    .timestamp(),
+            ),
+            (
+                "2014-11-28 21:00:09z",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap()
+                    .timestamp(),
+            ),
+            (
+                "2014-11-28T21:00:09+00:00",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap()
+                    .timestamp(),
+            ),
+            (
+                "2014-11-28T21:00:09.05+09:00",
+                civil::DateTime::constant(2014, 11, 28, 12, 0, 9, 50_000_000)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap()
+                    .timestamp(),
+            ),
+            (
+                "2014-11-28 21:00:09.05+09:00",
+                civil::DateTime::constant(2014, 11, 28, 12, 0, 9, 50_000_000)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap()
+                    .timestamp(),
+            ),
+        ] {
+            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-19Q14: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 [
+            (
+                civil::DateTime::constant(1996, 12, 19, 0, 0, 0, 0)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap()
+                    .timestamp(),
+                graphql_input_value!("1996-12-19T00:00:00Z"),
+            ),
+            (
+                civil::DateTime::constant(1564, 1, 30, 5, 0, 0, 123_000_000)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap()
+                    .timestamp(),
+                graphql_input_value!("1564-01-30T05:00:00.123Z"),
+            ),
+        ] {
+            let actual: InputValue = val.to_input_value();
+
+            assert_eq!(actual, expected, "on value: {val}");
+        }
+    }
+}
+
+#[cfg(test)]
+mod duration_test {
+    use jiff::ToSpan;
+
+    use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
+
+    use super::Duration;
+
+    #[test]
+    fn parses_correct_input() {
+        for (raw, expected) in [
+            ("P5dT8h1m", 5.days().hours(8).minutes(1)),
+            ("-P5d", (-5).days()),
+            ("P2M10DT2H30M", 2.months().days(10).hours(2).minutes(30)),
+            ("P40D", 40.days()),
+            ("P1y1d", 1.year().days(1)),
+            ("P3dT4h59m", 3.days().hours(4).minutes(59)),
+            ("PT2H30M", 2.hours().minutes(30)),
+            ("P1m", 1.month()),
+            ("P1w", 1.week()),
+            ("P1w4d", 1.week().days(4)),
+            ("PT1m", 1.minute()),
+            ("PT0.0021s", 2.milliseconds().microseconds(100)),
+            ("PT0s", 0.seconds()),
+            ("P0d", 0.seconds()),
+            (
+                "P1y1m1dT1h1m1.1s",
+                1.year()
+                    .months(1)
+                    .days(1)
+                    .hours(1)
+                    .minutes(1)
+                    .seconds(1)
+                    .milliseconds(100),
+            ),
+        ] {
+            let input: InputValue = graphql_input_value!((raw));
+            let parsed = Duration::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!("12S"),
+            graphql_input_value!("P0"),
+            graphql_input_value!("PT"),
+            graphql_input_value!("PTS"),
+            graphql_input_value!("56:34:22"),
+            graphql_input_value!("1996-12-19"),
+            graphql_input_value!("1996-12-19T14:23:43"),
+            graphql_input_value!("1996-12-19T14:23:43Z"),
+            graphql_input_value!("i'm not even a duration"),
+            graphql_input_value!(2.32),
+            graphql_input_value!(1),
+            graphql_input_value!(null),
+            graphql_input_value!(false),
+        ] {
+            let input: InputValue = input;
+            let parsed = Duration::from_input_value(&input);
+
+            assert!(parsed.is_err(), "allows input: {input:?}");
+        }
+    }
+
+    #[test]
+    fn formats_correctly() {
+        for (val, expected) in [
+            (
+                1.year()
+                    .months(1)
+                    .days(1)
+                    .hours(1)
+                    .minutes(1)
+                    .seconds(1)
+                    .milliseconds(100),
+                graphql_input_value!("P1y1m1dT1h1m1.1s"),
+            ),
+            ((-5).days(), graphql_input_value!("-P5d")),
+        ] {
+            let actual: InputValue = val.to_input_value();
+
+            assert_eq!(actual, expected, "on value: {val}");
+        }
+    }
+}
diff --git a/juniper/src/integrations/mod.rs b/juniper/src/integrations/mod.rs
index 0d88bdf2c..3f16037e6 100644
--- a/juniper/src/integrations/mod.rs
+++ b/juniper/src/integrations/mod.rs
@@ -10,6 +10,8 @@ pub mod bson;
 pub mod chrono;
 #[cfg(feature = "chrono-tz")]
 pub mod chrono_tz;
+#[cfg(feature = "jiff")]
+pub mod jiff;
 #[cfg(feature = "rust_decimal")]
 pub mod rust_decimal;
 #[doc(hidden)]


Test patch: