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