Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions credential-exchange-format/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#![doc = include_str!("../README.md")]

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

mod b64url;
Expand All @@ -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::*,
Expand All @@ -29,7 +31,8 @@ pub struct Header<E = ()> {
/// 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<Utc>,
/// The list of [Account]s being exported.
pub accounts: Vec<Account<E>>,
}
Expand Down Expand Up @@ -80,14 +83,22 @@ pub struct Collection<E = ()> {
/// 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<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "timestamp::option"
)]
pub creation_at: Option<DateTime<Utc>>,
/// 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<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "timestamp::option"
)]
pub modified_at: Option<DateTime<Utc>>,
/// The display name of the [Collection].
pub title: String,
/// This field is a subtitle or a description of the [Collection].
Expand Down Expand Up @@ -125,14 +136,18 @@ pub struct Item<E = ()> {
/// 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<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "timestamp::option"
)]
pub creation_at: Option<DateTime<Utc>>,
/// 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<u64>,
pub modified_at: Option<DateTime<Utc>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Should this also use with = "timestamp::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].
Expand Down
324 changes: 324 additions & 0 deletions credential-exchange-format/src/timestamp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
//! # Flexible Timestamp Serialization
//!
//! This module provides custom serde serialization and deserialization functions for
//! [`DateTime<Utc>`] 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<Utc>`] as a UNIX timestamp (i64).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I think we need to use u64 here. The CX specification specifies machine readable datetimes as uint .size 8 and I don't believe it's valid to serialize as signed integers.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's definitely right. i64 would cause errors. Good catch!

///
/// 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<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_i64(date.timestamp())
}

/// Deserializes a [`DateTime<Utc>`] 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<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
struct TimestampVisitor;

impl serde::de::Visitor<'_> for TimestampVisitor {
type Value = DateTime<Utc>;

fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a UNIX timestamp (u64) or ISO8601 string")
}

fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Utc.timestamp_opt(value, 0)
.single()
.ok_or_else(|| E::custom(format!("invalid timestamp: {value}")))
}

fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
#[allow(clippy::cast_possible_wrap)]
self.visit_i64(value as i64)
}

fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
value
.parse::<DateTime<Utc>>()
.map_err(|e| E::custom(format!("invalid ISO8601: {e}")))
}
}

deserializer.deserialize_any(TimestampVisitor)
}

pub mod option {
//! Serialization and deserialization functions for `Option<DateTime<Utc>>`.
//!
//! 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<DateTime<Utc>>` 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<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match date {
Some(dt) => serializer.serialize_some(&dt.timestamp()),
None => serializer.serialize_none(),
}
}

/// Deserializes an `Option<DateTime<Utc>>` 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<Option<DateTime<Utc>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<serde_json::Value>::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::<DateTime<Utc>>().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()
}
}
Comment on lines +92 to +161
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I believe we can use a helper struct here which would allow re-use of the previous implementation which ensures consistency.

    #[allow(clippy::ref_option)]
    pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        #[derive(Serialize)]
        struct Helper(#[serde(serialize_with = "super::serialize")] DateTime<Utc>);

        date.as_ref().map(|dt| Helper(*dt)).serialize(serializer)
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct Helper(#[serde(deserialize_with = "super::deserialize")] DateTime<Utc>);

        Option::<Helper>::deserialize(deserializer).map(|opt| opt.map(|h| h.0))
    }


#[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<Utc>,
}

#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct TestStructOptional {
#[serde(with = "crate::timestamp::option")]
timestamp: Option<DateTime<Utc>>,
}

#[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<TestStruct, _> = serde_json::from_str(json);
assert!(result.is_err());
}

#[test]
fn test_deserialize_invalid_type() {
let json = r#"{"timestamp": true}"#;
let result: Result<TestStruct, _> = serde_json::from_str(json);
assert!(result.is_err());
}

// Tests for Option<DateTime<Utc>>

#[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<TestStructOptional, _> = serde_json::from_str(json);
assert!(result.is_err());
}

#[test]
fn test_deserialize_optional_invalid_type() {
let json = r#"{"timestamp": true}"#;
let result: Result<TestStructOptional, _> = serde_json::from_str(json);
assert!(result.is_err());
}
}
Loading