Skip to main content

io_jmap/rfc8620/
query_changes.rs

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