Skip to main content

aster_forge_api/
patch.rs

1//! Omitted/null/value semantics for typed PATCH fields.
2use serde::Deserialize;
3/// Three-state nullable field used by PATCH-style request DTOs.
4///
5/// `Absent` means the request omitted the field and the existing value should be preserved.
6/// `Null` means the request explicitly supplied `null` and the existing value should be cleared.
7/// `Value` means the request supplied a concrete replacement value.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum NullablePatch<T> {
10    /// Field was omitted from the request.
11    #[default]
12    Absent,
13    /// Field was present with JSON `null`.
14    Null,
15    /// Field was present with a concrete value.
16    Value(T),
17}
18
19impl<T> NullablePatch<T> {
20    /// Returns whether the request supplied this field as either `null` or a concrete value.
21    pub fn is_present(&self) -> bool {
22        !matches!(self, Self::Absent)
23    }
24}
25
26/// Deserializes an optional PATCH field while preserving explicit `null`.
27///
28/// Use this with `#[serde(default, deserialize_with = "...")]` on `Option<NullablePatch<T>>`
29/// fields when the surrounding DTO needs to distinguish omitted fields from explicit nulls.
30///
31/// # Errors
32///
33/// Returns the deserializer's error when the present value cannot be deserialized as `T`.
34pub fn deserialize_nullable_patch_option<'de, D, T>(
35    deserializer: D,
36) -> std::result::Result<Option<NullablePatch<T>>, D::Error>
37where
38    D: serde::Deserializer<'de>,
39    T: Deserialize<'de>,
40{
41    Option::<T>::deserialize(deserializer).map(|value| Some(NullablePatch::from(value)))
42}
43
44impl<T> From<Option<T>> for NullablePatch<T> {
45    fn from(value: Option<T>) -> Self {
46        match value {
47            Some(value) => Self::Value(value),
48            None => Self::Null,
49        }
50    }
51}
52
53impl<'de, T> Deserialize<'de> for NullablePatch<T>
54where
55    T: Deserialize<'de>,
56{
57    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
58    where
59        D: serde::Deserializer<'de>,
60    {
61        Ok(match Option::<T>::deserialize(deserializer)? {
62            Some(value) => Self::Value(value),
63            None => Self::Null,
64        })
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use serde_json::json;
72
73    #[test]
74    fn patch_preserves_empty_values_and_rejects_wrong_types() {
75        let dto: PatchDto = serde_json::from_value(json!({"title":"", "description":""})).unwrap();
76        assert_eq!(dto.title, NullablePatch::Value(String::new()));
77        assert_eq!(dto.description, Some(NullablePatch::Value(String::new())));
78        for value in [
79            json!({"title":42}),
80            json!({"description":[]}),
81            json!({"title":false}),
82        ] {
83            assert!(serde_json::from_value::<PatchDto>(value).is_err());
84        }
85    }
86    #[derive(Debug, Deserialize, PartialEq, Eq)]
87    struct PatchDto {
88        #[serde(default)]
89        title: NullablePatch<String>,
90        #[serde(default, deserialize_with = "deserialize_nullable_patch_option")]
91        description: Option<NullablePatch<String>>,
92    }
93
94    #[test]
95    fn nullable_patch_deserializes_absent_null_and_value_fields() {
96        let dto: PatchDto = serde_json::from_value(json!({})).unwrap();
97        assert_eq!(dto.title, NullablePatch::Absent);
98        assert_eq!(dto.description, None);
99        assert!(!dto.title.is_present());
100
101        let dto: PatchDto =
102            serde_json::from_value(json!({ "title": null, "description": null })).unwrap();
103        assert_eq!(dto.title, NullablePatch::Null);
104        assert_eq!(dto.description, Some(NullablePatch::Null));
105        assert!(dto.title.is_present());
106
107        let dto: PatchDto = serde_json::from_value(json!({
108            "title": "new title",
109            "description": "new description"
110        }))
111        .unwrap();
112        assert_eq!(dto.title, NullablePatch::Value("new title".to_string()));
113        assert_eq!(
114            dto.description,
115            Some(NullablePatch::Value("new description".to_string()))
116        );
117    }
118}