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