Skip to main content

io_jmap/rfc8620/
get.rs

1//! Generic JMAP `Foo/get` coroutine (RFC 8620 §5.1): wraps [`JmapSend`] with a
2//! single method-call batch and a typed response decoder.
3//!
4//! # Example
5//!
6//! ```rust,no_run
7//! use std::{
8//!     io::{Read, Write},
9//!     net::TcpStream,
10//! };
11//!
12//! use io_jmap::{
13//!     coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
14//!     rfc8620::get::{JmapGet, JmapGetOptions},
15//! };
16//! use secrecy::SecretString;
17//! use serde::Deserialize;
18//! use url::Url;
19//!
20//! #[derive(Deserialize)]
21//! struct Mailbox { id: String, name: String }
22//!
23//! // Ready stream needed (TCP-connected, TLS-negociated)
24//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
25//! let mut buf = [0u8; 4096];
26//!
27//! let api_url: Url = "https://api.example.com/jmap/".parse().unwrap();
28//! let auth = SecretString::from("Bearer xyz");
29//! let mut coroutine = JmapGet::<Mailbox>::new(
30//!     "a1".into(),
31//!     &auth,
32//!     &api_url,
33//!     "Mailbox/get",
34//!     vec!["urn:ietf:params:jmap:mail".into()],
35//!     JmapGetOptions::default(),
36//! )
37//! .unwrap();
38//! let mut arg = None;
39//!
40//! let out = loop {
41//!     match coroutine.resume(arg.take()) {
42//!         JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
43//!             stream.write_all(&bytes).unwrap();
44//!         }
45//!         JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
46//!             let n = stream.read(&mut buf).unwrap();
47//!             arg = Some(&buf[..n]);
48//!         }
49//!         JmapCoroutineState::Complete(Ok(out)) => break out,
50//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
51//!     }
52//! };
53//!
54//! println!("got {} items", out.list.len());
55//! ```
56
57use core::{fmt, marker::PhantomData};
58
59use alloc::{string::String, vec::Vec};
60
61use log::trace;
62use secrecy::SecretString;
63use serde::{Deserialize, Serialize, de::DeserializeOwned};
64use thiserror::Error;
65use url::Url;
66
67use crate::{
68    coroutine::*,
69    jmap_try,
70    rfc8620::{JmapBatch, JmapMethodError, send::*},
71};
72
73/// Failure causes during a JMAP `Foo/get` flow.
74#[derive(Debug, Error)]
75pub enum JmapGetError {
76    #[error("JMAP Foo/get failed: missing response in method_responses")]
77    MissingResponse,
78    #[error("JMAP Foo/get failed: {0}")]
79    Send(#[from] JmapSendError),
80    #[error("JMAP Foo/get failed: serialize args: {0}")]
81    SerializeArgs(#[source] serde_json::Error),
82    #[error("JMAP Foo/get failed: parse response: {0}")]
83    ParseResponse(#[source] serde_json::Error),
84    #[error("JMAP Foo/get failed: {0}")]
85    Method(#[from] JmapMethodError),
86}
87
88/// Options for [`JmapGet::new`].
89#[derive(Clone, Debug, Default)]
90pub struct JmapGetOptions {
91    /// Restrict the fetch to these ids; `None` fetches all.
92    pub ids: Option<Vec<String>>,
93    /// Restrict the returned properties; `None` returns all.
94    pub properties: Option<Vec<String>>,
95}
96
97/// Successful terminal output of the [`JmapGet`] coroutine.
98#[derive(Clone, Debug)]
99pub struct JmapGetOutput<T> {
100    pub list: Vec<T>,
101    pub not_found: Vec<String>,
102    pub state: String,
103    pub keep_alive: bool,
104}
105
106/// Generic I/O-free coroutine for the JMAP `Foo/get` method (RFC 8620 §5.1).
107pub struct JmapGet<T> {
108    state: State,
109    _phantom: PhantomData<T>,
110}
111
112impl<T: DeserializeOwned> JmapGet<T> {
113    /// Builds a single-call `Foo/get` batch and wraps it in [`JmapSend`].
114    pub fn new(
115        account_id: String,
116        http_auth: &SecretString,
117        api_url: &Url,
118        method: impl Into<String>,
119        capabilities: Vec<String>,
120        opts: JmapGetOptions,
121    ) -> Result<Self, JmapGetError> {
122        let args = serde_json::to_value(GetArgs {
123            account_id: &account_id,
124            ids: opts.ids.as_deref(),
125            properties: opts.properties.as_deref(),
126        })
127        .map_err(JmapGetError::SerializeArgs)?;
128
129        let mut batch = JmapBatch::new();
130        batch.add(method, args);
131
132        let request = batch.into_request(capabilities);
133
134        Ok(Self {
135            state: State::Send(JmapSend::new(http_auth, api_url, request)?),
136            _phantom: PhantomData,
137        })
138    }
139
140    /// Wraps a pre-built [`JmapSend`] (advanced: lets callers compose
141    /// custom batches and still benefit from the typed response decode).
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 JmapGet<T> {
151    type Yield = JmapYield;
152    type Return = Result<JmapGetOutput<T>, JmapGetError>;
153
154    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
155        trace!("get: {}", self.state);
156
157        match &mut self.state {
158            State::Send(send) => {
159                let JmapSendOutput {
160                    response,
161                    keep_alive,
162                } = jmap_try!(send, arg);
163
164                let Some((name, args, _)) = response.method_responses.into_iter().next() else {
165                    return JmapCoroutineState::Complete(Err(JmapGetError::MissingResponse));
166                };
167
168                if name == "error" {
169                    let err = serde_json::from_value::<JmapMethodError>(args)
170                        .unwrap_or(JmapMethodError::Unknown);
171                    return JmapCoroutineState::Complete(Err(err.into()));
172                }
173
174                match serde_json::from_value::<GetResponse<T>>(args) {
175                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapGetOutput {
176                        list: r.list,
177                        not_found: r.not_found,
178                        state: r.state,
179                        keep_alive,
180                    })),
181                    Err(err) => JmapCoroutineState::Complete(Err(JmapGetError::ParseResponse(err))),
182                }
183            }
184        }
185    }
186}
187
188enum State {
189    Send(JmapSend),
190}
191
192impl fmt::Display for State {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            Self::Send(_) => f.write_str("send"),
196        }
197    }
198}
199
200#[derive(Serialize)]
201#[serde(rename_all = "camelCase")]
202struct GetArgs<'a> {
203    account_id: &'a str,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    ids: Option<&'a [String]>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    properties: Option<&'a [String]>,
208}
209
210#[derive(Deserialize)]
211#[serde(rename_all = "camelCase")]
212struct GetResponse<T> {
213    list: Vec<T>,
214    #[serde(default)]
215    not_found: Vec<String>,
216    state: String,
217}
218
219#[cfg(test)]
220mod tests {
221    use alloc::{format, string::ToString, vec};
222
223    use super::*;
224
225    #[derive(Debug, Deserialize, PartialEq)]
226    struct Probe {
227        id: String,
228    }
229
230    fn make_auth() -> SecretString {
231        SecretString::from("Bearer test")
232    }
233
234    fn make_url() -> Url {
235        "https://api.example.com/jmap/".parse().unwrap()
236    }
237
238    fn make_get() -> JmapGet<Probe> {
239        JmapGet::<Probe>::new(
240            "a1".to_string(),
241            &make_auth(),
242            &make_url(),
243            "Mailbox/get",
244            vec!["urn:ietf:params:jmap:mail".to_string()],
245            JmapGetOptions::default(),
246        )
247        .unwrap()
248    }
249
250    fn build_http_reply(status: u16, body: &[u8]) -> Vec<u8> {
251        let head = format!(
252            "HTTP/1.1 {} OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
253            status,
254            body.len()
255        );
256        let mut bytes = head.into_bytes();
257        bytes.extend_from_slice(body);
258        bytes
259    }
260
261    #[test]
262    fn success_returns_ok() {
263        let mut cor = make_get();
264        expect_wants_write(&mut cor, None);
265        expect_wants_read(&mut cor);
266
267        let body = br#"{
268            "methodResponses": [["Mailbox/get", {"list":[{"id":"m1"}],"notFound":[],"state":"s1"}, "c0"]],
269            "sessionState": "s1"
270        }"#;
271        let reply = build_http_reply(200, body);
272        let out = expect_complete_ok(&mut cor, &reply);
273        assert_eq!(out.list, vec![Probe { id: "m1".into() }]);
274        assert_eq!(out.state, "s1");
275    }
276
277    #[test]
278    fn method_error_returns_method_error() {
279        let mut cor = make_get();
280        expect_wants_write(&mut cor, None);
281        expect_wants_read(&mut cor);
282
283        let body = br#"{
284            "methodResponses": [["error", {"type":"accountNotFound"}, "c0"]],
285            "sessionState": "s1"
286        }"#;
287        let reply = build_http_reply(200, body);
288        let err = expect_complete_err(&mut cor, &reply);
289        assert!(matches!(err, JmapGetError::Method(_)));
290    }
291
292    #[test]
293    fn missing_response_returns_missing_response() {
294        let mut cor = make_get();
295        expect_wants_write(&mut cor, None);
296        expect_wants_read(&mut cor);
297
298        let body = br#"{"methodResponses": [], "sessionState": "s1"}"#;
299        let reply = build_http_reply(200, body);
300        let err = expect_complete_err(&mut cor, &reply);
301        assert!(matches!(err, JmapGetError::MissingResponse));
302    }
303
304    #[test]
305    fn parse_error_returns_parse_response() {
306        let mut cor = make_get();
307        expect_wants_write(&mut cor, None);
308        expect_wants_read(&mut cor);
309
310        let body = br#"{
311            "methodResponses": [["Mailbox/get", {"list":"nope"}, "c0"]],
312            "sessionState": "s1"
313        }"#;
314        let reply = build_http_reply(200, body);
315        let err = expect_complete_err(&mut cor, &reply);
316        assert!(matches!(err, JmapGetError::ParseResponse(_)));
317    }
318
319    #[test]
320    fn http_error_surfaces_as_send_error() {
321        let mut cor = make_get();
322        expect_wants_write(&mut cor, None);
323        expect_wants_read(&mut cor);
324
325        let reply = b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
326        let err = expect_complete_err(&mut cor, reply);
327        assert!(matches!(
328            err,
329            JmapGetError::Send(JmapSendError::HttpStatus(401))
330        ));
331    }
332
333    // --- utils
334
335    fn expect_wants_write(cor: &mut JmapGet<Probe>, arg: Option<&[u8]>) -> Vec<u8> {
336        match cor.resume(arg) {
337            JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => bytes,
338            state => panic!("expected WantsWrite, got {state:?}"),
339        }
340    }
341
342    fn expect_wants_read(cor: &mut JmapGet<Probe>) {
343        match cor.resume(None) {
344            JmapCoroutineState::Yielded(JmapYield::WantsRead) => {}
345            state => panic!("expected WantsRead, got {state:?}"),
346        }
347    }
348
349    fn expect_complete_ok(cor: &mut JmapGet<Probe>, reply: &[u8]) -> JmapGetOutput<Probe> {
350        match cor.resume(Some(reply)) {
351            JmapCoroutineState::Complete(Ok(out)) => out,
352            state => panic!("expected Complete(Ok), got {state:?}"),
353        }
354    }
355
356    fn expect_complete_err(cor: &mut JmapGet<Probe>, reply: &[u8]) -> JmapGetError {
357        match cor.resume(Some(reply)) {
358            JmapCoroutineState::Complete(Err(err)) => err,
359            state => panic!("expected Complete(Err), got {state:?}"),
360        }
361    }
362}