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}