Skip to main content

api_bones/serde/
timestamp.rs

1//! Flexible timestamp serde: deserializes from Unix epoch (integer or float)
2//! **or** ISO 8601 / RFC 3339 string; always serializes as an RFC 3339 string.
3//!
4//! Requires the `chrono` feature.
5//!
6//! ## Wire formats accepted on deserialization
7//!
8//! | Input                        | Interpretation                      |
9//! |------------------------------|-------------------------------------|
10//! | `1_700_000_000` (i64)        | Unix epoch seconds                  |
11//! | `1_700_000_000.5` (f64)      | Unix epoch seconds + sub-second     |
12//! | `"2023-11-14T22:13:20Z"`     | RFC 3339 / ISO 8601 string          |
13//!
14//! ## Examples
15//!
16//! ```rust
17//! use chrono::{DateTime, Utc};
18//! use serde::{Deserialize, Serialize};
19//!
20//! #[derive(Debug, PartialEq, Serialize, Deserialize)]
21//! struct Event {
22//!     #[serde(with = "api_bones::serde::timestamp")]
23//!     occurred_at: DateTime<Utc>,
24//! }
25//!
26//! // Deserialize from epoch integer
27//! let from_epoch: Event = serde_json::from_str(r#"{"occurred_at":0}"#).unwrap();
28//! assert_eq!(from_epoch.occurred_at, DateTime::<Utc>::from_timestamp(0, 0).unwrap());
29//!
30//! // Deserialize from RFC 3339 string
31//! let from_str: Event =
32//!     serde_json::from_str(r#"{"occurred_at":"1970-01-01T00:00:00+00:00"}"#).unwrap();
33//! assert_eq!(from_str.occurred_at, from_epoch.occurred_at);
34//!
35//! // Always serializes as RFC 3339
36//! let json = serde_json::to_string(&from_epoch).unwrap();
37//! assert_eq!(json, r#"{"occurred_at":"1970-01-01T00:00:00+00:00"}"#);
38//! ```
39//!
40//! ## Feature gating
41//!
42//! This module is re-exported only when the `chrono` feature is enabled
43//! (see `serde/mod.rs`). The `serde` feature gate on the parent `serde`
44//! module is a *necessary but not sufficient* condition — `chrono` must
45//! also be active for this module to be available. The explicit
46//! `#[cfg(feature = "chrono")]` in `mod.rs` is the authoritative gate;
47//! `serde` is required transitively because `DateTime<Utc>` serialization
48//! depends on it.
49
50#![cfg(feature = "chrono")]
51
52use chrono::{DateTime, TimeZone, Utc};
53use serde::{Deserializer, Serializer, de};
54
55/// Serialize a [`DateTime<Utc>`] as an RFC 3339 string.
56///
57/// # Errors
58///
59/// Returns a serialization error if the serializer rejects the string.
60pub fn serialize<S>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
61where
62    S: Serializer,
63{
64    serializer.serialize_str(&value.to_rfc3339())
65}
66
67/// Deserialize a [`DateTime<Utc>`] from a Unix epoch integer, float, or RFC 3339 string.
68///
69/// # Errors
70///
71/// Returns a deserialization error if the input cannot be parsed as a valid
72/// timestamp.
73pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
74where
75    D: Deserializer<'de>,
76{
77    deserializer.deserialize_any(TimestampVisitor)
78}
79
80struct TimestampVisitor;
81
82impl de::Visitor<'_> for TimestampVisitor {
83    type Value = DateTime<Utc>;
84
85    fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
86        formatter.write_str("a Unix epoch integer, float, or RFC 3339 timestamp string")
87    }
88
89    fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
90        Utc.timestamp_opt(v, 0)
91            .single()
92            .ok_or_else(|| E::custom(format!("timestamp out of range: {v}")))
93    }
94
95    fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
96        let secs = i64::try_from(v).map_err(|_| E::custom("timestamp out of i64 range"))?;
97        self.visit_i64(secs)
98    }
99
100    fn visit_f64<E: de::Error>(self, v: f64) -> Result<Self::Value, E> {
101        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
102        let secs = v.floor() as i64;
103        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
104        let nanos = ((v - v.floor()) * 1_000_000_000.0).round() as u32;
105        Utc.timestamp_opt(secs, nanos)
106            .single()
107            .ok_or_else(|| E::custom(format!("timestamp out of range: {v}")))
108    }
109
110    fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
111        v.parse::<DateTime<Utc>>().map_err(E::custom)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use chrono::{DateTime, TimeZone, Utc};
118    use serde::{Deserialize, Serialize};
119
120    #[derive(Debug, PartialEq, Serialize, Deserialize)]
121    struct Event {
122        #[serde(with = "super")]
123        ts: DateTime<Utc>,
124    }
125
126    fn epoch() -> DateTime<Utc> {
127        Utc.timestamp_opt(0, 0).unwrap()
128    }
129
130    // --- serialize ---
131
132    #[test]
133    fn serialize_as_rfc3339() {
134        let e = Event { ts: epoch() };
135        let json = serde_json::to_string(&e).unwrap();
136        assert_eq!(json, r#"{"ts":"1970-01-01T00:00:00+00:00"}"#);
137    }
138
139    #[test]
140    fn serialize_non_epoch() {
141        let ts = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
142        let e = Event { ts };
143        let json = serde_json::to_string(&e).unwrap();
144        assert!(json.contains("2023-"));
145    }
146
147    // --- deserialize: visit_i64 (negative or positive integer) ---
148
149    #[test]
150    fn deserialize_from_i64_zero() {
151        let e: Event = serde_json::from_str(r#"{"ts":0}"#).unwrap();
152        assert_eq!(e.ts, epoch());
153    }
154
155    #[test]
156    fn deserialize_from_i64_positive() {
157        let e: Event = serde_json::from_str(r#"{"ts":1700000000}"#).unwrap();
158        assert_eq!(e.ts, Utc.timestamp_opt(1_700_000_000, 0).unwrap());
159    }
160
161    #[test]
162    fn deserialize_from_i64_negative() {
163        let e: Event = serde_json::from_str(r#"{"ts":-1}"#).unwrap();
164        assert_eq!(e.ts, Utc.timestamp_opt(-1, 0).unwrap());
165    }
166
167    // --- deserialize: visit_u64 (large positive integer) ---
168
169    #[test]
170    fn deserialize_from_u64() {
171        // serde_json sends positive integers that fit u64 as u64
172        let e: Event =
173            serde_json::from_value(serde_json::json!({"ts": 1_700_000_000_u64})).unwrap();
174        assert_eq!(e.ts, Utc.timestamp_opt(1_700_000_000, 0).unwrap());
175    }
176
177    // --- deserialize: visit_f64 (fractional epoch) ---
178
179    #[test]
180    fn deserialize_from_f64_with_fraction() {
181        let e: Event = serde_json::from_str(r#"{"ts":1700000000.5}"#).unwrap();
182        let expected = Utc.timestamp_opt(1_700_000_000, 500_000_000).unwrap();
183        assert_eq!(e.ts, expected);
184    }
185
186    #[test]
187    fn deserialize_from_f64_whole() {
188        let e: Event = serde_json::from_str(r#"{"ts":0.0}"#).unwrap();
189        assert_eq!(e.ts, epoch());
190    }
191
192    // --- deserialize: visit_str (RFC 3339 string) ---
193
194    #[test]
195    fn deserialize_from_rfc3339_utc() {
196        let e: Event = serde_json::from_str(r#"{"ts":"1970-01-01T00:00:00+00:00"}"#).unwrap();
197        assert_eq!(e.ts, epoch());
198    }
199
200    #[test]
201    fn deserialize_from_rfc3339_z_suffix() {
202        let e: Event = serde_json::from_str(r#"{"ts":"1970-01-01T00:00:00Z"}"#).unwrap();
203        assert_eq!(e.ts, epoch());
204    }
205
206    #[test]
207    fn deserialize_from_rfc3339_non_epoch() {
208        let e: Event = serde_json::from_str(r#"{"ts":"2023-11-14T22:13:20+00:00"}"#).unwrap();
209        assert_eq!(e.ts, Utc.timestamp_opt(1_700_000_000, 0).unwrap());
210    }
211
212    // --- error paths ---
213
214    #[test]
215    fn deserialize_invalid_string() {
216        let result: Result<Event, _> = serde_json::from_str(r#"{"ts":"not-a-date"}"#);
217        assert!(result.is_err());
218    }
219
220    #[test]
221    fn deserialize_u64_out_of_i64_range() {
222        // u64::MAX cannot fit in i64 → visit_u64 error branch
223        let val = serde_json::json!({"ts": u64::MAX});
224        let result: Result<Event, _> = serde_json::from_value(val);
225        assert!(result.is_err());
226    }
227
228    // --- roundtrip ---
229
230    #[test]
231    fn roundtrip() {
232        let original = Event {
233            ts: Utc.timestamp_opt(1_234_567_890, 0).unwrap(),
234        };
235        let json = serde_json::to_string(&original).unwrap();
236        let back: Event = serde_json::from_str(&json).unwrap();
237        assert_eq!(back, original);
238    }
239
240    // --- expecting: exercise error message path ---
241
242    #[test]
243    fn deserialize_invalid_type_triggers_expecting() {
244        // Passing a boolean to a timestamp field exercises the `expecting` path in the error
245        let result: Result<Event, _> = serde_json::from_str(r#"{"ts":true}"#);
246        assert!(result.is_err());
247    }
248
249    // --- error paths: visit_i64 out-of-range ---
250
251    #[test]
252    fn deserialize_i64_out_of_range() {
253        // i64::MAX is far beyond any valid Unix timestamp that chrono accepts,
254        // exercising the `ok_or_else` branch in visit_i64.
255        let val = serde_json::json!({"ts": i64::MAX});
256        let result: Result<Event, _> = serde_json::from_value(val);
257        assert!(result.is_err());
258    }
259
260    // --- error paths: visit_f64 out-of-range ---
261
262    #[test]
263    fn deserialize_f64_out_of_range() {
264        // A very large float maps to seconds beyond what chrono can represent.
265        let val = serde_json::json!({"ts": 1.0e18_f64});
266        let result: Result<Event, _> = serde_json::from_value(val);
267        assert!(result.is_err());
268    }
269}