Skip to main content

jmap_tasks_client/methods/
mod.rs

1//! Typed JMAP Tasks method wrappers — response types, SessionClient,
2//! constants, and helpers.
3//!
4//! Response types mirror RFC 8620 standard shapes (§5.1 /get, §5.5 /query,
5//! §5.2 /changes, §5.3 /set). Method implementations live in sub-modules and
6//! operate on `SessionClient`.
7
8pub mod task;
9pub mod task_list;
10pub mod task_notification;
11
12// ---------------------------------------------------------------------------
13// Response types (RFC 8620 §5)
14// ---------------------------------------------------------------------------
15//
16// Re-exported from `jmap-types::methods` so all `jmap-*-client` crates share
17// one canonical set of /get, /set, /changes, /query, /queryChanges shapes.
18// The wire format is identical to the previous local definitions.
19
20pub use jmap_types::{
21    AddedItem, ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetError,
22    SetResponse,
23};
24
25// ---------------------------------------------------------------------------
26// Constants
27// ---------------------------------------------------------------------------
28
29/// The call-id embedded in every single-method JMAP request produced by
30/// [`build_request`]. Pass directly to `jmap_base_client::extract_response`.
31pub(crate) const CALL_ID: &str = "r1";
32
33/// Capability URIs for JMAP Tasks method calls.
34pub(crate) const USING_TASKS: &[&str] =
35    &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:tasks"];
36
37// ---------------------------------------------------------------------------
38// build_request helper
39// ---------------------------------------------------------------------------
40
41/// Build a single-method JMAP request.
42///
43/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
44/// Use the pre-defined constant [`USING_TASKS`] for standard calls.
45///
46/// The embedded call-id is [`CALL_ID`]; pass it directly to
47/// `jmap_base_client::extract_response`.
48pub(crate) fn build_request(
49    method: &str,
50    args: serde_json::Value,
51    using: &[&str],
52) -> jmap_types::JmapRequest {
53    let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
54    let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
55    jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
56}
57
58// ---------------------------------------------------------------------------
59// SessionClient — session-bound client
60// ---------------------------------------------------------------------------
61
62/// A `JmapClient` bound to a JMAP session.
63///
64/// Obtain via [`JmapTasksExt::with_tasks_session`](crate::JmapTasksExt::with_tasks_session).
65/// All JMAP Tasks methods are available on this type without needing to pass
66/// `&Session` on every call.
67///
68/// # Session lifecycle
69///
70/// `SessionClient` captures the `Session` at construction time. After
71/// re-fetching the session via `JmapClient::fetch_session`, construct a new
72/// `SessionClient` with the updated session. Reusing a stale `SessionClient`
73/// after session expiry will result in `unknownAccount` or similar errors
74/// from the server.
75///
76/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
77/// already implements `Clone` and `with_tasks_session` clones one
78/// internally), enabling parallel-task fan-out with one bound session.
79///
80/// `Debug` is implemented manually to redact the inner `JmapClient` (which
81/// holds an HTTP client and is intentionally not `Debug` in
82/// `jmap-base-client`); only the `Session` is shown. This lets callers
83/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
84/// impls of their own.
85#[non_exhaustive]
86#[derive(Clone)]
87pub struct SessionClient {
88    pub(crate) client: jmap_base_client::JmapClient,
89    pub(crate) session: jmap_base_client::Session,
90}
91
92impl std::fmt::Debug for SessionClient {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("SessionClient")
95            // The inner JmapClient is not Debug — show a placeholder so
96            // callers know it is present without leaking HTTP-client
97            // internals.
98            .field("client", &"<JmapClient>")
99            .field("session", &self.session)
100            .finish()
101    }
102}
103
104impl SessionClient {
105    /// Extract `(api_url, tasks_account_id)` from the bound session.
106    ///
107    /// Returns `Err(InvalidSession)` if there is no primary account for
108    /// `urn:ietf:params:jmap:tasks`.
109    pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
110        let api_url = self.session.api_url.as_str();
111        let account_id = self
112            .session
113            .primary_account_id("urn:ietf:params:jmap:tasks")
114            .ok_or_else(|| {
115                jmap_base_client::ClientError::InvalidSession(
116                    "no primary account for urn:ietf:params:jmap:tasks".into(),
117                )
118            })?;
119        Ok((api_url, account_id))
120    }
121
122    /// Forward a JMAP request to the underlying HTTP client.
123    pub(crate) async fn call_internal(
124        &self,
125        api_url: &str,
126        req: &jmap_types::JmapRequest,
127    ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
128        self.client.call(api_url, req).await
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Tests
134// ---------------------------------------------------------------------------
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use serde_json::json;
140
141    /// Oracle: build_request produces the correct method name.
142    /// Expected: invocation[0] == method name, invocation[2] == CALL_ID.
143    #[test]
144    fn build_request_method_name_and_call_id() {
145        let req = build_request(
146            "TaskList/get",
147            json!({"accountId": "acc1", "ids": null}),
148            USING_TASKS,
149        );
150        let v = serde_json::to_value(&req).expect("serialize JmapRequest");
151
152        let calls = v["methodCalls"]
153            .as_array()
154            .expect("methodCalls must be array");
155        assert_eq!(calls.len(), 1, "must have exactly 1 method call");
156        assert_eq!(calls[0][0], json!("TaskList/get"), "method name must match");
157        assert_eq!(calls[0][2], json!("r1"), "call_id must be CALL_ID constant");
158    }
159
160    /// Oracle: USING_TASKS contains exactly the two JMAP Tasks capability URIs.
161    #[test]
162    fn using_tasks_contains_correct_uris() {
163        let req = build_request("TaskList/get", json!({}), USING_TASKS);
164        let v = serde_json::to_value(&req).expect("serialize");
165        let using = v["using"].as_array().expect("using must be array");
166        assert_eq!(using.len(), 2);
167        assert!(
168            using.contains(&json!("urn:ietf:params:jmap:core")),
169            "must include jmap:core"
170        );
171        assert!(
172            using.contains(&json!("urn:ietf:params:jmap:tasks")),
173            "must include jmap:tasks"
174        );
175    }
176
177    /// Oracle: CALL_ID constant is "r1".
178    #[test]
179    fn call_id_is_r1() {
180        assert_eq!(CALL_ID, "r1");
181    }
182
183    /// Oracle: GetResponse<T> deserializes from RFC 8620 §5.1 shape.
184    #[test]
185    fn get_response_deserializes() {
186        let json = json!({
187            "accountId": "acc1",
188            "state": "s42",
189            "list": [],
190            "notFound": ["missing1"]
191        });
192        let resp: GetResponse<serde_json::Value> =
193            serde_json::from_value(json).expect("GetResponse must deserialize");
194        assert_eq!(resp.account_id, "acc1");
195        assert_eq!(resp.state, "s42");
196        assert!(resp.list.is_empty());
197        assert_eq!(
198            resp.not_found.as_deref(),
199            Some(["missing1".into()].as_slice())
200        );
201    }
202
203    /// Oracle: SetResponse deserializes from RFC 8620 §5.3 shape.
204    #[test]
205    fn set_response_deserializes() {
206        let json = json!({
207            "accountId": "acc1",
208            "oldState": "s10",
209            "newState": "s11",
210            "created": null,
211            "updated": null,
212            "destroyed": ["id1"],
213            "notCreated": null,
214            "notUpdated": null,
215            "notDestroyed": null
216        });
217        let resp: SetResponse = serde_json::from_value(json).expect("SetResponse must deserialize");
218        assert_eq!(resp.new_state, "s11");
219        assert_eq!(resp.destroyed.as_deref(), Some(["id1".into()].as_slice()));
220    }
221}