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::HashMap;
6
7/// A JMAP method invocation: `[method_name, arguments, call_id]`.
8///
9/// Serializes as a 3-element JSON array per RFC 8620 §3.2.
10pub type Invocation = (String, serde_json::Value, String);
11
12/// JMAP request envelope (RFC 8620 §3.3).
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[non_exhaustive]
15pub struct JmapRequest {
16    /// Capability URIs this request uses, e.g. `["urn:ietf:params:jmap:core"]`.
17    pub using: Vec<String>,
18    /// Ordered list of method invocations.
19    #[serde(rename = "methodCalls")]
20    pub method_calls: Vec<Invocation>,
21    /// Client-supplied creation ID map (optional, RFC 8620 §3.3).
22    #[serde(rename = "createdIds", skip_serializing_if = "Option::is_none")]
23    pub created_ids: Option<HashMap<Id, Id>>,
24    /// Catch-all for vendor / site / private extension fields not covered
25    /// by the typed fields above. Preserves unknown fields across
26    /// deserialize/serialize round-trip per workspace extras-preservation
27    /// policy (see workspace AGENTS.md).
28    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
29    pub extra: serde_json::Map<String, serde_json::Value>,
30}
31
32/// JMAP response envelope (RFC 8620 §3.4).
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[non_exhaustive]
35pub struct JmapResponse {
36    /// Ordered list of method responses (same 3-tuple structure as requests).
37    #[serde(rename = "methodResponses")]
38    pub method_responses: Vec<Invocation>,
39    /// Opaque server state token. Changes when any data type's state advances.
40    #[serde(rename = "sessionState")]
41    pub session_state: State,
42    /// Maps client-supplied creation IDs to server-assigned IDs.
43    /// Omitted when no objects were created in the batch (RFC 8620 §3.4).
44    #[serde(rename = "createdIds", skip_serializing_if = "Option::is_none")]
45    pub created_ids: Option<HashMap<Id, Id>>,
46    /// Catch-all for vendor / site / private extension fields not covered
47    /// by the typed fields above. Preserves unknown fields across
48    /// deserialize/serialize round-trip per workspace extras-preservation
49    /// policy (see workspace AGENTS.md).
50    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
51    pub extra: serde_json::Map<String, serde_json::Value>,
52}
53
54impl JmapRequest {
55    /// Construct a [`JmapRequest`].
56    ///
57    /// Required because the struct is `#[non_exhaustive]` and cannot be
58    /// built with a struct literal outside this crate.
59    pub fn new(
60        using: Vec<String>,
61        method_calls: Vec<Invocation>,
62        created_ids: Option<HashMap<Id, Id>>,
63    ) -> Self {
64        Self {
65            using,
66            method_calls,
67            created_ids,
68            extra: serde_json::Map::new(),
69        }
70    }
71}
72
73impl JmapResponse {
74    /// Construct a [`JmapResponse`].
75    ///
76    /// Required because the struct is `#[non_exhaustive]` and cannot be
77    /// built with a struct literal outside this crate.
78    pub fn new(
79        method_responses: Vec<Invocation>,
80        session_state: State,
81        created_ids: Option<HashMap<Id, Id>>,
82    ) -> Self {
83        Self {
84            method_responses,
85            session_state,
86            created_ids,
87            extra: serde_json::Map::new(),
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use serde_json::json;
96
97    // Oracle: tests/fixtures/rfc8620-request.json (RFC 8620 §3.3.1)
98    #[test]
99    fn request_deserializes_from_rfc_fixture() {
100        let raw = include_str!("../tests/fixtures/rfc8620-request.json");
101        let req: JmapRequest = serde_json::from_str(raw).expect("deserialize JmapRequest");
102        assert_eq!(req.using.len(), 2);
103        assert_eq!(req.using[0], "urn:ietf:params:jmap:core");
104        assert_eq!(req.using[1], "urn:ietf:params:jmap:mail");
105        assert_eq!(req.method_calls.len(), 3);
106        assert_eq!(req.method_calls[0].0, "method1");
107        assert_eq!(req.method_calls[0].2, "c1");
108        assert_eq!(req.method_calls[2].0, "method3");
109        // method3 has empty args {}
110        assert_eq!(req.method_calls[2].1, json!({}));
111        assert!(req.created_ids.is_none());
112    }
113
114    // Oracle: tests/fixtures/rfc8620-response.json (RFC 8620 §3.4.1)
115    #[test]
116    fn response_deserializes_from_rfc_fixture() {
117        let raw = include_str!("../tests/fixtures/rfc8620-response.json");
118        let resp: JmapResponse = serde_json::from_str(raw).expect("deserialize JmapResponse");
119        assert_eq!(resp.session_state.as_ref(), "75128aab4b1b");
120        assert_eq!(resp.method_responses.len(), 4);
121        assert_eq!(resp.method_responses[0].0, "method1");
122        assert_eq!(resp.method_responses[3].0, "error");
123        assert_eq!(resp.method_responses[3].2, "c3");
124        assert!(resp.created_ids.is_none());
125    }
126
127    // Oracle: RFC 8620 §3.3 — field name must be "methodCalls" (camelCase).
128    #[test]
129    fn request_serializes_camelcase() {
130        let req = JmapRequest {
131            using: vec!["urn:ietf:params:jmap:core".into()],
132            method_calls: vec![],
133            created_ids: None,
134            extra: serde_json::Map::new(),
135        };
136        let j = serde_json::to_string(&req).expect("serialize");
137        assert!(
138            j.contains("\"methodCalls\""),
139            "must use camelCase methodCalls"
140        );
141        assert!(!j.contains("\"method_calls\""), "must not use snake_case");
142    }
143
144    // Oracle: RFC 8620 §3.4 — field names "methodResponses" and "sessionState".
145    #[test]
146    fn response_serializes_camelcase() {
147        let resp = JmapResponse {
148            method_responses: vec![],
149            session_state: "s-1".into(),
150            created_ids: None,
151            extra: serde_json::Map::new(),
152        };
153        let j = serde_json::to_string(&resp).expect("serialize");
154        assert!(j.contains("\"methodResponses\""));
155        assert!(j.contains("\"sessionState\""));
156    }
157
158    // Oracle: RFC 8620 §3.4 — createdIds omitted when no objects were created.
159    #[test]
160    fn created_ids_absent_when_none() {
161        let resp = JmapResponse {
162            method_responses: vec![],
163            session_state: "s-1".into(),
164            created_ids: None,
165            extra: serde_json::Map::new(),
166        };
167        let j = serde_json::to_string(&resp).expect("serialize");
168        assert!(
169            !j.contains("createdIds"),
170            "createdIds must be absent when None"
171        );
172    }
173
174    // Oracle: RFC 8620 §3.4 — createdIds present when objects were created.
175    #[test]
176    fn created_ids_present_when_some() {
177        let mut ids = std::collections::HashMap::new();
178        ids.insert(Id::from("c0"), Id::from("server-1"));
179        let resp = JmapResponse {
180            method_responses: vec![],
181            session_state: "s-1".into(),
182            created_ids: Some(ids),
183            extra: serde_json::Map::new(),
184        };
185        let v = serde_json::to_value(&resp).expect("serialize");
186        assert_eq!(v["createdIds"]["c0"], "server-1");
187    }
188
189    // Oracle: RFC 8620 §3.2 — Invocation serializes as a 3-element JSON array.
190    #[test]
191    fn invocation_is_three_element_array() {
192        let inv: Invocation = ("m/get".into(), json!({"accountId": "a1"}), "c0".into());
193        let j = serde_json::to_string(&inv).expect("serialize");
194        assert_eq!(j, r#"["m/get",{"accountId":"a1"},"c0"]"#);
195    }
196
197    // ── Extras-preservation policy tests (JMAP-lbdy.1) ───────────────────
198    //
199    // Round-trip preservation tests asserting vendor / site / private-
200    // extension fields survive deserialize/serialize unchanged on the
201    // envelope types. Per workspace AGENTS.md "Extras-preservation policy
202    // for vendor/site fields".
203
204    /// `JmapRequest.extra` captures vendor envelope-level fields and
205    /// preserves them on re-serialize.
206    #[test]
207    fn request_preserves_vendor_extras() {
208        let raw = json!({
209            "using": ["urn:ietf:params:jmap:core"],
210            "methodCalls": [["m1", {}, "c0"]],
211            "acmeCorpClientTraceId": "trace-7"
212        });
213        let req: JmapRequest = serde_json::from_value(raw).unwrap();
214        assert_eq!(
215            req.extra
216                .get("acmeCorpClientTraceId")
217                .and_then(|v| v.as_str()),
218            Some("trace-7"),
219            "vendor field must land in extra: {:?}",
220            req.extra
221        );
222        let back = serde_json::to_value(&req).unwrap();
223        assert_eq!(
224            back["acmeCorpClientTraceId"], "trace-7",
225            "vendor field must survive serialize"
226        );
227    }
228
229    /// `JmapResponse.extra` captures vendor envelope-level fields and
230    /// preserves them on re-serialize.
231    #[test]
232    fn response_preserves_vendor_extras() {
233        let raw = json!({
234            "methodResponses": [],
235            "sessionState": "s-1",
236            "acmeCorpServerHostName": "srv-3"
237        });
238        let resp: JmapResponse = serde_json::from_value(raw).unwrap();
239        assert_eq!(
240            resp.extra
241                .get("acmeCorpServerHostName")
242                .and_then(|v| v.as_str()),
243            Some("srv-3")
244        );
245        let back = serde_json::to_value(&resp).unwrap();
246        assert_eq!(back["acmeCorpServerHostName"], "srv-3");
247    }
248
249    /// Empty extras must NOT add any keys to the wire form — `skip_serializing_if`
250    /// keeps the byte shape identical to the pre-migration envelope.
251    #[test]
252    fn empty_extras_omitted_from_wire() {
253        let req = JmapRequest::new(vec!["urn:ietf:params:jmap:core".into()], vec![], None);
254        let v = serde_json::to_value(&req).expect("serialize");
255        let obj = v.as_object().expect("object");
256        // Expected wire keys: using + methodCalls. createdIds skipped (None).
257        // extras skipped (empty).
258        assert_eq!(
259            obj.len(),
260            2,
261            "empty extras + skipped optionals must yield exactly using+methodCalls; got {v}"
262        );
263        assert!(obj.contains_key("using"));
264        assert!(obj.contains_key("methodCalls"));
265    }
266}