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}
25
26/// JMAP response envelope (RFC 8620 §3.4).
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[non_exhaustive]
29pub struct JmapResponse {
30    /// Ordered list of method responses (same 3-tuple structure as requests).
31    #[serde(rename = "methodResponses")]
32    pub method_responses: Vec<Invocation>,
33    /// Opaque server state token. Changes when any data type's state advances.
34    #[serde(rename = "sessionState")]
35    pub session_state: State,
36    /// Maps client-supplied creation IDs to server-assigned IDs.
37    /// Omitted when no objects were created in the batch (RFC 8620 §3.4).
38    #[serde(rename = "createdIds", skip_serializing_if = "Option::is_none")]
39    pub created_ids: Option<HashMap<Id, Id>>,
40}
41
42impl JmapRequest {
43    /// Construct a [`JmapRequest`].
44    ///
45    /// Required because the struct is `#[non_exhaustive]` and cannot be
46    /// built with a struct literal outside this crate.
47    pub fn new(
48        using: Vec<String>,
49        method_calls: Vec<Invocation>,
50        created_ids: Option<HashMap<Id, Id>>,
51    ) -> Self {
52        Self {
53            using,
54            method_calls,
55            created_ids,
56        }
57    }
58}
59
60impl JmapResponse {
61    /// Construct a [`JmapResponse`].
62    ///
63    /// Required because the struct is `#[non_exhaustive]` and cannot be
64    /// built with a struct literal outside this crate.
65    pub fn new(
66        method_responses: Vec<Invocation>,
67        session_state: State,
68        created_ids: Option<HashMap<Id, Id>>,
69    ) -> Self {
70        Self {
71            method_responses,
72            session_state,
73            created_ids,
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use serde_json::json;
82
83    // Oracle: tests/fixtures/rfc8620-request.json (RFC 8620 §3.3.1)
84    #[test]
85    fn request_deserializes_from_rfc_fixture() {
86        let raw = include_str!("../tests/fixtures/rfc8620-request.json");
87        let req: JmapRequest = serde_json::from_str(raw).expect("deserialize JmapRequest");
88        assert_eq!(req.using.len(), 2);
89        assert_eq!(req.using[0], "urn:ietf:params:jmap:core");
90        assert_eq!(req.using[1], "urn:ietf:params:jmap:mail");
91        assert_eq!(req.method_calls.len(), 3);
92        assert_eq!(req.method_calls[0].0, "method1");
93        assert_eq!(req.method_calls[0].2, "c1");
94        assert_eq!(req.method_calls[2].0, "method3");
95        // method3 has empty args {}
96        assert_eq!(req.method_calls[2].1, json!({}));
97        assert!(req.created_ids.is_none());
98    }
99
100    // Oracle: tests/fixtures/rfc8620-response.json (RFC 8620 §3.4.1)
101    #[test]
102    fn response_deserializes_from_rfc_fixture() {
103        let raw = include_str!("../tests/fixtures/rfc8620-response.json");
104        let resp: JmapResponse = serde_json::from_str(raw).expect("deserialize JmapResponse");
105        assert_eq!(resp.session_state.as_ref(), "75128aab4b1b");
106        assert_eq!(resp.method_responses.len(), 4);
107        assert_eq!(resp.method_responses[0].0, "method1");
108        assert_eq!(resp.method_responses[3].0, "error");
109        assert_eq!(resp.method_responses[3].2, "c3");
110        assert!(resp.created_ids.is_none());
111    }
112
113    // Oracle: RFC 8620 §3.3 — field name must be "methodCalls" (camelCase).
114    #[test]
115    fn request_serializes_camelcase() {
116        let req = JmapRequest {
117            using: vec!["urn:ietf:params:jmap:core".into()],
118            method_calls: vec![],
119            created_ids: None,
120        };
121        let j = serde_json::to_string(&req).expect("serialize");
122        assert!(
123            j.contains("\"methodCalls\""),
124            "must use camelCase methodCalls"
125        );
126        assert!(!j.contains("\"method_calls\""), "must not use snake_case");
127    }
128
129    // Oracle: RFC 8620 §3.4 — field names "methodResponses" and "sessionState".
130    #[test]
131    fn response_serializes_camelcase() {
132        let resp = JmapResponse {
133            method_responses: vec![],
134            session_state: "s-1".into(),
135            created_ids: None,
136        };
137        let j = serde_json::to_string(&resp).expect("serialize");
138        assert!(j.contains("\"methodResponses\""));
139        assert!(j.contains("\"sessionState\""));
140    }
141
142    // Oracle: RFC 8620 §3.4 — createdIds omitted when no objects were created.
143    #[test]
144    fn created_ids_absent_when_none() {
145        let resp = JmapResponse {
146            method_responses: vec![],
147            session_state: "s-1".into(),
148            created_ids: None,
149        };
150        let j = serde_json::to_string(&resp).expect("serialize");
151        assert!(
152            !j.contains("createdIds"),
153            "createdIds must be absent when None"
154        );
155    }
156
157    // Oracle: RFC 8620 §3.4 — createdIds present when objects were created.
158    #[test]
159    fn created_ids_present_when_some() {
160        let mut ids = std::collections::HashMap::new();
161        ids.insert(Id::from("c0"), Id::from("server-1"));
162        let resp = JmapResponse {
163            method_responses: vec![],
164            session_state: "s-1".into(),
165            created_ids: Some(ids),
166        };
167        let v = serde_json::to_value(&resp).expect("serialize");
168        assert_eq!(v["createdIds"]["c0"], "server-1");
169    }
170
171    // Oracle: RFC 8620 §3.2 — Invocation serializes as a 3-element JSON array.
172    #[test]
173    fn invocation_is_three_element_array() {
174        let inv: Invocation = ("m/get".into(), json!({"accountId": "a1"}), "c0".into());
175        let j = serde_json::to_string(&inv).expect("serialize");
176        assert_eq!(j, r#"["m/get",{"accountId":"a1"},"c0"]"#);
177    }
178}