diff --git a/credential-exchange-format/src/lib.rs b/credential-exchange-format/src/lib.rs index f81ecbf..7e8d203 100644 --- a/credential-exchange-format/src/lib.rs +++ b/credential-exchange-format/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; mod b64url; @@ -10,6 +11,7 @@ mod extensions; mod identity; mod login; mod passkey; +mod timestamp; pub use self::{ b64url::*, credential_scope::*, document::*, editable_field::*, extensions::*, identity::*, @@ -29,7 +31,8 @@ pub struct Header { /// The display name of the exporting app to be presented to the user. pub exporter_display_name: String, /// The UNIX timestamp during at which the export document was completed. - pub timestamp: u64, + #[serde(with = "timestamp")] + pub timestamp: DateTime, /// The list of [Account]s being exported. pub accounts: Vec>, } @@ -80,14 +83,22 @@ pub struct Collection { /// originally created. If this member is not set, but the importing provider requires this /// member in their proprietary data model, the importer SHOULD use the current timestamp at /// the time the provider encounters this 8Collection]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creation_at: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "timestamp::option" + )] + pub creation_at: Option>, /// This member contains the UNIX timestamp in seconds of the last modification brought to this /// [Collection]. If this member is not set, but the importing provider requires this member in /// their proprietary data model, the importer SHOULD use the current timestamp at the time the /// provider encounters this [Collection]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub modified_at: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "timestamp::option" + )] + pub modified_at: Option>, /// The display name of the [Collection]. pub title: String, /// This field is a subtitle or a description of the [Collection]. @@ -125,14 +136,18 @@ pub struct Item { /// created. If this member is not set, but the importing provider requires this /// member in their proprietary data model, the importer SHOULD use the current timestamp /// at the time the provider encounters this [Item]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creation_at: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "timestamp::option" + )] + pub creation_at: Option>, /// This member contains the UNIX timestamp in seconds of the last modification brought to this /// [Item]. If this member is not set, but the importing provider requires this member in /// their proprietary data model, the importer SHOULD use the current timestamp at the time /// the provider encounters this [Item]. #[serde(default, skip_serializing_if = "Option::is_none")] - pub modified_at: Option, + pub modified_at: Option>, /// This member’s value is the user-defined name or title of the item. pub title: String, /// This member is a subtitle or description for the [Item]. diff --git a/credential-exchange-format/src/timestamp.rs b/credential-exchange-format/src/timestamp.rs new file mode 100644 index 0000000..243a650 --- /dev/null +++ b/credential-exchange-format/src/timestamp.rs @@ -0,0 +1,324 @@ +//! # Flexible Timestamp Serialization +//! +//! This module provides custom serde serialization and deserialization functions for +//! [`DateTime`] that support multiple input formats while maintaining consistent output. +//! +//! ## Deserialization +//! +//! The deserializers accept timestamps in two formats: +//! - **UNIX timestamps**: Integer values (i64 or u64) representing seconds since the Unix epoch +//! - **ISO8601 strings**: RFC3339-compliant datetime strings (e.g., `"2023-11-18T10:30:00Z"`) +//! +//! ## Serialization +//! +//! All timestamps are serialized as UNIX timestamps (i64) for consistency and compatibility +//! with the CXF standard. + +use chrono::{DateTime, TimeZone, Utc}; +use serde::{Deserialize, Deserializer, Serializer}; + +/// Serializes a [`DateTime`] as a UNIX timestamp (i64). +/// +/// This function is intended to be used with serde's `#[serde(with = "...")]` attribute. +/// +/// # Errors +/// +/// Returns an error if the serializer fails to serialize the timestamp value. +pub fn serialize(date: &DateTime, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_i64(date.timestamp()) +} + +/// Deserializes a [`DateTime`] from either a UNIX timestamp (i64/u64) or an ISO8601 string. +/// +/// This function is intended to be used with serde's `#[serde(with = "...")]` attribute. +/// +/// # Accepted Formats +/// +/// - UNIX timestamp as i64 or u64 (seconds since Unix epoch) +/// - ISO8601/RFC3339 string (e.g., `"2023-11-18T10:30:00Z"`) +/// +/// # Errors +/// +/// Returns an error if: +/// - The timestamp value is invalid or out of range +/// - The ISO8601 string cannot be parsed +/// - The input is neither a number nor a string +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct TimestampVisitor; + + impl serde::de::Visitor<'_> for TimestampVisitor { + type Value = DateTime; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a UNIX timestamp (u64) or ISO8601 string") + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + Utc.timestamp_opt(value, 0) + .single() + .ok_or_else(|| E::custom(format!("invalid timestamp: {value}"))) + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + #[allow(clippy::cast_possible_wrap)] + self.visit_i64(value as i64) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + value + .parse::>() + .map_err(|e| E::custom(format!("invalid ISO8601: {e}"))) + } + } + + deserializer.deserialize_any(TimestampVisitor) +} + +pub mod option { + //! Serialization and deserialization functions for `Option>`. + //! + //! This module provides the same flexible deserialization as the parent module, + //! but for optional timestamp fields. + + use super::{DateTime, Deserialize, Deserializer, Serializer, TimeZone, Utc}; + + /// Serializes an `Option>` as either a UNIX timestamp (i64) or null. + /// + /// This function is intended to be used with serde's `#[serde(with = "...")]` attribute. + /// + /// # Errors + /// + /// Returns an error if the serializer fails to serialize the timestamp value. + #[allow(clippy::ref_option)] + pub fn serialize(date: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match date { + Some(dt) => serializer.serialize_some(&dt.timestamp()), + None => serializer.serialize_none(), + } + } + + /// Deserializes an `Option>` from either a UNIX timestamp, an ISO8601 string, + /// or null. + /// + /// This function is intended to be used with serde's `#[serde(with = "...")]` attribute. + /// + /// # Accepted Formats + /// + /// - UNIX timestamp as i64 or u64 (seconds since Unix epoch) + /// - ISO8601/RFC3339 string (e.g., `"2023-11-18T10:30:00Z"`) + /// - null + /// + /// # Errors + /// + /// Returns an error if: + /// - The timestamp value is invalid or out of range + /// - The ISO8601 string cannot be parsed + /// - The input is neither a number, string, nor null + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer)? + .map(|v| { + v.as_i64().map_or_else( + || { + v.as_str().map_or_else( + || Err(serde::de::Error::custom("expected number or string")), + |s| { + s.parse::>().map_err(|e| { + serde::de::Error::custom(format!("invalid ISO8601: {e}")) + }) + }, + ) + }, + |num| { + Utc.timestamp_opt(num, 0) + .single() + .ok_or_else(|| serde::de::Error::custom("invalid timestamp")) + }, + ) + }) + .transpose() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::unreadable_literal)] +mod tests { + use super::*; + use chrono::TimeZone; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestStruct { + #[serde(with = "crate::timestamp")] + timestamp: DateTime, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestStructOptional { + #[serde(with = "crate::timestamp::option")] + timestamp: Option>, + } + + #[test] + fn test_deserialize_from_unix_timestamp() { + let json = r#"{"timestamp": 1700000000}"#; + let result: TestStruct = serde_json::from_str(json).unwrap(); + let expected = Utc.timestamp_opt(1700000000, 0).unwrap(); + assert_eq!(result.timestamp, expected); + } + + #[test] + fn test_deserialize_from_iso8601_string() { + let json = r#"{"timestamp": "2023-11-14T22:13:20Z"}"#; + let result: TestStruct = serde_json::from_str(json).unwrap(); + let expected = Utc.with_ymd_and_hms(2023, 11, 14, 22, 13, 20).unwrap(); + assert_eq!(result.timestamp, expected); + } + + #[test] + fn test_deserialize_from_iso8601_with_offset() { + let json = r#"{"timestamp": "2023-11-14T22:13:20+00:00"}"#; + let result: TestStruct = serde_json::from_str(json).unwrap(); + let expected = Utc.with_ymd_and_hms(2023, 11, 14, 22, 13, 20).unwrap(); + assert_eq!(result.timestamp, expected); + } + + #[test] + fn test_serialize_to_unix_timestamp() { + let timestamp = Utc.timestamp_opt(1700000000, 0).unwrap(); + let test_struct = TestStruct { timestamp }; + let json = serde_json::to_string(&test_struct).unwrap(); + assert_eq!(json, r#"{"timestamp":1700000000}"#); + } + + #[test] + fn test_roundtrip_unix_timestamp() { + let original_json = r#"{"timestamp": 1700000000}"#; + let parsed: TestStruct = serde_json::from_str(original_json).unwrap(); + let serialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!(serialized, r#"{"timestamp":1700000000}"#); + } + + #[test] + fn test_roundtrip_iso8601_to_unix() { + // ISO8601 input should serialize to UNIX timestamp + let original_json = r#"{"timestamp": "2023-11-14T22:13:20Z"}"#; + let parsed: TestStruct = serde_json::from_str(original_json).unwrap(); + let serialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!(serialized, r#"{"timestamp":1700000000}"#); + } + + #[test] + fn test_deserialize_invalid_timestamp() { + let json = r#"{"timestamp": "not a timestamp"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_invalid_type() { + let json = r#"{"timestamp": true}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + // Tests for Option> + + #[test] + fn test_deserialize_optional_from_unix_timestamp() { + let json = r#"{"timestamp": 1700000000}"#; + let result: TestStructOptional = serde_json::from_str(json).unwrap(); + let expected = Utc.timestamp_opt(1700000000, 0).unwrap(); + assert_eq!(result.timestamp, Some(expected)); + } + + #[test] + fn test_deserialize_optional_from_iso8601() { + let json = r#"{"timestamp": "2023-11-14T22:13:20Z"}"#; + let result: TestStructOptional = serde_json::from_str(json).unwrap(); + let expected = Utc.with_ymd_and_hms(2023, 11, 14, 22, 13, 20).unwrap(); + assert_eq!(result.timestamp, Some(expected)); + } + + #[test] + fn test_deserialize_optional_null() { + let json = r#"{"timestamp": null}"#; + let result: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(result.timestamp, None); + } + + #[test] + fn test_serialize_optional_some() { + let timestamp = Utc.timestamp_opt(1700000000, 0).unwrap(); + let test_struct = TestStructOptional { + timestamp: Some(timestamp), + }; + let json = serde_json::to_string(&test_struct).unwrap(); + assert_eq!(json, r#"{"timestamp":1700000000}"#); + } + + #[test] + fn test_serialize_optional_none() { + let test_struct = TestStructOptional { timestamp: None }; + let json = serde_json::to_string(&test_struct).unwrap(); + assert_eq!(json, r#"{"timestamp":null}"#); + } + + #[test] + fn test_roundtrip_optional_unix_timestamp() { + let original_json = r#"{"timestamp": 1700000000}"#; + let parsed: TestStructOptional = serde_json::from_str(original_json).unwrap(); + let serialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!(serialized, r#"{"timestamp":1700000000}"#); + } + + #[test] + fn test_roundtrip_optional_iso8601_to_unix() { + let original_json = r#"{"timestamp": "2023-11-14T22:13:20Z"}"#; + let parsed: TestStructOptional = serde_json::from_str(original_json).unwrap(); + let serialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!(serialized, r#"{"timestamp":1700000000}"#); + } + + #[test] + fn test_roundtrip_optional_null() { + let original_json = r#"{"timestamp": null}"#; + let parsed: TestStructOptional = serde_json::from_str(original_json).unwrap(); + let serialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!(serialized, r#"{"timestamp":null}"#); + } + + #[test] + fn test_deserialize_optional_invalid_timestamp() { + let json = r#"{"timestamp": "not a timestamp"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_optional_invalid_type() { + let json = r#"{"timestamp": true}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } +}