Skip to content

Commit 354f83f

Browse files
authored
feat: Add DurationVisitors for serde for modular serialization and deserialization (lakekeeper#1575)
1 parent 26cf69d commit 354f83f

File tree

7 files changed

+420
-79
lines changed

7 files changed

+420
-79
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ google-cloud-storage = { package = "gcloud-storage", version = "1.1", default-fe
8080
"rustls-tls",
8181
"auth",
8282
] }
83-
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
8483
google-cloud-token = { package = "token-source", version = "1.0" }
8584
headers = { version = "^0.4" }
8685
heck = "0.5.0"
@@ -94,6 +93,7 @@ iceberg = { git = "https://github.com/lakekeeper/iceberg-rust.git", rev = "acda0
9493
] }
9594
iso8601 = "0.6.2"
9695
itertools = "0.14.0"
96+
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
9797
jwks_client_rs = { version = "0.5.1", default-features = false, features = [
9898
"rustls-tls",
9999
] }

crates/lakekeeper/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub use service::{ProjectId, SecretId, WarehouseId};
2121
pub mod serve;
2222

2323
pub mod implementations;
24-
pub(crate) mod utils;
24+
pub mod utils;
2525

2626
pub mod api;
2727
mod request_metadata;

crates/lakekeeper/src/utils/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
pub(crate) mod time_conversion;
1+
pub mod time_conversion;

crates/lakekeeper/src/utils/time_conversion.rs

Lines changed: 123 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,97 @@
1+
//! Duration conversion utilities for ISO 8601 and chrono compatibility.
2+
//!
3+
//! This module provides functions to convert between [`iso8601::Duration`] and [`chrono::Duration`],
4+
//! along with serde support for serializing/deserializing durations in ISO 8601 format.
5+
//!
6+
//! # Examples
7+
//!
8+
//! ```
9+
//! use std::str::FromStr;
10+
//! use lakekeeper::utils::time_conversion::iso_8601_duration_to_chrono;
11+
//!
12+
//! let iso_duration = iso8601::Duration::from_str("P3DT4H5M").unwrap();
13+
//! let chrono_duration = iso_8601_duration_to_chrono(&iso_duration).unwrap();
14+
//! assert_eq!(chrono_duration.num_days(), 3);
15+
//! ```
16+
//!
17+
//! # Limitations
18+
//!
19+
//! - Years and months are not supported (will return an error)
20+
//! - Negative durations are not supported
21+
//! - The serde modules assume ISO 8601 duration string format
22+
123
use iceberg_ext::catalog::rest::ErrorModel;
224

3-
pub(crate) fn iso_8601_duration_to_chrono(
25+
/// Serde visitors for deserializing ISO 8601 duration strings.
26+
///
27+
/// This module provides [`Visitor`](serde::de::Visitor) implementations that convert
28+
/// ISO 8601 duration strings into both [`iso8601::Duration`] and [`chrono::Duration`] types.
29+
pub mod duration_serde_visitor;
30+
31+
/// Serialization support for `chrono::Duration` as ISO 8601 duration strings.
32+
///
33+
/// Use this module in your struct definitions with the `#[serde(with = "...")]` attribute
34+
/// to automatically serialize/deserialize `Duration` fields as ISO 8601 strings.
35+
///
36+
/// # Examples
37+
///
38+
/// ```ignore
39+
/// use chrono::Duration;
40+
/// use serde::{Deserialize, Serialize};
41+
/// use lakekeeper::utils::time_conversion::iso8601_duration_serde;
42+
///
43+
/// #[derive(Serialize, Deserialize)]
44+
/// struct Task {
45+
/// #[serde(with = "iso8601_duration_serde")]
46+
/// timeout: Duration,
47+
/// }
48+
/// ```
49+
pub mod iso8601_duration_serde;
50+
51+
/// Serialization support for `Option<chrono::Duration>` as ISO 8601 duration strings.
52+
///
53+
/// Similar to [`iso8601_duration_serde`], but handles `Option<Duration>` fields.
54+
/// `None` values are serialized as `null`.
55+
///
56+
/// # Examples
57+
///
58+
/// ```ignore
59+
/// use chrono::Duration;
60+
/// use serde::{Deserialize, Serialize};
61+
/// use lakekeeper::utils::time_conversion::iso8601_option_duration_serde;
62+
///
63+
/// #[derive(Serialize, Deserialize)]
64+
/// struct Config {
65+
/// #[serde(with = "iso8601_option_duration_serde")]
66+
/// optional_timeout: Option<Duration>,
67+
/// }
68+
/// ```
69+
pub mod iso8601_option_duration_serde;
70+
71+
/// Converts an ISO 8601 duration to a `chrono::Duration`.
72+
///
73+
/// # Arguments
74+
///
75+
/// * `duration` - An ISO 8601 duration in either weeks (`P<n>W`) or date-time format (`P<d>DT<h>H<m>M<s>S`)
76+
///
77+
/// # Returns
78+
///
79+
/// * `Ok(chrono::Duration)` - Successfully converted duration
80+
/// * `Err(ErrorModel)` - If the duration contains years or months (not supported)
81+
///
82+
/// # Examples
83+
///
84+
/// ```
85+
/// use std::str::FromStr;
86+
/// use lakekeeper::utils::time_conversion::iso_8601_duration_to_chrono;
87+
///
88+
/// // Parse ISO 8601 duration string
89+
/// let iso_duration = iso8601::Duration::from_str("P3DT4H5M6S").unwrap();
90+
/// let chrono_duration = iso_8601_duration_to_chrono(&iso_duration).unwrap();
91+
/// assert_eq!(chrono_duration.num_days(), 3);
92+
/// assert_eq!(chrono_duration.num_hours() % 24, 4);
93+
/// ```
94+
pub fn iso_8601_duration_to_chrono(
495
duration: &iso8601::Duration,
596
) -> Result<chrono::Duration, ErrorModel> {
697
match duration {
@@ -30,10 +121,39 @@ pub(crate) fn iso_8601_duration_to_chrono(
30121
}
31122
}
32123

33-
pub(crate) fn chrono_to_iso_8601_duration(
124+
/// Converts a `chrono::Duration` to an ISO 8601 duration.
125+
///
126+
/// The conversion prefers the weeks representation (`P<n>W`) if the duration is divisible by 7 days,
127+
/// otherwise uses the YMDHMS format (`P<d>DT<h>H<m>M<s>S`).
128+
///
129+
/// # Arguments
130+
///
131+
/// * `duration` - A chrono duration (must be non-negative)
132+
///
133+
/// # Returns
134+
///
135+
/// * `Ok(iso8601::Duration)` - Successfully converted duration
136+
/// * `Err(ErrorModel)` - If the duration is negative or conversion would overflow
137+
///
138+
/// # Examples
139+
///
140+
/// ```
141+
/// use chrono::Duration;
142+
/// use lakekeeper::utils::time_conversion::chrono_to_iso_8601_duration;
143+
///
144+
/// // Convert duration with multiple components
145+
/// let duration = Duration::days(3) + Duration::hours(4) + Duration::minutes(5);
146+
/// let iso_duration = chrono_to_iso_8601_duration(&duration).unwrap();
147+
/// assert_eq!(iso_duration.to_string(), "P3DT4H5M");
148+
///
149+
/// // Convert week-divisible duration (uses weeks representation)
150+
/// let weeks = Duration::weeks(2);
151+
/// let iso_duration = chrono_to_iso_8601_duration(&weeks).unwrap();
152+
/// assert_eq!(iso_duration.to_string(), "P2W");
153+
/// ```
154+
pub fn chrono_to_iso_8601_duration(
34155
duration: &chrono::Duration,
35156
) -> Result<iso8601::Duration, crate::api::ErrorModel> {
36-
// Check for negative duration
37157
if duration.num_milliseconds() < 0 {
38158
return Err(crate::api::ErrorModel::bad_request(
39159
"Negative durations not supported for ISO8601 format".to_string(),
@@ -111,79 +231,6 @@ pub(crate) fn chrono_to_iso_8601_duration(
111231
})
112232
}
113233

114-
/// Module for serializing `chrono::Duration` as ISO8601 duration strings
115-
pub(crate) mod iso8601_duration_serde {
116-
use std::str::FromStr;
117-
118-
use chrono::Duration;
119-
use serde::{Deserialize, Deserializer, Serializer};
120-
121-
use super::{chrono_to_iso_8601_duration, iso_8601_duration_to_chrono};
122-
123-
pub(crate) fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
124-
where
125-
S: Serializer,
126-
{
127-
// Convert chrono::Duration to iso8601::Duration
128-
let iso_duration =
129-
chrono_to_iso_8601_duration(duration).map_err(serde::ser::Error::custom)?;
130-
131-
// Serialize to string
132-
serializer.serialize_str(&iso_duration.to_string())
133-
}
134-
135-
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
136-
where
137-
D: Deserializer<'de>,
138-
{
139-
let duration_str = String::deserialize(deserializer)?;
140-
141-
// Parse string into iso8601::Duration
142-
let iso_duration = iso8601::Duration::from_str(&duration_str)
143-
.map_err(|e| serde::de::Error::custom(format!("Invalid ISO8601 duration: {e}")))?;
144-
145-
// Convert to chrono::Duration
146-
iso_8601_duration_to_chrono(&iso_duration).map_err(|e| serde::de::Error::custom(e.message))
147-
}
148-
}
149-
150-
pub(crate) mod iso8601_option_duration_serde {
151-
use chrono::Duration;
152-
use serde::{Deserialize, Deserializer, Serializer};
153-
154-
use super::iso8601_duration_serde;
155-
156-
#[allow(clippy::ref_option)]
157-
pub(crate) fn serialize<S>(
158-
duration: &Option<Duration>,
159-
serializer: S,
160-
) -> Result<S::Ok, S::Error>
161-
where
162-
S: Serializer,
163-
{
164-
match duration {
165-
Some(d) => iso8601_duration_serde::serialize(d, serializer),
166-
None => serializer.serialize_none(),
167-
}
168-
}
169-
170-
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
171-
where
172-
D: Deserializer<'de>,
173-
{
174-
let opt: Option<String> = Option::deserialize(deserializer)?;
175-
match opt {
176-
Some(duration_str) => {
177-
let duration = iso8601_duration_serde::deserialize(
178-
serde::de::value::StrDeserializer::new(&duration_str),
179-
)?;
180-
Ok(Some(duration))
181-
}
182-
None => Ok(None),
183-
}
184-
}
185-
}
186-
187234
#[cfg(test)]
188235
mod test {
189236
use std::str::FromStr;
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//! Serde visitors for deserializing ISO 8601 duration strings.
2+
//!
3+
//! This module provides [`Visitor`](serde::de::Visitor) implementations used by the
4+
//! serde modules to convert ISO 8601 duration strings into duration types.
5+
6+
use std::{fmt::Formatter, str::FromStr};
7+
8+
use serde::de::{Error, Visitor};
9+
10+
use crate::utils::time_conversion::iso_8601_duration_to_chrono;
11+
12+
/// Visitor for deserializing ISO 8601 duration strings into `iso8601::Duration`.
13+
///
14+
/// This visitor parses string input in ISO 8601 format (e.g., `P3DT4H5M6S` or `P2W`)
15+
/// and converts it to an [`iso8601::Duration`].
16+
///
17+
/// # Examples
18+
///
19+
/// ```
20+
/// use serde::de::Visitor;
21+
/// use lakekeeper::utils::time_conversion::duration_serde_visitor::ISO8601DurationVisitor;
22+
///
23+
/// let visitor = ISO8601DurationVisitor::default();
24+
/// let duration = visitor.visit_str::<serde_json::Error>("P3DT4H").unwrap();
25+
/// ```
26+
#[derive(Debug, Default)]
27+
pub struct ISO8601DurationVisitor;
28+
29+
impl Visitor<'_> for ISO8601DurationVisitor {
30+
type Value = iso8601::Duration;
31+
32+
fn expecting(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
33+
formatter.write_str("a duration string in ISO 8601 format")
34+
}
35+
36+
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
37+
where
38+
E: Error,
39+
{
40+
iso8601::Duration::from_str(value).map_err(E::custom)
41+
}
42+
}
43+
44+
/// Visitor for deserializing ISO 8601 duration strings into `chrono::Duration`.
45+
///
46+
/// This visitor combines the ISO 8601 parsing with conversion to [`chrono::Duration`],
47+
/// validating that the duration doesn't contain unsupported components (years/months).
48+
///
49+
/// # Examples
50+
///
51+
/// ```
52+
/// use serde::de::Visitor;
53+
/// use lakekeeper::utils::time_conversion::duration_serde_visitor::ChronoDurationVisitor;
54+
///
55+
/// let visitor = ChronoDurationVisitor::default();
56+
/// let duration = visitor.visit_str::<serde_json::Error>("P3DT4H").unwrap();
57+
/// assert_eq!(duration.num_days(), 3);
58+
/// ```
59+
#[derive(Debug, Default)]
60+
pub struct ChronoDurationVisitor;
61+
62+
impl Visitor<'_> for ChronoDurationVisitor {
63+
type Value = chrono::Duration;
64+
65+
fn expecting(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
66+
formatter.write_str("a duration string in ISO 8601 format")
67+
}
68+
69+
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
70+
where
71+
E: Error,
72+
{
73+
let iso8601_duration_visitor = ISO8601DurationVisitor;
74+
let duration = iso8601_duration_visitor.visit_str::<E>(value)?;
75+
iso_8601_duration_to_chrono(&duration).map_err(E::custom)
76+
}
77+
}
78+
79+
#[cfg(test)]
80+
mod test {
81+
use serde_json::error::Error;
82+
83+
use super::*;
84+
85+
#[test]
86+
fn test_iso8601_duration_visitor_can_parse_iso_8601_duration() {
87+
let iso_duration_str = "P3DT4H";
88+
let duration: iso8601::Duration = ISO8601DurationVisitor
89+
.visit_str::<Error>(iso_duration_str)
90+
.unwrap();
91+
assert_eq!(
92+
duration,
93+
iso8601::Duration::YMDHMS {
94+
year: 0,
95+
month: 0,
96+
day: 3,
97+
hour: 4,
98+
minute: 0,
99+
second: 0,
100+
millisecond: 0
101+
}
102+
);
103+
}
104+
105+
#[test]
106+
fn test_iso8601_duration_visitor_throws_error_with_invalid_format() {
107+
let iso_duration_str = "InvalidDuration";
108+
let result = ISO8601DurationVisitor.visit_str::<Error>(iso_duration_str);
109+
assert!(result.is_err());
110+
}
111+
112+
#[test]
113+
fn test_chrono_duration_visitor_can_parse_iso_8601_duration() {
114+
let iso_duration_str = "P3DT4H";
115+
let duration: chrono::Duration = ChronoDurationVisitor
116+
.visit_str::<Error>(iso_duration_str)
117+
.unwrap();
118+
assert_eq!(
119+
duration,
120+
chrono::Duration::days(3) + chrono::Duration::hours(4)
121+
);
122+
}
123+
124+
#[test]
125+
fn test_chrono_duration_visitor_throws_error_with_invalid_format() {
126+
let iso_duration_str = "InvalidDuration";
127+
let result = ChronoDurationVisitor.visit_str::<Error>(iso_duration_str);
128+
assert!(result.is_err());
129+
}
130+
131+
#[test]
132+
fn test_chrono_duration_visitor_returns_error_if_it_contains_month() {
133+
let iso_duration_str = "P1MT2H";
134+
let result = ChronoDurationVisitor.visit_str::<Error>(iso_duration_str);
135+
assert!(result.is_err());
136+
}
137+
138+
#[test]
139+
fn test_chrono_duration_visitor_returns_error_if_it_contains_year() {
140+
let iso_duration_str = "P1YT2H";
141+
let result = ChronoDurationVisitor.visit_str::<Error>(iso_duration_str);
142+
assert!(result.is_err());
143+
}
144+
}

0 commit comments

Comments
 (0)