Skip to main content

jmap_types/
wire.rs

1//! RFC 8620 §3 JMAP request/response envelope types ([`JmapRequest`], [`JmapResponse`], [`Invocation`]).
2
3use crate::id::{Id, State};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7/// A JMAP method invocation (RFC 8620 §3.2).
8///
9/// On the wire this is a 3-element JSON array
10/// `[name, arguments, callId]`; in Rust the elements are named fields.
11/// [`From`] conversions to and from `(String, Value, String)` are
12/// provided for code that already holds the tuple form.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Invocation {
15    /// Method name, e.g. `"Email/get"`, or `"error"` in a response.
16    pub name: String,
17    /// Method arguments (request) or result (response) object.
18    pub arguments: serde_json::Value,
19    /// Client-chosen method call id, echoed back in the response.
20    pub call_id: String,
21}
22
23impl Invocation {
24    /// Construct an [`Invocation`] from its three wire elements.
25    pub fn new(
26        name: impl Into<String>,
27        arguments: serde_json::Value,
28        call_id: impl Into<String>,
29    ) -> Self {
30        Self {
31            name: name.into(),
32            arguments,
33            call_id: call_id.into(),
34        }
35    }
36}
37
38impl From<(String, serde_json::Value, String)> for Invocation {
39    fn from((name, arguments, call_id): (String, serde_json::Value, String)) -> Self {
40        Self {
41            name,
42            arguments,
43            call_id,
44        }
45    }
46}
47
48impl From<Invocation> for (String, serde_json::Value, String) {
49    fn from(inv: Invocation) -> Self {
50        (inv.name, inv.arguments, inv.call_id)
51    }
52}
53
54impl Serialize for Invocation {
55    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
56        (&self.name, &self.arguments, &self.call_id).serialize(serializer)
57    }
58}
59
60impl<'de> Deserialize<'de> for Invocation {
61    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
62        <(String, serde_json::Value, String)>::deserialize(deserializer).map(Self::from)
63    }
64}
65
66/// JMAP request envelope (RFC 8620 §3.3).
67#[non_exhaustive]
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct JmapRequest {
70    /// Capability URIs this request uses, e.g. `["urn:ietf:params:jmap:core"]`.
71    pub using: Vec<String>,
72    /// Ordered list of method invocations.
73    #[serde(rename = "methodCalls")]
74    pub method_calls: Vec<Invocation>,
75    /// Client-supplied creation ID map (optional, RFC 8620 §3.3).
76    #[serde(rename = "createdIds", skip_serializing_if = "Option::is_none")]
77    pub created_ids: Option<BTreeMap<Id, Id>>,
78    /// Catch-all for vendor / site / private extension fields not covered
79    /// by the typed fields above. Preserves unknown fields across
80    /// deserialize/serialize round-trip per workspace extras-preservation
81    /// policy (see workspace AGENTS.md).
82    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
83    pub extra: serde_json::Map<String, serde_json::Value>,
84}
85
86/// JMAP response envelope (RFC 8620 §3.4).
87#[non_exhaustive]
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct JmapResponse {
90    /// Ordered list of method responses (same `[name, arguments, callId]`
91    /// structure as requests).
92    #[serde(rename = "methodResponses")]
93    pub method_responses: Vec<Invocation>,
94    /// Opaque server state token. Changes when any data type's state advances.
95    #[serde(rename = "sessionState")]
96    pub session_state: State,
97    /// Maps client-supplied creation IDs to server-assigned IDs.
98    /// Omitted when no objects were created in the batch (RFC 8620 §3.4).
99    #[serde(rename = "createdIds", skip_serializing_if = "Option::is_none")]
100    pub created_ids: Option<BTreeMap<Id, Id>>,
101    /// Catch-all for vendor / site / private extension fields not covered
102    /// by the typed fields above. Preserves unknown fields across
103    /// deserialize/serialize round-trip per workspace extras-preservation
104    /// policy (see workspace AGENTS.md).
105    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
106    pub extra: serde_json::Map<String, serde_json::Value>,
107}
108
109impl JmapRequest {
110    /// Construct a [`JmapRequest`].
111    ///
112    /// Required because the struct is `#[non_exhaustive]` and cannot be
113    /// built with a struct literal outside this crate.
114    pub fn new(
115        using: Vec<String>,
116        method_calls: Vec<Invocation>,
117        created_ids: Option<BTreeMap<Id, Id>>,
118    ) -> Self {
119        Self {
120            using,
121            method_calls,
122            created_ids,
123            extra: serde_json::Map::new(),
124        }
125    }
126}
127
128impl JmapResponse {
129    /// Construct a [`JmapResponse`].
130    ///
131    /// Required because the struct is `#[non_exhaustive]` and cannot be
132    /// built with a struct literal outside this crate.
133    pub fn new(
134        method_responses: Vec<Invocation>,
135        session_state: State,
136        created_ids: Option<BTreeMap<Id, Id>>,
137    ) -> Self {
138        Self {
139            method_responses,
140            session_state,
141            created_ids,
142            extra: serde_json::Map::new(),
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use serde_json::json;
151
152    // Oracle: tests/fixtures/rfc8620-request.json (RFC 8620 §3.3.1)
153    #[test]
154    fn request_deserializes_from_rfc_fixture() {
155        let raw = include_str!("../tests/fixtures/rfc8620-request.json");
156        let req: JmapRequest = serde_json::from_str(raw).expect("deserialize JmapRequest");
157        assert_eq!(req.using.len(), 2);
158        assert_eq!(req.using[0], "urn:ietf:params:jmap:core");
159        assert_eq!(req.using[1], "urn:ietf:params:jmap:mail");
160        assert_eq!(req.method_calls.len(), 3);
161        assert_eq!(req.method_calls[0].name, "method1");
162        assert_eq!(
163            req.method_calls[0].arguments,
164            json!({"arg1": "arg1data", "arg2": "arg2data"})
165        );
166        assert_eq!(req.method_calls[0].call_id, "c1");
167        assert_eq!(req.method_calls[2].name, "method3");
168        // method3 has empty args {}
169        assert_eq!(req.method_calls[2].arguments, json!({}));
170        assert!(req.created_ids.is_none());
171    }
172
173    // Oracle: tests/fixtures/rfc8620-response.json (RFC 8620 §3.4.1)
174    #[test]
175    fn response_deserializes_from_rfc_fixture() {
176        let raw = include_str!("../tests/fixtures/rfc8620-response.json");
177        let resp: JmapResponse = serde_json::from_str(raw).expect("deserialize JmapResponse");
178        assert_eq!(resp.session_state.as_ref(), "75128aab4b1b");
179        assert_eq!(resp.method_responses.len(), 4);
180        assert_eq!(resp.method_responses[0].name, "method1");
181        assert_eq!(resp.method_responses[3].name, "error");
182        assert_eq!(resp.method_responses[3].call_id, "c3");
183        assert!(resp.created_ids.is_none());
184    }
185
186    // Oracle: RFC 8620 §3.3 — field name must be "methodCalls" (camelCase).
187    #[test]
188    fn request_serializes_camelcase() {
189        let req = JmapRequest {
190            using: vec!["urn:ietf:params:jmap:core".into()],
191            method_calls: vec![],
192            created_ids: None,
193            extra: serde_json::Map::new(),
194        };
195        let j = serde_json::to_string(&req).expect("serialize");
196        assert!(
197            j.contains("\"methodCalls\""),
198            "must use camelCase methodCalls"
199        );
200        assert!(!j.contains("\"method_calls\""), "must not use snake_case");
201    }
202
203    // Oracle: RFC 8620 §3.4 — field names "methodResponses" and "sessionState".
204    #[test]
205    fn response_serializes_camelcase() {
206        let resp = JmapResponse {
207            method_responses: vec![],
208            session_state: "s-1".into(),
209            created_ids: None,
210            extra: serde_json::Map::new(),
211        };
212        let j = serde_json::to_string(&resp).expect("serialize");
213        assert!(j.contains("\"methodResponses\""));
214        assert!(j.contains("\"sessionState\""));
215    }
216
217    // Oracle: RFC 8620 §3.4 — createdIds omitted when no objects were created.
218    #[test]
219    fn created_ids_absent_when_none() {
220        let resp = JmapResponse {
221            method_responses: vec![],
222            session_state: "s-1".into(),
223            created_ids: None,
224            extra: serde_json::Map::new(),
225        };
226        let j = serde_json::to_string(&resp).expect("serialize");
227        assert!(
228            !j.contains("createdIds"),
229            "createdIds must be absent when None"
230        );
231    }
232
233    // Oracle: RFC 8620 §3.4 — createdIds present when objects were created.
234    #[test]
235    fn created_ids_present_when_some() {
236        let mut ids = std::collections::BTreeMap::new();
237        ids.insert(Id::from("c0"), Id::from("server-1"));
238        let resp = JmapResponse {
239            method_responses: vec![],
240            session_state: "s-1".into(),
241            created_ids: Some(ids),
242            extra: serde_json::Map::new(),
243        };
244        let v = serde_json::to_value(&resp).expect("serialize");
245        assert_eq!(v["createdIds"]["c0"], "server-1");
246    }
247
248    // Oracle: RFC 8620 §3.2 — Invocation serializes as a 3-element JSON array.
249    #[test]
250    fn invocation_is_three_element_array() {
251        let inv = Invocation::new("m/get", json!({"accountId": "a1"}), "c0");
252        let j = serde_json::to_string(&inv).expect("serialize");
253        assert_eq!(j, r#"["m/get",{"accountId":"a1"},"c0"]"#);
254    }
255
256    // Oracle: RFC 8620 §3.3.1 example request; the methodCalls array
257    // re-serializes to the same JSON value element-for-element.
258    #[test]
259    fn rfc_request_method_calls_round_trip() {
260        let raw = include_str!("../tests/fixtures/rfc8620-request.json");
261        let original: serde_json::Value = serde_json::from_str(raw).expect("parse fixture");
262        let req: JmapRequest = serde_json::from_str(raw).expect("deserialize JmapRequest");
263        let back = serde_json::to_value(&req.method_calls).expect("serialize");
264        assert_eq!(back, original["methodCalls"]);
265    }
266
267    // Oracle: RFC 8620 §3.2 — the Invocation "is a tuple, represented as a
268    // JSON array containing three elements". Any other shape is rejected.
269    #[test]
270    fn invocation_rejects_wrong_arity() {
271        assert!(serde_json::from_str::<Invocation>(r#"["m/get",{}]"#).is_err());
272        assert!(serde_json::from_str::<Invocation>(r#"["m/get",{},"c0","x"]"#).is_err());
273        assert!(serde_json::from_str::<Invocation>(r#"{"name":"m/get"}"#).is_err());
274    }
275
276    #[test]
277    fn invocation_tuple_conversions() {
278        let inv = Invocation::from(("m/get".to_owned(), json!({}), "c0".to_owned()));
279        assert_eq!(inv.name, "m/get");
280        assert_eq!(inv.arguments, json!({}));
281        assert_eq!(inv.call_id, "c0");
282        let t: (String, serde_json::Value, String) = inv.into();
283        assert_eq!(t, ("m/get".to_owned(), json!({}), "c0".to_owned()));
284    }
285
286    // ── Extras-preservation policy tests (JMAP-lbdy.1) ───────────────────
287    //
288    // Round-trip preservation tests asserting vendor / site / private-
289    // extension fields survive deserialize/serialize unchanged on the
290    // envelope types. Per workspace AGENTS.md "Extras-preservation policy
291    // for vendor/site fields".
292
293    /// `JmapRequest.extra` captures vendor envelope-level fields and
294    /// preserves them on re-serialize.
295    #[test]
296    fn request_preserves_vendor_extras() {
297        let raw = json!({
298            "using": ["urn:ietf:params:jmap:core"],
299            "methodCalls": [["m1", {}, "c0"]],
300            "acmeCorpClientTraceId": "trace-7"
301        });
302        let req: JmapRequest = serde_json::from_value(raw).unwrap();
303        assert_eq!(
304            req.extra
305                .get("acmeCorpClientTraceId")
306                .and_then(|v| v.as_str()),
307            Some("trace-7"),
308            "vendor field must land in extra: {:?}",
309            req.extra
310        );
311        let back = serde_json::to_value(&req).unwrap();
312        assert_eq!(
313            back["acmeCorpClientTraceId"], "trace-7",
314            "vendor field must survive serialize"
315        );
316    }
317
318    /// `JmapResponse.extra` captures vendor envelope-level fields and
319    /// preserves them on re-serialize.
320    #[test]
321    fn response_preserves_vendor_extras() {
322        let raw = json!({
323            "methodResponses": [],
324            "sessionState": "s-1",
325            "acmeCorpServerHostName": "srv-3"
326        });
327        let resp: JmapResponse = serde_json::from_value(raw).unwrap();
328        assert_eq!(
329            resp.extra
330                .get("acmeCorpServerHostName")
331                .and_then(|v| v.as_str()),
332            Some("srv-3")
333        );
334        let back = serde_json::to_value(&resp).unwrap();
335        assert_eq!(back["acmeCorpServerHostName"], "srv-3");
336    }
337
338    /// Empty extras must NOT add any keys to the wire form — `skip_serializing_if`
339    /// keeps the byte shape identical to the pre-migration envelope.
340    #[test]
341    fn empty_extras_omitted_from_wire() {
342        let req = JmapRequest::new(vec!["urn:ietf:params:jmap:core".into()], vec![], None);
343        let v = serde_json::to_value(&req).expect("serialize");
344        let obj = v.as_object().expect("object");
345        // Expected wire keys: using + methodCalls. createdIds skipped (None).
346        // extras skipped (empty).
347        assert_eq!(
348            obj.len(),
349            2,
350            "empty extras + skipped optionals must yield exactly using+methodCalls; got {v}"
351        );
352        assert!(obj.contains_key("using"));
353        assert!(obj.contains_key("methodCalls"));
354    }
355
356    /// Pins the workspace extras-preservation caller-contract for the
357    /// foundation envelope type [`JmapRequest`].
358    ///
359    /// Per workspace AGENTS.md "Caller contract — wire-key collisions"
360    /// (lines 292-312), callers MUST NOT insert keys into `extra` whose
361    /// names match a typed field on the same struct. The contract is
362    /// enforced at the doc/convention layer, not via a wrapper type.
363    /// This test locks in the current `#[serde(flatten)]` behavior so a
364    /// future serde change cannot silently alter the contract:
365    ///
366    /// 1. Serialize succeeds and emits BOTH the typed field's value and
367    ///    the colliding extras entry verbatim, producing JSON with two
368    ///    entries for the same key. RFC 8259 §4 calls this "unpredictable",
369    ///    and downstream parsers vary in which value wins.
370    /// 2. The crate's own `Deserialize` impl rejects the duplicate-key
371    ///    wire form with a "duplicate field" error, so a serialize +
372    ///    deserialize round-trip surfaces the contract violation rather
373    ///    than silently propagating it.
374    ///
375    /// Companion regression marker in the extension-types canonical:
376    /// `crate-jmap-mail-types/src/email.rs`
377    /// `extra_collision_with_typed_field_round_trip_fails`.
378    /// Filed under bd:JMAP-6xs8.4.
379    ///
380    /// Independent oracle: the expected duplicate-key count (two
381    /// `"methodCalls":` substring occurrences) is built by string
382    /// counting against the serialized bytes, not by the code under test.
383    #[test]
384    fn request_extra_collision_with_typed_field_round_trip_fails() {
385        let mut req = JmapRequest::new(
386            vec!["urn:ietf:params:jmap:core".into()],
387            vec![Invocation::new("real-method", json!({}), "c-real")],
388            None,
389        );
390        // Caller-contract violation: inserting a key whose name matches
391        // a typed field on the same struct.
392        req.extra
393            .insert("methodCalls".into(), json!([["acmeFake", {}, "c-fake"]]));
394
395        // (1) Serialize succeeds and emits both keys.
396        let serialised = serde_json::to_string(&req).expect("serialize must succeed");
397        let occurrences = serialised.matches("\"methodCalls\":").count();
398        assert_eq!(
399            occurrences, 2,
400            "wire output must contain two duplicate methodCalls keys; \
401             got {serialised}"
402        );
403
404        // (2) Deserialize on the same bytes must reject with duplicate
405        // field. RFC 8259 §4 calls duplicate keys "unpredictable" and
406        // the workspace's strict-deserialize stance rejects them.
407        let err = serde_json::from_str::<JmapRequest>(&serialised)
408            .expect_err("deserialize must reject duplicate-key wire form");
409        assert!(
410            err.to_string().contains("duplicate field"),
411            "error must mention duplicate field; got: {err}"
412        );
413    }
414
415    // Oracle: workspace map-ordering policy (bd:JMAP-pdux) — wire-format
416    // maps serialize in sorted key order. Expected bytes are hand-written.
417    #[test]
418    fn created_ids_serialize_in_sorted_key_order() {
419        let mut ids = std::collections::BTreeMap::new();
420        ids.insert(Id::from("c2"), Id::from("server-2"));
421        ids.insert(Id::from("c1"), Id::from("server-1"));
422        let resp = JmapResponse {
423            method_responses: vec![],
424            session_state: "s-1".into(),
425            created_ids: Some(ids),
426            extra: serde_json::Map::new(),
427        };
428        let j = serde_json::to_string(&resp).expect("serialize");
429        assert!(
430            j.contains(r#""createdIds":{"c1":"server-1","c2":"server-2"}"#),
431            "createdIds keys must be sorted: {j}"
432        );
433    }
434}