Skip to main content

io_jmap/calendars/calendar_event/
query.rs

1//! Batched JMAP `CalendarEvent/query` + `CalendarEvent/get` coroutine
2//! (draft-ietf-jmap-calendars-27): single HTTP request, server-side
3//! `#ids` back-reference resolves the get against the query results.
4//!
5//! Asking the server to expand recurrences is what makes this cheaper
6//! than the CalDAV equivalent: one occurrence per id over the queried
7//! window, no local expander.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use std::{
13//!     io::{Read, Write},
14//!     net::TcpStream,
15//! };
16//!
17//! use io_jmap::{
18//!     calendars::calendar_event::query::{
19//!         JmapCalendarEventFilter, JmapCalendarEventQuery, JmapCalendarEventQueryOptions,
20//!     },
21//!     coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
22//!     rfc8620::session::JmapSession,
23//! };
24//! use secrecy::SecretString;
25//!
26//! // Ready stream needed (TCP-connected, TLS-negociated)
27//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
28//! let mut buf = [0u8; 4096];
29//!
30//! let session: JmapSession = serde_json::from_str(r#"{
31//!     "username": "",
32//!     "accounts": {},
33//!     "primaryAccounts": {"urn:ietf:params:jmap:calendars": "a1"},
34//!     "capabilities": {},
35//!     "apiUrl": "https://api.example.com/jmap/",
36//!     "downloadUrl": "",
37//!     "uploadUrl": "",
38//!     "eventSourceUrl": "",
39//!     "state": ""
40//! }"#).unwrap();
41//! let auth = SecretString::from("Bearer xyz");
42//! let opts = JmapCalendarEventQueryOptions {
43//!     filter: Some(JmapCalendarEventFilter {
44//!         after: Some("2026-08-01T00:00:00".into()),
45//!         before: Some("2026-09-01T00:00:00".into()),
46//!         ..Default::default()
47//!     }),
48//!     expand_recurrences: true,
49//!     ..Default::default()
50//! };
51//! let mut coroutine = JmapCalendarEventQuery::new(&session, &auth, opts).unwrap();
52//! let mut arg = None;
53//!
54//! let out = loop {
55//!     match coroutine.resume(arg.take()) {
56//!         JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
57//!             stream.write_all(&bytes).unwrap();
58//!         }
59//!         JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
60//!             let n = stream.read(&mut buf).unwrap();
61//!             arg = Some(&buf[..n]);
62//!         }
63//!         JmapCoroutineState::Complete(Ok(out)) => break out,
64//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
65//!     }
66//! };
67//!
68//! println!("{} events", out.events.len());
69//! ```
70
71use core::fmt;
72
73use alloc::{string::String, vec, vec::Vec};
74
75use secrecy::SecretString;
76use serde::{Deserialize, Serialize, Serializer};
77use thiserror::Error;
78
79use crate::{
80    calendars::{JMAP_CALENDARS_CAPABILITY, calendar_event::JmapCalendarEvent},
81    coroutine::*,
82    jmap_try,
83    rfc8620::{
84        JMAP_CORE_CAPABILITY,
85        error::JmapMethodError,
86        request::{JmapBatch, JmapResultReference},
87        send::*,
88        session::JmapSession,
89    },
90};
91
92/// Filter for `CalendarEvent/query` (draft-ietf-jmap-calendars); all
93/// specified conditions must apply.
94#[derive(Clone, Debug, Default, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct JmapCalendarEventFilter {
97    /// Calendar id the event must be in.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub in_calendar: Option<String>,
100    /// The event must end after this local date-time.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub after: Option<String>,
103    /// The event must start before this local date-time.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub before: Option<String>,
106    /// Free-text match against any text in the event.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub text: Option<String>,
109    /// Match against the event title.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub title: Option<String>,
112    /// Match against the event description.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub description: Option<String>,
115    /// Match against any location name of the event.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub location: Option<String>,
118    /// Match against the name or address of a participant with the
119    /// owner role.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub owner: Option<String>,
122    /// Match against the name or address of any participant with the
123    /// attendee role.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub attendee: Option<String>,
126    /// Exact JSCalendar `uid` of the event.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub uid: Option<String>,
129}
130
131/// Sort property for `CalendarEvent/query` (draft-ietf-jmap-calendars).
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub enum JmapCalendarEventSortProperty {
134    /// The event's start, in the query's time zone.
135    Start,
136    /// The JSCalendar `uid` of the event.
137    Uid,
138    /// The recurrence id of the occurrence, which orders the instances
139    /// of a single series.
140    RecurrenceId,
141    /// The `created` date on the event.
142    Created,
143    /// The `updated` date on the event.
144    Updated,
145}
146
147impl fmt::Display for JmapCalendarEventSortProperty {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.write_str(match self {
150            Self::Start => "start",
151            Self::Uid => "uid",
152            Self::RecurrenceId => "recurrenceId",
153            Self::Created => "created",
154            Self::Updated => "updated",
155        })
156    }
157}
158
159impl Serialize for JmapCalendarEventSortProperty {
160    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
161        s.collect_str(self)
162    }
163}
164
165/// Sort comparator for `CalendarEvent/query` (RFC 8620 ยง5.5).
166#[derive(Clone, Debug, Serialize)]
167#[serde(rename_all = "camelCase")]
168pub struct JmapCalendarEventSortComparator {
169    /// The property to sort by.
170    pub property: JmapCalendarEventSortProperty,
171    /// Ascending if `None` or `Some(true)`.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub is_ascending: Option<bool>,
174}
175
176/// Failure causes during a batched JMAP `CalendarEvent/query` +
177/// `CalendarEvent/get` flow.
178#[derive(Debug, Error)]
179pub enum JmapCalendarEventQueryError {
180    /// The response carried no query response.
181    #[error(
182        "JMAP CalendarEvent/query failed: missing CalendarEvent/query response in method_responses"
183    )]
184    MissingQueryResponse,
185    /// The response carried no get response.
186    #[error(
187        "JMAP CalendarEvent/query failed: missing CalendarEvent/get response in method_responses"
188    )]
189    MissingGetResponse,
190    /// The inner send coroutine failed.
191    #[error("JMAP CalendarEvent/query failed: {0}")]
192    Send(#[from] JmapSendError),
193    /// The method arguments could not be serialized.
194    #[error("JMAP CalendarEvent/query failed: serialize args: {0}")]
195    SerializeArgs(#[source] serde_json::Error),
196    /// The query response could not be parsed.
197    #[error("JMAP CalendarEvent/query failed: parse CalendarEvent/query response: {0}")]
198    ParseQueryResponse(#[source] serde_json::Error),
199    /// The get response could not be parsed.
200    #[error("JMAP CalendarEvent/query failed: parse CalendarEvent/get response: {0}")]
201    ParseGetResponse(#[source] serde_json::Error),
202    /// The server returned a method-level error for the query call.
203    #[error("JMAP CalendarEvent/query failed: CalendarEvent/query: {0}")]
204    QueryMethod(JmapMethodError),
205    /// The server returned a method-level error for the get call.
206    #[error("JMAP CalendarEvent/query failed: CalendarEvent/get: {0}")]
207    GetMethod(JmapMethodError),
208}
209
210/// Options for [`JmapCalendarEventQuery::new`].
211#[derive(Clone, Debug, Default)]
212pub struct JmapCalendarEventQueryOptions {
213    /// Filter criteria; `None` matches all events.
214    pub filter: Option<JmapCalendarEventFilter>,
215    /// Sort order; `None` uses the server default.
216    pub sort: Option<Vec<JmapCalendarEventSortComparator>>,
217    /// Zero-based offset into the result list.
218    pub position: Option<u64>,
219    /// Max number of events to return.
220    pub limit: Option<u64>,
221    /// Return one id per occurrence rather than one per series, which
222    /// requires the filter to bound the window with `after` and
223    /// `before`.
224    pub expand_recurrences: bool,
225    /// IANA time zone id the filter's and the returned floating times
226    /// are resolved against; `None` leaves the server default
227    /// (`Etc/UTC`).
228    pub time_zone: Option<String>,
229    /// Event properties to fetch (JSCalendar property names plus the
230    /// JMAP ones); `None` returns all.
231    pub properties: Option<Vec<String>>,
232    /// Return only the participants the user needs to answer an
233    /// invitation, i.e. themselves and the owners.
234    pub reduce_participants: bool,
235}
236
237/// Successful terminal output of [`JmapCalendarEventQuery`].
238#[derive(Clone, Debug)]
239pub struct JmapCalendarEventQueryOutput {
240    /// The fetched calendar events.
241    pub events: Vec<JmapCalendarEvent>,
242    /// The total number of matching objects, when the server computed
243    /// it.
244    pub total: Option<u64>,
245    /// Zero-based index of the first returned id.
246    pub position: u64,
247    /// The state the query results were computed at.
248    pub query_state: String,
249    /// Whether the server indicated the connection can be reused.
250    pub keep_alive: bool,
251}
252
253/// I/O-free coroutine for the combined `CalendarEvent/query` +
254/// `CalendarEvent/get` operation.
255pub struct JmapCalendarEventQuery {
256    state: State,
257}
258
259impl JmapCalendarEventQuery {
260    /// Prepares the method call request and builds the coroutine.
261    pub fn new(
262        session: &JmapSession,
263        http_auth: &SecretString,
264        opts: JmapCalendarEventQueryOptions,
265    ) -> Result<Self, JmapCalendarEventQueryError> {
266        let account_id = session
267            .primary_accounts
268            .get(JMAP_CALENDARS_CAPABILITY)
269            .cloned()
270            .unwrap_or_default();
271        let api_url = &session.api_url;
272
273        let query_args = CalendarEventQueryArgs {
274            account_id: &account_id,
275            filter: opts.filter.as_ref(),
276            sort: opts.sort.as_deref(),
277            position: opts.position,
278            limit: opts.limit,
279            calculate_total: true,
280            expand_recurrences: opts.expand_recurrences,
281            time_zone: opts.time_zone.as_deref(),
282        };
283
284        let mut batch = JmapBatch::new();
285        let query_id = batch.add(
286            "CalendarEvent/query",
287            serde_json::to_value(&query_args)
288                .map_err(JmapCalendarEventQueryError::SerializeArgs)?,
289        );
290
291        let get_args = CalendarEventGetByRefArgs {
292            account_id: &account_id,
293            ids_ref: JmapResultReference {
294                result_of: &query_id,
295                name: "CalendarEvent/query",
296                path: "/ids",
297            },
298            properties: opts.properties.as_deref(),
299            reduce_participants: opts.reduce_participants,
300            time_zone: opts.time_zone.as_deref(),
301        };
302
303        batch.add(
304            "CalendarEvent/get",
305            serde_json::to_value(&get_args).map_err(JmapCalendarEventQueryError::SerializeArgs)?,
306        );
307
308        let request = batch.into_request(vec![
309            JMAP_CORE_CAPABILITY.into(),
310            JMAP_CALENDARS_CAPABILITY.into(),
311        ]);
312
313        Ok(Self {
314            state: State::Send(JmapSend::new(http_auth, api_url, request)?),
315        })
316    }
317}
318
319impl JmapCoroutine for JmapCalendarEventQuery {
320    type Yield = JmapYield;
321    type Return = Result<JmapCalendarEventQueryOutput, JmapCalendarEventQueryError>;
322
323    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
324        match &mut self.state {
325            State::Send(send) => {
326                let JmapSendOutput {
327                    response,
328                    keep_alive,
329                } = jmap_try!(send, arg);
330
331                let mut responses = response.method_responses.into_iter();
332
333                let Some((query_name, query_args, _)) = responses.next() else {
334                    return JmapCoroutineState::Complete(Err(
335                        JmapCalendarEventQueryError::MissingQueryResponse,
336                    ));
337                };
338
339                if query_name == "error" {
340                    let err = serde_json::from_value::<JmapMethodError>(query_args)
341                        .unwrap_or(JmapMethodError::Unknown);
342                    return JmapCoroutineState::Complete(Err(
343                        JmapCalendarEventQueryError::QueryMethod(err),
344                    ));
345                }
346
347                let query_response =
348                    match serde_json::from_value::<CalendarEventQueryResponse>(query_args) {
349                        Ok(r) => r,
350                        Err(err) => {
351                            return JmapCoroutineState::Complete(Err(
352                                JmapCalendarEventQueryError::ParseQueryResponse(err),
353                            ));
354                        }
355                    };
356
357                let Some((get_name, get_args, _)) = responses.next() else {
358                    return JmapCoroutineState::Complete(Err(
359                        JmapCalendarEventQueryError::MissingGetResponse,
360                    ));
361                };
362
363                if get_name == "error" {
364                    let err = serde_json::from_value::<JmapMethodError>(get_args)
365                        .unwrap_or(JmapMethodError::Unknown);
366                    return JmapCoroutineState::Complete(Err(
367                        JmapCalendarEventQueryError::GetMethod(err),
368                    ));
369                }
370
371                match serde_json::from_value::<CalendarEventGetResponse>(get_args) {
372                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapCalendarEventQueryOutput {
373                        events: r.list,
374                        total: query_response.total,
375                        position: query_response.position,
376                        query_state: query_response.query_state,
377                        keep_alive,
378                    })),
379                    Err(err) => JmapCoroutineState::Complete(Err(
380                        JmapCalendarEventQueryError::ParseGetResponse(err),
381                    )),
382                }
383            }
384        }
385    }
386}
387
388enum State {
389    Send(JmapSend),
390}
391
392#[derive(Serialize)]
393#[serde(rename_all = "camelCase")]
394struct CalendarEventQueryArgs<'a> {
395    account_id: &'a str,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    filter: Option<&'a JmapCalendarEventFilter>,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    sort: Option<&'a [JmapCalendarEventSortComparator]>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    position: Option<u64>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    limit: Option<u64>,
404    calculate_total: bool,
405    #[serde(skip_serializing_if = "is_false")]
406    expand_recurrences: bool,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    time_zone: Option<&'a str>,
409}
410
411#[derive(Serialize)]
412#[serde(rename_all = "camelCase")]
413struct CalendarEventGetByRefArgs<'a> {
414    account_id: &'a str,
415    #[serde(rename = "#ids")]
416    ids_ref: JmapResultReference<'a>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    properties: Option<&'a [String]>,
419    #[serde(skip_serializing_if = "is_false")]
420    reduce_participants: bool,
421    #[serde(skip_serializing_if = "Option::is_none")]
422    time_zone: Option<&'a str>,
423}
424
425#[derive(Deserialize)]
426#[serde(rename_all = "camelCase")]
427struct CalendarEventQueryResponse {
428    query_state: String,
429    #[serde(default)]
430    total: Option<u64>,
431    #[serde(default)]
432    position: u64,
433}
434
435#[derive(Deserialize)]
436#[serde(rename_all = "camelCase")]
437struct CalendarEventGetResponse {
438    list: Vec<JmapCalendarEvent>,
439}
440
441fn is_false(b: &bool) -> bool {
442    !b
443}