Skip to main content

jmap_types/
patch.rs

1//! RFC 8620 §5.3 [`PatchObject`] — the typed wire-format wrapper for
2//! JMAP `*/set` `update` argument values.
3//!
4//! `PatchObject` is a string-keyed JSON map where:
5//!
6//! - Keys are RFC 6901 JSON Pointers with an *implicit* leading `/`
7//!   (i.e. the wire form is `"alerts/1/offset"`, evaluated as
8//!   `"/alerts/1/offset"` for the JSON Pointer algorithm).
9//! - A `null` leaf value either restores a default value (if the property
10//!   has a spec-defined default) or removes that property from the object.
11//! - Any other leaf value replaces or adds the value at that path.
12//!
13//! The type is intentionally a thin newtype around
14//! [`serde_json::Map<String, serde_json::Value>`] with `#[serde(transparent)]`,
15//! so the wire format is byte-identical to a bare JSON object. The newtype
16//! exists to bind the RFC 8620 §5.3 contract to the type system: when a
17//! function signature carries `PatchObject`, every reader knows the contained
18//! map is a JMAP patch (with JSON Pointer key semantics and the null-leaf
19//! removal rule), not arbitrary JSON.
20//!
21//! # Path-syntax validation is the handler's job
22//!
23//! Per RFC 8620 §5.3, path violations (paths that traverse arrays, paths
24//! whose parent does not yet exist on the patched object, paths that prefix
25//! another patch in the same `PatchObject`) MUST be rejected by the server
26//! with an `invalidPatch` error. This crate intentionally does **not**
27//! perform that validation: the wire-format type is value-agnostic, and
28//! the meaning of each path is method-specific (e.g. `Email/set` patches
29//! mean something different from `Mailbox/set` patches). Validation lives
30//! in the method handler that knows the target object's schema.
31//!
32//! # Future adoption
33//!
34//! [`crate::SetObject::Patch`] is currently typed as
35//! `serde::Serialize + serde::de::DeserializeOwned`, which downstream
36//! crates fill with `serde_json::Value`. Migrating those impls to use
37//! `PatchObject` is a separate, opt-in refactor (see bd JMAP-o7cj
38//! follow-ups) and does not change the wire format.
39
40use serde::{Deserialize, Serialize};
41use serde_json::{Map, Value};
42
43/// A JMAP `PatchObject` (RFC 8620 §5.3) — a String → JSON map describing
44/// the changes to apply to a record during `*/set` `update`.
45///
46/// See the [module documentation](crate::patch) for the path-key semantics
47/// and null-leaf removal rule.
48///
49/// # Wire format
50///
51/// `PatchObject` serialises and deserialises as a plain JSON object via
52/// `#[serde(transparent)]`. There is no discriminant or wrapper key on the
53/// wire — `{"name": "New"}` and `PatchObject::from_map([("name", json!("New"))])`
54/// are equivalent.
55///
56/// # Example
57///
58/// ```
59/// use jmap_types::PatchObject;
60/// use serde_json::{json, Map, Value};
61///
62/// let mut m = Map::new();
63/// m.insert("name".to_owned(), json!("Renamed"));
64/// m.insert("keywords/$flagged".to_owned(), Value::Null);
65/// let patch = PatchObject::from_map(m);
66///
67/// // Wire form is byte-identical to the inner map.
68/// let wire = serde_json::to_string(&patch).unwrap();
69/// assert!(wire.contains("\"name\":\"Renamed\""));
70/// assert!(wire.contains("\"keywords/$flagged\":null"));
71/// ```
72//
73// `#[non_exhaustive]` reserves the right to add fields later (e.g. a
74// metadata or audit-trail companion) without a SemVer break. The inner
75// field is intentionally private so the only construction paths are the
76// explicit constructors below; this matches the `Id`/`UTCDate`/`State`
77// pattern already used in this crate.
78#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
79#[serde(transparent)]
80#[non_exhaustive]
81pub struct PatchObject(Map<String, Value>);
82
83impl PatchObject {
84    /// Construct an empty patch.
85    ///
86    /// An empty patch is a valid no-op `update` value per RFC 8620 §5.3:
87    /// the server accepts it and applies no changes to the target record.
88    pub fn new() -> Self {
89        Self(Map::new())
90    }
91
92    /// Wrap an existing [`serde_json::Map`] as a [`PatchObject`].
93    ///
94    /// No validation is performed — the caller is responsible for the
95    /// keys conforming to RFC 8620 §5.3 path syntax (or the server will
96    /// reject the patch with `invalidPatch`).
97    pub fn from_map(map: Map<String, Value>) -> Self {
98        Self(map)
99    }
100
101    /// Borrow the inner map.
102    pub fn as_map(&self) -> &Map<String, Value> {
103        &self.0
104    }
105
106    /// Mutably borrow the inner map for in-place edits.
107    pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
108        &mut self.0
109    }
110
111    /// Consume the [`PatchObject`] and return the underlying map.
112    pub fn into_inner(self) -> Map<String, Value> {
113        self.0
114    }
115
116    /// `true` if the patch contains no key/value pairs.
117    pub fn is_empty(&self) -> bool {
118        self.0.is_empty()
119    }
120
121    /// Number of key/value pairs in the patch.
122    pub fn len(&self) -> usize {
123        self.0.len()
124    }
125}
126
127impl From<Map<String, Value>> for PatchObject {
128    fn from(map: Map<String, Value>) -> Self {
129        Self(map)
130    }
131}
132
133impl From<PatchObject> for Map<String, Value> {
134    fn from(patch: PatchObject) -> Self {
135        patch.0
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use serde_json::json;
143
144    // Independent oracle: hand-written JSON literal for an empty patch.
145    // RFC 8620 §5.3 implicitly admits an empty PatchObject as a no-op
146    // (it lists no rule that rejects an empty map).
147    #[test]
148    fn empty_patch_serializes_as_empty_object() {
149        let p = PatchObject::new();
150        let wire = serde_json::to_string(&p).expect("serialize empty patch");
151        assert_eq!(wire, "{}");
152    }
153
154    // Independent oracle: an empty JSON object must round-trip into an
155    // empty PatchObject.
156    #[test]
157    fn empty_object_deserializes_as_empty_patch() {
158        let p: PatchObject = serde_json::from_str("{}").expect("deserialize empty patch");
159        assert!(p.is_empty());
160        assert_eq!(p.len(), 0);
161    }
162
163    // Independent oracle: RFC 8620 §5.3 line 1937-1941 — "an entire Foo
164    // object is also a valid PatchObject". Here we exercise the
165    // whole-object replacement form: top-level field names without `/`
166    // separators are valid wire-format keys.
167    #[test]
168    fn whole_object_form_round_trips() {
169        let wire = r#"{"name":"Inbox","sortOrder":0,"isSubscribed":true}"#;
170        let p: PatchObject = serde_json::from_str(wire).expect("deserialize whole-object patch");
171        assert_eq!(p.len(), 3);
172        assert_eq!(p.as_map().get("name"), Some(&json!("Inbox")));
173        assert_eq!(p.as_map().get("sortOrder"), Some(&json!(0)));
174        assert_eq!(p.as_map().get("isSubscribed"), Some(&json!(true)));
175        // Round-trip back to wire — same object, byte-equal modulo key order.
176        let reserialized = serde_json::to_string(&p).expect("reserialize");
177        let reparsed: serde_json::Value = serde_json::from_str(&reserialized).unwrap();
178        let original_parsed: serde_json::Value = serde_json::from_str(wire).unwrap();
179        assert_eq!(reparsed, original_parsed);
180    }
181
182    // Independent oracle: RFC 8620 §5.3 line 1925-1927 — "If null, set to
183    // the default value if specified for this property; otherwise, remove
184    // the property from the patched object." A null leaf is the canonical
185    // "remove this field" wire form. Verify it round-trips losslessly.
186    #[test]
187    fn null_leaf_round_trips() {
188        let wire = r#"{"keywords/$flagged":null}"#;
189        let p: PatchObject = serde_json::from_str(wire).expect("deserialize null-leaf patch");
190        assert_eq!(p.as_map().get("keywords/$flagged"), Some(&Value::Null));
191        let reserialized = serde_json::to_string(&p).expect("reserialize");
192        assert_eq!(reserialized, wire);
193    }
194
195    // Independent oracle: RFC 8620 §5.3 line 1918-1920 — patch keys are
196    // JSON Pointer paths with an implicit leading `/`. The example in the
197    // RFC text uses `alerts/1/offset` (an alerts-array element offset).
198    // Here we verify the multi-segment path key serialises and parses
199    // verbatim — the wire-format type is path-agnostic and does not
200    // attempt JSON Pointer interpretation.
201    #[test]
202    fn pointer_path_key_round_trips() {
203        let wire = r#"{"alerts/abc/offset":"PT5M"}"#;
204        let p: PatchObject = serde_json::from_str(wire).expect("deserialize pointer-path patch");
205        assert_eq!(p.as_map().get("alerts/abc/offset"), Some(&json!("PT5M")));
206        let reserialized = serde_json::to_string(&p).expect("reserialize");
207        assert_eq!(reserialized, wire);
208    }
209
210    // Independent oracle: a non-null nested object value at a path key.
211    // From RFC 8620 §5.3 line 1929-1930 — "Anything else: The value to
212    // set for this property". Setting a whole sub-object at a path is
213    // valid and common (e.g. setting `alerts/abc` to a full Alert object).
214    #[test]
215    fn nested_object_value_round_trips() {
216        let inner = json!({"offset": "PT5M", "type": "display"});
217        let mut m = Map::new();
218        m.insert("alerts/abc".to_owned(), inner.clone());
219        let p = PatchObject::from_map(m);
220        let wire = serde_json::to_string(&p).expect("serialize nested object");
221        // Round-trip: parse -> compare structurally to avoid key-order
222        // dependence inside the inner object.
223        let parsed: PatchObject = serde_json::from_str(&wire).expect("reparse");
224        assert_eq!(parsed.as_map().get("alerts/abc"), Some(&inner));
225    }
226
227    // Independent oracle: the wire format MUST be byte-identical to a
228    // plain JSON object — this is what `#[serde(transparent)]` guarantees
229    // and is the entire reason for the newtype's design. If a future
230    // refactor accidentally drops `#[serde(transparent)]` this test will
231    // fail with an extra wrapper key on the wire.
232    #[test]
233    fn wire_format_is_transparent_json_object() {
234        let mut m = Map::new();
235        m.insert("name".to_owned(), json!("Test"));
236        let p = PatchObject::from_map(m.clone());
237
238        let from_patch = serde_json::to_string(&p).unwrap();
239        let from_map = serde_json::to_string(&m).unwrap();
240        assert_eq!(from_patch, from_map);
241    }
242
243    // Independent oracle: From<Map> and Into<Map> conversions are total
244    // and lossless.
245    #[test]
246    fn from_into_map_round_trip() {
247        let mut m = Map::new();
248        m.insert("k".to_owned(), json!(42));
249
250        let p: PatchObject = m.clone().into();
251        let back: Map<String, Value> = p.into();
252        assert_eq!(back, m);
253    }
254
255    // Independent oracle: Default impl yields an empty patch — same as ::new().
256    #[test]
257    fn default_is_empty() {
258        let p: PatchObject = Default::default();
259        assert!(p.is_empty());
260        assert_eq!(p, PatchObject::new());
261    }
262
263    // Independent oracle: as_map_mut allows in-place modification, and
264    // the modification is visible on subsequent serialise.
265    #[test]
266    fn as_map_mut_allows_in_place_edits() {
267        let mut p = PatchObject::new();
268        p.as_map_mut().insert("name".to_owned(), json!("Edited"));
269        let wire = serde_json::to_string(&p).unwrap();
270        assert_eq!(wire, r#"{"name":"Edited"}"#);
271    }
272
273    // Independent oracle: a non-object JSON value MUST fail to deserialize
274    // as a PatchObject. RFC 8620 §5.3 mandates that a PatchObject is a
275    // map (`String[*]`); arrays, scalars, and null are not valid wire
276    // values for this type.
277    #[test]
278    fn non_object_json_fails_to_deserialize() {
279        assert!(serde_json::from_str::<PatchObject>("[]").is_err());
280        assert!(serde_json::from_str::<PatchObject>("42").is_err());
281        assert!(serde_json::from_str::<PatchObject>("\"string\"").is_err());
282        assert!(serde_json::from_str::<PatchObject>("true").is_err());
283        assert!(serde_json::from_str::<PatchObject>("null").is_err());
284    }
285}