Skip to main content

cognee_http_server/dto/
util.rs

1//! Shared deserializer utilities re-used across multiple pipeline-router DTOs.
2
3use serde::{Deserialize, Deserializer, Serialize, de};
4use uuid::Uuid;
5
6// ─── iso8601_offset (Decision 6) ──────────────────────────────────────────────
7
8/// Serde helper module for `chrono::DateTime<Utc>` fields whose wire format
9/// must match Python's `datetime.isoformat()` shape.
10///
11/// Python's pydantic `OutDTO.model_dump()` calls `datetime.isoformat()` which
12/// emits an explicit `+00:00` offset and microsecond precision (e.g.
13/// `"2026-04-29T14:32:01.123456+00:00"`). chrono's default `Serialize` impl
14/// instead emits `"…Z"` with nanosecond precision, which causes byte-level
15/// drift against the Python SDK on every wire-visible timestamp.
16///
17/// This helper is the project-wide remedy (per
18/// [`docs/http-api-v2/README.md` §1.1 — Decision 6](../../../../docs/http-api-v2/README.md#11-wire-conventions-project-wide-set-by-decision-6)):
19///
20/// - **Serialization**: emits `%Y-%m-%dT%H:%M:%S%.6f%:z` — explicit `+00:00`
21///   offset, microsecond precision, truncating any sub-microsecond digits.
22/// - **Deserialization**: leniently accepts any RFC 3339 string via
23///   `chrono::DateTime::parse_from_rfc3339`, so both `"…+00:00"` and `"…Z"`
24///   round-trip cleanly. The parsed timestamp is converted to UTC.
25///
26/// # Usage
27///
28/// ```rust,ignore
29/// use chrono::{DateTime, Utc};
30///
31/// #[derive(Serialize, Deserialize)]
32/// struct MyDto {
33///     #[serde(with = "crate::dto::util::iso8601_offset")]
34///     created_at: DateTime<Utc>,
35/// }
36/// ```
37pub mod iso8601_offset {
38    use chrono::{DateTime, Utc};
39    use serde::{Deserialize, Deserializer, Serializer, de};
40
41    /// RFC 3339 with explicit `+00:00` offset and microsecond precision.
42    ///
43    /// `%.6f` truncates fractional seconds to 6 digits (microseconds), matching
44    /// Python's default `datetime.isoformat()` output for non-naive UTC values.
45    pub fn serialize<S>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
46    where
47        S: Serializer,
48    {
49        let formatted = dt.format("%Y-%m-%dT%H:%M:%S%.6f%:z").to_string();
50        s.serialize_str(&formatted)
51    }
52
53    /// Parse any RFC 3339 timestamp (with `Z` or numeric offset) and convert
54    /// to UTC. Returns a serde error on malformed input.
55    pub fn deserialize<'de, D>(d: D) -> Result<DateTime<Utc>, D::Error>
56    where
57        D: Deserializer<'de>,
58    {
59        let s = String::deserialize(d)?;
60        DateTime::parse_from_rfc3339(&s)
61            .map(|dt| dt.with_timezone(&Utc))
62            .map_err(|err| de::Error::custom(format!("invalid RFC 3339 timestamp {s:?}: {err}")))
63    }
64}
65
66// ─── iso8601_offset_option (Decision 6 — Option<DateTime<Utc>>) ───────────────
67
68/// Sibling of [`iso8601_offset`] for `Option<DateTime<Utc>>` fields.
69///
70/// `None` round-trips to / from JSON `null`. `Some(dt)` emits the same
71/// `%Y-%m-%dT%H:%M:%S%.6f%:z` shape as [`iso8601_offset`] and accepts any
72/// RFC 3339 string on input.
73///
74/// # Usage
75///
76/// ```rust,ignore
77/// #[derive(Serialize, Deserialize)]
78/// struct MyDto {
79///     #[serde(with = "crate::dto::util::iso8601_offset_option", default)]
80///     ended_at: Option<chrono::DateTime<chrono::Utc>>,
81/// }
82/// ```
83pub mod iso8601_offset_option {
84    use chrono::{DateTime, Utc};
85    use serde::{Deserialize, Deserializer, Serializer, de};
86
87    pub fn serialize<S>(dt: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
88    where
89        S: Serializer,
90    {
91        match dt {
92            Some(t) => {
93                let formatted = t.format("%Y-%m-%dT%H:%M:%S%.6f%:z").to_string();
94                s.serialize_some(&formatted)
95            }
96            None => s.serialize_none(),
97        }
98    }
99
100    pub fn deserialize<'de, D>(d: D) -> Result<Option<DateTime<Utc>>, D::Error>
101    where
102        D: Deserializer<'de>,
103    {
104        let opt: Option<String> = Option::deserialize(d)?;
105        match opt {
106            None => Ok(None),
107            Some(s) => DateTime::parse_from_rfc3339(&s)
108                .map(|dt| Some(dt.with_timezone(&Utc)))
109                .map_err(|err| {
110                    de::Error::custom(format!("invalid RFC 3339 timestamp {s:?}: {err}"))
111                }),
112        }
113    }
114}
115
116// ─── DatasetIdRef ─────────────────────────────────────────────────────────────
117
118/// A nullable dataset-id field that accepts three forms:
119///
120/// | Wire value | Deserialises to |
121/// |---|---|
122/// | `null` (JSON null) | `None` |
123/// | `""` (empty string) | `None` |
124/// | `"<valid UUID>"` | `Some(<uuid>)` |
125///
126/// Any other string — non-UUID, non-empty — is a deserialization error.
127///
128/// This matches Python's `Optional[UUID]` behaviour combined with the
129/// empty-string normalization applied by several FastAPI endpoints.
130///
131/// # Usage
132///
133/// ```rust,ignore
134/// #[derive(Deserialize)]
135/// struct MyPayload {
136///     #[serde(default)]
137///     dataset_id: DatasetIdRef,
138/// }
139/// ```
140///
141/// The newtype wraps `Option<Uuid>` and is `#[repr(transparent)]`.
142#[derive(Debug, Clone, Default, PartialEq, Eq)]
143pub struct DatasetIdRef(pub Option<Uuid>);
144
145impl Serialize for DatasetIdRef {
146    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
147    where
148        S: serde::Serializer,
149    {
150        self.0.serialize(serializer)
151    }
152}
153
154impl DatasetIdRef {
155    /// Consume the newtype and return the inner `Option<Uuid>`.
156    pub fn into_inner(self) -> Option<Uuid> {
157        self.0
158    }
159
160    /// Borrow the inner `Option<Uuid>`.
161    pub fn as_option(&self) -> Option<Uuid> {
162        self.0
163    }
164}
165
166impl From<DatasetIdRef> for Option<Uuid> {
167    fn from(d: DatasetIdRef) -> Self {
168        d.0
169    }
170}
171
172// ─── OpenAPI schema ───────────────────────────────────────────────────────────
173
174impl utoipa::ToSchema for DatasetIdRef {
175    fn name() -> std::borrow::Cow<'static, str> {
176        std::borrow::Cow::Borrowed("DatasetIdRef")
177    }
178}
179
180impl utoipa::PartialSchema for DatasetIdRef {
181    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
182        // Represented as a nullable UUID string in the OpenAPI spec.
183        utoipa::openapi::RefOr::T(utoipa::openapi::Schema::Object(
184            utoipa::openapi::ObjectBuilder::new()
185                .schema_type(utoipa::openapi::schema::Type::String)
186                .description(Some(
187                    "Optional dataset UUID. Null, empty string, or a valid UUID string.",
188                ))
189                .build(),
190        ))
191    }
192}
193
194impl<'de> Deserialize<'de> for DatasetIdRef {
195    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
196    where
197        D: Deserializer<'de>,
198    {
199        // We accept: JSON null, empty string, valid UUID string.
200        // We reject: any other non-empty string.
201        let opt: Option<String> = Option::deserialize(deserializer)?;
202        match opt {
203            None => Ok(DatasetIdRef(None)),
204            Some(s) if s.trim().is_empty() => Ok(DatasetIdRef(None)),
205            Some(s) => {
206                let uuid = Uuid::parse_str(&s).map_err(|_| {
207                    de::Error::custom(format!(
208                        "invalid dataset_id: expected a UUID string or empty, got {s:?}"
209                    ))
210                })?;
211                Ok(DatasetIdRef(Some(uuid)))
212            }
213        }
214    }
215}
216
217// ─── Unit tests ───────────────────────────────────────────────────────────────
218
219#[cfg(test)]
220#[allow(
221    clippy::unwrap_used,
222    clippy::expect_used,
223    reason = "test code — panics are acceptable failures"
224)]
225mod tests {
226    use super::*;
227    use serde::Deserialize;
228    use serde_json::json;
229
230    #[derive(Debug, Deserialize)]
231    struct Wrapper {
232        #[serde(default)]
233        id: DatasetIdRef,
234    }
235
236    fn parse(v: serde_json::Value) -> Result<DatasetIdRef, serde_json::Error> {
237        #[derive(Deserialize)]
238        struct W {
239            id: DatasetIdRef,
240        }
241        let w: W = serde_json::from_value(json!({ "id": v }))?;
242        Ok(w.id)
243    }
244
245    #[test]
246    fn null_deserialises_to_none() {
247        let result = parse(json!(null)).expect("should succeed");
248        assert_eq!(result, DatasetIdRef(None));
249    }
250
251    #[test]
252    fn empty_string_deserialises_to_none() {
253        let result = parse(json!("")).expect("should succeed");
254        assert_eq!(result, DatasetIdRef(None));
255    }
256
257    #[test]
258    fn whitespace_only_string_deserialises_to_none() {
259        let result = parse(json!("   ")).expect("should succeed");
260        assert_eq!(result, DatasetIdRef(None));
261    }
262
263    #[test]
264    fn valid_uuid_deserialises_to_some() {
265        let id = Uuid::new_v4();
266        let result = parse(json!(id.to_string())).expect("should succeed");
267        assert_eq!(result, DatasetIdRef(Some(id)));
268    }
269
270    #[test]
271    fn invalid_uuid_string_is_rejected() {
272        let err = parse(json!("not-a-uuid")).expect_err("should fail");
273        assert!(
274            err.to_string().contains("invalid dataset_id"),
275            "error message should mention the field: {err}"
276        );
277    }
278
279    #[test]
280    fn non_string_scalar_is_rejected() {
281        let err = parse(json!(42)).expect_err("should fail for integer");
282        // serde reports a type mismatch
283        assert!(!err.to_string().is_empty());
284    }
285
286    #[test]
287    fn default_is_none() {
288        let w: Wrapper = serde_json::from_str("{}").expect("empty object");
289        assert_eq!(w.id, DatasetIdRef(None));
290    }
291
292    #[test]
293    fn into_inner_works() {
294        let id = Uuid::new_v4();
295        let d = DatasetIdRef(Some(id));
296        assert_eq!(d.into_inner(), Some(id));
297
298        let none = DatasetIdRef(None);
299        assert_eq!(none.into_inner(), None);
300    }
301
302    // ─── iso8601_offset (Decision 6) ─────────────────────────────────────────
303
304    use chrono::{DateTime, TimeZone, Utc};
305    use serde::Serialize;
306
307    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
308    struct TsWrapper {
309        #[serde(with = "super::iso8601_offset")]
310        ts: DateTime<Utc>,
311    }
312
313    #[test]
314    fn serializes_utc_with_plus_zero_zero() {
315        // 2026-04-29T14:32:01Z -> "2026-04-29T14:32:01.000000+00:00"
316        let ts = Utc
317            .with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
318            .single()
319            .expect("valid UTC datetime");
320        let w = TsWrapper { ts };
321        let s = serde_json::to_string(&w).expect("serialize");
322        assert!(
323            s.contains("\"2026-04-29T14:32:01.000000+00:00\""),
324            "expected +00:00 offset in: {s}"
325        );
326        assert!(
327            !s.contains("Z\""),
328            "should not emit chrono's default Z suffix: {s}"
329        );
330    }
331
332    #[test]
333    fn deserializes_z_suffix() {
334        let json = r#"{"ts":"2026-04-29T14:32:01Z"}"#;
335        let w: TsWrapper = serde_json::from_str(json).expect("Z suffix should parse");
336        let expected = Utc
337            .with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
338            .single()
339            .expect("valid UTC datetime");
340        assert_eq!(w.ts, expected);
341    }
342
343    #[test]
344    fn deserializes_plus_zero_zero() {
345        let json = r#"{"ts":"2026-04-29T14:32:01+00:00"}"#;
346        let w: TsWrapper = serde_json::from_str(json).expect("+00:00 offset should parse");
347        let expected = Utc
348            .with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
349            .single()
350            .expect("valid UTC datetime");
351        assert_eq!(w.ts, expected);
352    }
353
354    #[test]
355    fn round_trip_microsecond_precision() {
356        let json = r#"{"ts":"2026-04-29T14:32:01.123456+00:00"}"#;
357        let w: TsWrapper = serde_json::from_str(json).expect("microsecond input");
358        let s = serde_json::to_string(&w).expect("serialize");
359        assert!(
360            s.contains("\"2026-04-29T14:32:01.123456+00:00\""),
361            "round-trip should preserve microseconds: {s}"
362        );
363    }
364
365    // ─── iso8601_offset_option round-trip ─────────────────────────────────
366
367    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
368    struct OptTsWrapper {
369        #[serde(with = "super::iso8601_offset_option", default)]
370        ts: Option<DateTime<Utc>>,
371    }
372
373    #[test]
374    fn iso8601_offset_option_round_trip_some_and_none() {
375        // Some -> emits the offset shape.
376        let ts = Utc
377            .with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
378            .single()
379            .expect("valid UTC datetime");
380        let w = OptTsWrapper { ts: Some(ts) };
381        let s = serde_json::to_string(&w).expect("serialize Some");
382        assert!(
383            s.contains("\"2026-04-29T14:32:01.000000+00:00\""),
384            "Some(...) should emit +00:00 offset shape: {s}"
385        );
386
387        // None -> JSON null.
388        let w_none = OptTsWrapper { ts: None };
389        let s_none = serde_json::to_string(&w_none).expect("serialize None");
390        assert_eq!(s_none, r#"{"ts":null}"#);
391
392        // Round-trip null and an offset string.
393        let parsed_null: OptTsWrapper = serde_json::from_str(r#"{"ts":null}"#).expect("null");
394        assert_eq!(parsed_null.ts, None);
395
396        let parsed_some: OptTsWrapper =
397            serde_json::from_str(r#"{"ts":"2026-04-29T14:32:01.000000+00:00"}"#).expect("some");
398        assert_eq!(parsed_some.ts, Some(ts));
399    }
400
401    #[test]
402    fn truncates_nanoseconds_to_microseconds_on_serialize() {
403        // Build a datetime carrying 123_456_789 ns; the helper must drop the
404        // last three digits ("789") so the wire matches Python microseconds.
405        let ts = Utc
406            .with_ymd_and_hms(2026, 4, 29, 14, 32, 1)
407            .single()
408            .expect("valid UTC datetime")
409            + chrono::Duration::nanoseconds(123_456_789);
410        let w = TsWrapper { ts };
411        let s = serde_json::to_string(&w).expect("serialize");
412        assert!(
413            s.contains("\"2026-04-29T14:32:01.123456+00:00\""),
414            "expected microsecond truncation, got: {s}"
415        );
416        assert!(
417            !s.contains("123456789"),
418            "nanoseconds should be truncated, got: {s}"
419        );
420    }
421}