Skip to main content

io_jmap/rfc8620/
set.rs

1//! Generic JMAP `Foo/set` coroutine (RFC 8620 §5.3): wraps [`JmapSend`] with a
2//! single create/update/destroy batch and a typed response decoder.
3//!
4//! # Example
5//!
6//! ```rust,no_run
7//! use std::collections::BTreeMap;
8//! use io_jmap::rfc8620::set::{JmapSet, JmapSetOptions};
9//! use secrecy::SecretString;
10//! use serde::{Deserialize, Serialize};
11//! use url::Url;
12//!
13//! #[derive(Serialize)]
14//! struct MailboxCreate { name: String }
15//! #[derive(Deserialize, Serialize)]
16//! struct Mailbox { id: String }
17//!
18//! let auth = SecretString::from("Bearer xyz");
19//! let api_url: Url = "https://api.example.com/jmap/".parse().unwrap();
20//! let mut create = BTreeMap::new();
21//! create.insert("c1".to_string(), MailboxCreate { name: "Inbox".into() });
22//!
23//! let coroutine = JmapSet::<Mailbox>::new::<_, Mailbox>(
24//!     "a1".into(),
25//!     &auth,
26//!     &api_url,
27//!     "Mailbox/set",
28//!     vec!["urn:ietf:params:jmap:mail".into()],
29//!     JmapSetOptions { create: Some(create), ..Default::default() },
30//! )
31//! .unwrap();
32//! # let _ = coroutine;
33//! ```
34
35use core::{fmt, marker::PhantomData};
36
37use alloc::{collections::BTreeMap, string::String, vec::Vec};
38
39use log::trace;
40use secrecy::SecretString;
41use serde::{Deserialize, Serialize, de::DeserializeOwned};
42use thiserror::Error;
43use url::Url;
44
45use crate::{
46    coroutine::*,
47    jmap_try,
48    rfc8620::{JmapBatch, JmapMethodError, send::*},
49};
50
51/// Failure causes during a JMAP `Foo/set` flow.
52#[derive(Debug, Error)]
53pub enum JmapSetError {
54    #[error("JMAP Foo/set failed: missing response in method_responses")]
55    MissingResponse,
56    #[error("JMAP Foo/set failed: {0}")]
57    Send(#[from] JmapSendError),
58    #[error("JMAP Foo/set failed: serialize args: {0}")]
59    SerializeArgs(#[source] serde_json::Error),
60    #[error("JMAP Foo/set failed: parse response: {0}")]
61    ParseResponse(#[source] serde_json::Error),
62    #[error("JMAP Foo/set failed: {0}")]
63    Method(#[from] JmapMethodError),
64}
65
66/// Options for [`JmapSet::new`].
67#[derive(Clone, Debug)]
68pub struct JmapSetOptions<C: Serialize, U: Serialize> {
69    /// Optimistic concurrency token: matched against the server's current
70    /// state; mismatch aborts the call with `stateMismatch`.
71    pub if_in_state: Option<String>,
72    /// Objects to create (client ID → partial object).
73    pub create: Option<BTreeMap<String, C>>,
74    /// Objects to update (id → patch).
75    pub update: Option<BTreeMap<String, U>>,
76    /// Object IDs to destroy.
77    pub destroy: Option<Vec<String>>,
78}
79
80impl<C: Serialize, U: Serialize> Default for JmapSetOptions<C, U> {
81    fn default() -> Self {
82        Self {
83            if_in_state: None,
84            create: None,
85            update: None,
86            destroy: None,
87        }
88    }
89}
90
91/// Successful terminal output of the [`JmapSet`] coroutine.
92#[derive(Clone, Debug)]
93pub struct JmapSetOutput<T> {
94    pub new_state: String,
95    pub created: BTreeMap<String, T>,
96    pub updated: BTreeMap<String, Option<T>>,
97    pub destroyed: Vec<String>,
98    pub not_created: BTreeMap<String, serde_json::Value>,
99    pub not_updated: BTreeMap<String, serde_json::Value>,
100    pub not_destroyed: BTreeMap<String, serde_json::Value>,
101    pub keep_alive: bool,
102}
103
104/// Generic I/O-free coroutine for the JMAP `Foo/set` method (RFC 8620 §5.3).
105pub struct JmapSet<T> {
106    state: State,
107    _phantom: PhantomData<T>,
108}
109
110impl<T: DeserializeOwned> JmapSet<T> {
111    /// Builds a single-call `Foo/set` batch and wraps it in [`JmapSend`].
112    pub fn new<C: Serialize, U: Serialize>(
113        account_id: String,
114        http_auth: &SecretString,
115        api_url: &Url,
116        method: impl Into<String>,
117        capabilities: Vec<String>,
118        opts: JmapSetOptions<C, U>,
119    ) -> Result<Self, JmapSetError> {
120        let args = serde_json::to_value(SetArgs {
121            account_id,
122            if_in_state: opts.if_in_state,
123            create: opts.create,
124            update: opts.update,
125            destroy: opts.destroy,
126        })
127        .map_err(JmapSetError::SerializeArgs)?;
128
129        let mut batch = JmapBatch::new();
130        batch.add(method, args);
131
132        let request = batch.into_request(capabilities);
133        let send = JmapSend::new(http_auth, api_url, request)?;
134
135        Ok(Self {
136            state: State::Send(send),
137            _phantom: PhantomData,
138        })
139    }
140
141    /// Wraps a pre-built [`JmapSend`].
142    pub fn from_send(send: JmapSend) -> Self {
143        Self {
144            state: State::Send(send),
145            _phantom: PhantomData,
146        }
147    }
148}
149
150impl<T: DeserializeOwned> JmapCoroutine for JmapSet<T> {
151    type Yield = JmapYield;
152    type Return = Result<JmapSetOutput<T>, JmapSetError>;
153
154    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
155        trace!("set: {}", self.state);
156        match &mut self.state {
157            State::Send(send) => {
158                let JmapSendOutput {
159                    response,
160                    keep_alive,
161                } = jmap_try!(send, arg);
162
163                let Some((name, args, _)) = response.method_responses.into_iter().next() else {
164                    return JmapCoroutineState::Complete(Err(JmapSetError::MissingResponse));
165                };
166
167                if name == "error" {
168                    let err = serde_json::from_value::<JmapMethodError>(args)
169                        .unwrap_or(JmapMethodError::Unknown);
170                    return JmapCoroutineState::Complete(Err(err.into()));
171                }
172
173                match serde_json::from_value::<SetResponse<T>>(args) {
174                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapSetOutput {
175                        new_state: r.new_state,
176                        created: r.created.unwrap_or_default(),
177                        updated: r.updated.unwrap_or_default(),
178                        destroyed: r.destroyed.unwrap_or_default(),
179                        not_created: r.not_created.unwrap_or_default(),
180                        not_updated: r.not_updated.unwrap_or_default(),
181                        not_destroyed: r.not_destroyed.unwrap_or_default(),
182                        keep_alive,
183                    })),
184                    Err(err) => JmapCoroutineState::Complete(Err(JmapSetError::ParseResponse(err))),
185                }
186            }
187        }
188    }
189}
190
191enum State {
192    Send(JmapSend),
193}
194
195impl fmt::Display for State {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            Self::Send(_) => f.write_str("send"),
199        }
200    }
201}
202
203#[derive(Serialize)]
204#[serde(rename_all = "camelCase")]
205struct SetArgs<C: Serialize, U: Serialize> {
206    account_id: String,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    if_in_state: Option<String>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    create: Option<BTreeMap<String, C>>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    update: Option<BTreeMap<String, U>>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    destroy: Option<Vec<String>>,
215}
216
217#[derive(Deserialize)]
218#[serde(rename_all = "camelCase")]
219struct SetResponse<T> {
220    new_state: String,
221    created: Option<BTreeMap<String, T>>,
222    updated: Option<BTreeMap<String, Option<T>>>,
223    destroyed: Option<Vec<String>>,
224    not_created: Option<BTreeMap<String, serde_json::Value>>,
225    not_updated: Option<BTreeMap<String, serde_json::Value>>,
226    not_destroyed: Option<BTreeMap<String, serde_json::Value>>,
227}
228
229#[cfg(test)]
230mod tests {
231    use alloc::{format, string::ToString, vec};
232
233    use super::*;
234
235    #[derive(Debug, Deserialize, Serialize, PartialEq)]
236    struct Probe {
237        id: String,
238    }
239
240    #[derive(Serialize)]
241    struct Create {
242        name: String,
243    }
244
245    fn make_auth() -> SecretString {
246        SecretString::from("Bearer test")
247    }
248
249    fn make_url() -> Url {
250        "https://api.example.com/jmap/".parse().unwrap()
251    }
252
253    fn make_set() -> JmapSet<Probe> {
254        let mut create = BTreeMap::new();
255        create.insert(
256            "c1".to_string(),
257            Create {
258                name: "Inbox".into(),
259            },
260        );
261        JmapSet::<Probe>::new::<_, Probe>(
262            "a1".to_string(),
263            &make_auth(),
264            &make_url(),
265            "Mailbox/set",
266            vec!["urn:ietf:params:jmap:mail".to_string()],
267            JmapSetOptions {
268                create: Some(create),
269                ..Default::default()
270            },
271        )
272        .unwrap()
273    }
274
275    fn build_http_reply(body: &[u8]) -> Vec<u8> {
276        let head = format!(
277            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
278            body.len()
279        );
280        let mut bytes = head.into_bytes();
281        bytes.extend_from_slice(body);
282        bytes
283    }
284
285    #[test]
286    fn success_returns_ok() {
287        let mut cor = make_set();
288        expect_wants_write(&mut cor, None);
289        expect_wants_read(&mut cor);
290
291        let body = br#"{
292            "methodResponses": [["Mailbox/set", {"newState":"s2","created":{"c1":{"id":"m1"}}}, "c0"]],
293            "sessionState": "s2"
294        }"#;
295        let reply = build_http_reply(body);
296        let out = expect_complete_ok(&mut cor, &reply);
297        assert_eq!(out.new_state, "s2");
298        assert_eq!(out.created["c1"], Probe { id: "m1".into() });
299    }
300
301    #[test]
302    fn method_error_returns_method_error() {
303        let mut cor = make_set();
304        expect_wants_write(&mut cor, None);
305        expect_wants_read(&mut cor);
306
307        let body = br#"{
308            "methodResponses": [["error", {"type":"stateMismatch"}, "c0"]],
309            "sessionState": "s1"
310        }"#;
311        let reply = build_http_reply(body);
312        let err = expect_complete_err(&mut cor, &reply);
313        assert!(matches!(err, JmapSetError::Method(_)));
314    }
315
316    #[test]
317    fn missing_response_returns_missing_response() {
318        let mut cor = make_set();
319        expect_wants_write(&mut cor, None);
320        expect_wants_read(&mut cor);
321
322        let reply = build_http_reply(br#"{"methodResponses":[], "sessionState":"s1"}"#);
323        let err = expect_complete_err(&mut cor, &reply);
324        assert!(matches!(err, JmapSetError::MissingResponse));
325    }
326
327    #[test]
328    fn parse_error_returns_parse_response() {
329        let mut cor = make_set();
330        expect_wants_write(&mut cor, None);
331        expect_wants_read(&mut cor);
332
333        let body = br#"{
334            "methodResponses": [["Mailbox/set", {"newState":42}, "c0"]],
335            "sessionState": "s1"
336        }"#;
337        let reply = build_http_reply(body);
338        let err = expect_complete_err(&mut cor, &reply);
339        assert!(matches!(err, JmapSetError::ParseResponse(_)));
340    }
341
342    #[test]
343    fn not_created_passthrough_succeeds() {
344        let mut cor = make_set();
345        expect_wants_write(&mut cor, None);
346        expect_wants_read(&mut cor);
347
348        let body = br#"{
349            "methodResponses": [["Mailbox/set", {"newState":"s2","notCreated":{"c1":{"type":"invalidArguments"}}}, "c0"]],
350            "sessionState": "s2"
351        }"#;
352        let reply = build_http_reply(body);
353        let out = expect_complete_ok(&mut cor, &reply);
354        assert!(out.not_created.contains_key("c1"));
355    }
356
357    // --- utils
358
359    fn expect_wants_write(cor: &mut JmapSet<Probe>, arg: Option<&[u8]>) -> Vec<u8> {
360        match cor.resume(arg) {
361            JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => bytes,
362            state => panic!("expected WantsWrite, got {state:?}"),
363        }
364    }
365
366    fn expect_wants_read(cor: &mut JmapSet<Probe>) {
367        match cor.resume(None) {
368            JmapCoroutineState::Yielded(JmapYield::WantsRead) => {}
369            state => panic!("expected WantsRead, got {state:?}"),
370        }
371    }
372
373    fn expect_complete_ok(cor: &mut JmapSet<Probe>, reply: &[u8]) -> JmapSetOutput<Probe> {
374        match cor.resume(Some(reply)) {
375            JmapCoroutineState::Complete(Ok(out)) => out,
376            state => panic!("expected Complete(Ok), got {state:?}"),
377        }
378    }
379
380    fn expect_complete_err(cor: &mut JmapSet<Probe>, reply: &[u8]) -> JmapSetError {
381        match cor.resume(Some(reply)) {
382            JmapCoroutineState::Complete(Err(err)) => err,
383            state => panic!("expected Complete(Err), got {state:?}"),
384        }
385    }
386}