Skip to main content

io_jmap/rfc8620/
query.rs

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