144 lines
4.6 KiB
Rust
144 lines
4.6 KiB
Rust
//! Serde adaptors for timestamp fields: `i64` unix-epoch seconds in
|
|
//! Rust, RFC 3339 UTC strings (`2026-07-02T18:30:00Z`) in JSON.
|
|
//!
|
|
//! Rust code keeps doing plain integer arithmetic on these fields —
|
|
//! only the serialized representation changes, so the dashboard (and
|
|
//! any other JSON consumer) can feed the value straight into
|
|
//! `new Date(s)` without the `* 1000` epoch dance.
|
|
//!
|
|
//! Deserialization is lenient: both the RFC 3339 string form and the
|
|
//! legacy bare-integer form are accepted. That keeps a rolling deploy
|
|
//! safe (an old peer emitting epoch ints into a new reader) and lets
|
|
//! previously persisted JSON blobs re-load unchanged.
|
|
//!
|
|
//! Usage: `#[serde(with = "crate::wire_time::iso")]` on `i64` fields,
|
|
//! `#[serde(with = "crate::wire_time::iso_opt")]` on `Option<i64>`
|
|
//! (keep the usual `default` + `skip_serializing_if` attributes).
|
|
|
|
use chrono::{DateTime, SecondsFormat, Utc};
|
|
use serde::Deserialize;
|
|
|
|
/// Format unix-epoch seconds as an RFC 3339 UTC string with a `Z`
|
|
/// suffix. Out-of-range values (never produced by our clocks) clamp to
|
|
/// the epoch rather than erroring — serialization must not fail.
|
|
#[must_use]
|
|
pub fn to_iso(secs: i64) -> String {
|
|
DateTime::<Utc>::from_timestamp(secs, 0)
|
|
.unwrap_or_default()
|
|
.to_rfc3339_opts(SecondsFormat::Secs, true)
|
|
}
|
|
|
|
/// Parse an RFC 3339 string back to unix-epoch seconds. Any UTC offset
|
|
/// is accepted and normalized.
|
|
pub fn from_iso(s: &str) -> Result<i64, chrono::ParseError> {
|
|
Ok(DateTime::parse_from_rfc3339(s)?.timestamp())
|
|
}
|
|
|
|
/// Lenient wire form: either the legacy epoch integer or the RFC 3339
|
|
/// string. `untagged` tries the integer first (cheap), then the string.
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum EpochOrIso {
|
|
Epoch(i64),
|
|
Iso(String),
|
|
}
|
|
|
|
impl EpochOrIso {
|
|
fn into_secs<E: serde::de::Error>(self) -> Result<i64, E> {
|
|
match self {
|
|
Self::Epoch(secs) => Ok(secs),
|
|
Self::Iso(s) => from_iso(&s).map_err(E::custom),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adaptor for required `i64` timestamp fields.
|
|
pub mod iso {
|
|
use serde::{Deserializer, Serializer};
|
|
|
|
use super::{Deserialize, EpochOrIso};
|
|
|
|
pub fn serialize<S: Serializer>(secs: &i64, ser: S) -> Result<S::Ok, S::Error> {
|
|
ser.serialize_str(&super::to_iso(*secs))
|
|
}
|
|
|
|
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<i64, D::Error> {
|
|
EpochOrIso::deserialize(de)?.into_secs()
|
|
}
|
|
}
|
|
|
|
/// Adaptor for `Option<i64>` timestamp fields.
|
|
pub mod iso_opt {
|
|
use serde::{Deserializer, Serializer};
|
|
|
|
use super::{Deserialize, EpochOrIso};
|
|
|
|
pub fn serialize<S: Serializer>(secs: &Option<i64>, ser: S) -> Result<S::Ok, S::Error> {
|
|
match secs {
|
|
Some(secs) => ser.serialize_str(&super::to_iso(*secs)),
|
|
None => ser.serialize_none(),
|
|
}
|
|
}
|
|
|
|
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<i64>, D::Error> {
|
|
Option::<EpochOrIso>::deserialize(de)?
|
|
.map(EpochOrIso::into_secs)
|
|
.transpose()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
|
struct Row {
|
|
#[serde(with = "crate::wire_time::iso")]
|
|
at: i64,
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Option::is_none",
|
|
with = "crate::wire_time::iso_opt"
|
|
)]
|
|
maybe_at: Option<i64>,
|
|
}
|
|
|
|
#[test]
|
|
fn serializes_epoch_as_rfc3339_z() {
|
|
let json = serde_json::to_string(&Row {
|
|
at: 1_751_480_000,
|
|
maybe_at: None,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(json, r#"{"at":"2025-07-02T18:13:20Z"}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips_and_serializes_some() {
|
|
let row = Row {
|
|
at: 0,
|
|
maybe_at: Some(1_751_480_000),
|
|
};
|
|
let json = serde_json::to_string(&row).unwrap();
|
|
assert_eq!(
|
|
json,
|
|
r#"{"at":"1970-01-01T00:00:00Z","maybe_at":"2025-07-02T18:13:20Z"}"#
|
|
);
|
|
assert_eq!(serde_json::from_str::<Row>(&json).unwrap(), row);
|
|
}
|
|
|
|
#[test]
|
|
fn deserializes_legacy_epoch_ints() {
|
|
// Rolling-deploy skew: an old writer still emits bare epoch
|
|
// integers — the lenient reader must accept them.
|
|
let row: Row = serde_json::from_str(r#"{"at":1751480000,"maybe_at":1751480000}"#).unwrap();
|
|
assert_eq!(row.at, 1_751_480_000);
|
|
assert_eq!(row.maybe_at, Some(1_751_480_000));
|
|
}
|
|
|
|
#[test]
|
|
fn deserializes_offset_form_normalized_to_utc() {
|
|
let row: Row = serde_json::from_str(r#"{"at":"2025-07-02T20:13:20+02:00"}"#).unwrap();
|
|
assert_eq!(row.at, 1_751_480_000);
|
|
}
|
|
}
|