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