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