use serde::{Deserialize, Deserializer, Serialize, de};
use uuid::Uuid;
pub mod iso8601_offset {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serializer, de};
pub fn serialize<S>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let formatted = dt.format("%Y-%m-%dT%H:%M:%S%.6f%:z").to_string();
s.serialize_str(&formatted)
}
pub fn deserialize<'de, D>(d: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(d)?;
DateTime::parse_from_rfc3339(&s)
.map(|dt| dt.with_timezone(&Utc))
.map_err(|err| de::Error::custom(format!("invalid RFC 3339 timestamp {s:?}: {err}")))
}
}
pub mod iso8601_offset_option {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serializer, de};
pub fn serialize<S>(dt: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match dt {
Some(t) => {
let formatted = t.format("%Y-%m-%dT%H:%M:%S%.6f%:z").to_string();
s.serialize_some(&formatted)
}
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D>(d: D) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(d)?;
match opt {
None => Ok(None),
Some(s) => DateTime::parse_from_rfc3339(&s)
.map(|dt| Some(dt.with_timezone(&Utc)))
.map_err(|err| {
de::Error::custom(format!("invalid RFC 3339 timestamp {s:?}: {err}"))
}),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DatasetIdRef(pub Option<Uuid>);
impl Serialize for DatasetIdRef {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}
impl DatasetIdRef {
pub fn into_inner(self) -> Option<Uuid> {
self.0
}
pub fn as_option(&self) -> Option<Uuid> {
self.0
}
}
impl From<DatasetIdRef> for Option<Uuid> {
fn from(d: DatasetIdRef) -> Self {
d.0
}
}
impl utoipa::ToSchema for DatasetIdRef {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("DatasetIdRef")
}
}
impl utoipa::PartialSchema for DatasetIdRef {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
utoipa::openapi::RefOr::T(utoipa::openapi::Schema::Object(
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String)
.description(Some(
"Optional dataset UUID. Null, empty string, or a valid UUID string.",
))
.build(),
))
}
}
impl<'de> Deserialize<'de> for DatasetIdRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
match opt {
None => Ok(DatasetIdRef(None)),
Some(s) if s.trim().is_empty() => Ok(DatasetIdRef(None)),
Some(s) => {
let uuid = Uuid::parse_str(&s).map_err(|_| {
de::Error::custom(format!(
"invalid dataset_id: expected a UUID string or empty, got {s:?}"
))
})?;
Ok(DatasetIdRef(Some(uuid)))
}
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code — panics are acceptable failures"
)]
mod tests {
use super::*;
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize)]
struct Wrapper {
#[serde(default)]
id: DatasetIdRef,
}
fn parse(v: serde_json::Value) -> Result<DatasetIdRef, serde_json::Error> {
#[derive(Deserialize)]
struct W {
id: DatasetIdRef,
}
let w: W = serde_json::from_value(json!({ "id": v }))?;
Ok(w.id)
}
#[test]
fn null_deserialises_to_none() {
let result = parse(json!(null)).expect("should succeed");
assert_eq!(result, DatasetIdRef(None));
}
#[test]
fn empty_string_deserialises_to_none() {
let result = parse(json!("")).expect("should succeed");
assert_eq!(result, DatasetIdRef(None));
}
#[test]
fn whitespace_only_string_deserialises_to_none() {
let result = parse(json!(" ")).expect("should succeed");
assert_eq!(result, DatasetIdRef(None));
}
#[test]
fn valid_uuid_deserialises_to_some() {
let id = Uuid::new_v4();
let result = parse(json!(id.to_string())).expect("should succeed");
assert_eq!(result, DatasetIdRef(Some(id)));
}
#[test]
fn invalid_uuid_string_is_rejected() {
let err = parse(json!("not-a-uuid")).expect_err("should fail");
assert!(
err.to_string().contains("invalid dataset_id"),
"error message should mention the field: {err}"
);
}
#[test]
fn non_string_scalar_is_rejected() {
let err = parse(json!(42)).expect_err("should fail for integer");
assert!(!err.to_string().is_empty());
}
#[test]
fn default_is_none() {
let w: Wrapper = serde_json::from_str("{}").expect("empty object");
assert_eq!(w.id, DatasetIdRef(None));
}
#[test]
fn into_inner_works() {
let id = Uuid::new_v4();
let d = DatasetIdRef(Some(id));
assert_eq!(d.into_inner(), Some(id));
let none = DatasetIdRef(None);
assert_eq!(none.into_inner(), None);
}
use chrono::{DateTime, TimeZone, Utc};
use serde::Serialize;
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct TsWrapper {
#[serde(with = "super::iso8601_offset")]
ts: DateTime<Utc>,
}
#[test]
fn serializes_utc_with_plus_zero_zero() {
let ts = Utc
.with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
.single()
.expect("valid UTC datetime");
let w = TsWrapper { ts };
let s = serde_json::to_string(&w).expect("serialize");
assert!(
s.contains("\"2026-04-29T14:32:01.000000+00:00\""),
"expected +00:00 offset in: {s}"
);
assert!(
!s.contains("Z\""),
"should not emit chrono's default Z suffix: {s}"
);
}
#[test]
fn deserializes_z_suffix() {
let json = r#"{"ts":"2026-04-29T14:32:01Z"}"#;
let w: TsWrapper = serde_json::from_str(json).expect("Z suffix should parse");
let expected = Utc
.with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
.single()
.expect("valid UTC datetime");
assert_eq!(w.ts, expected);
}
#[test]
fn deserializes_plus_zero_zero() {
let json = r#"{"ts":"2026-04-29T14:32:01+00:00"}"#;
let w: TsWrapper = serde_json::from_str(json).expect("+00:00 offset should parse");
let expected = Utc
.with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
.single()
.expect("valid UTC datetime");
assert_eq!(w.ts, expected);
}
#[test]
fn round_trip_microsecond_precision() {
let json = r#"{"ts":"2026-04-29T14:32:01.123456+00:00"}"#;
let w: TsWrapper = serde_json::from_str(json).expect("microsecond input");
let s = serde_json::to_string(&w).expect("serialize");
assert!(
s.contains("\"2026-04-29T14:32:01.123456+00:00\""),
"round-trip should preserve microseconds: {s}"
);
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct OptTsWrapper {
#[serde(with = "super::iso8601_offset_option", default)]
ts: Option<DateTime<Utc>>,
}
#[test]
fn iso8601_offset_option_round_trip_some_and_none() {
let ts = Utc
.with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
.single()
.expect("valid UTC datetime");
let w = OptTsWrapper { ts: Some(ts) };
let s = serde_json::to_string(&w).expect("serialize Some");
assert!(
s.contains("\"2026-04-29T14:32:01.000000+00:00\""),
"Some(...) should emit +00:00 offset shape: {s}"
);
let w_none = OptTsWrapper { ts: None };
let s_none = serde_json::to_string(&w_none).expect("serialize None");
assert_eq!(s_none, r#"{"ts":null}"#);
let parsed_null: OptTsWrapper = serde_json::from_str(r#"{"ts":null}"#).expect("null");
assert_eq!(parsed_null.ts, None);
let parsed_some: OptTsWrapper =
serde_json::from_str(r#"{"ts":"2026-04-29T14:32:01.000000+00:00"}"#).expect("some");
assert_eq!(parsed_some.ts, Some(ts));
}
#[test]
fn truncates_nanoseconds_to_microseconds_on_serialize() {
let ts = Utc
.with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
.single()
.expect("valid UTC datetime")
+ chrono::Duration::nanoseconds(123_456_789);
let w = TsWrapper { ts };
let s = serde_json::to_string(&w).expect("serialize");
assert!(
s.contains("\"2026-04-29T14:32:01.123456+00:00\""),
"expected microsecond truncation, got: {s}"
);
assert!(
!s.contains("123456789"),
"nanoseconds should be truncated, got: {s}"
);
}
}