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// TaskList/set extra parameters
27// ---------------------------------------------------------------------------
28
29/// Extra method-level arguments for `TaskList/set`
30/// (draft-ietf-jmap-tasks-06 §3.7).
31///
32/// All fields are optional. Pass `None` (or `Default::default()`) when not
33/// needed. Mirrors the canonical
34/// [`MailboxSetParams`](https://docs.rs/jmap-mail-client/latest/jmap_mail_client/struct.MailboxSetParams.html)
35/// shape in `jmap-mail-client` (workspace canonical extension-client
36/// template).
37#[derive(Debug, Default, Clone, serde::Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct TaskListSetParams {
40 /// If `true`, destroying a TaskList also destroys all its Tasks
41 /// (draft-ietf-jmap-tasks-06 §3.7). Server default: false (the
42 /// server MUST reject a destroy on a TaskList with tasks,
43 /// returning the `taskListHasTasks` SetError).
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub on_destroy_remove_tasks: Option<bool>,
46
47 /// Catch-all for vendor / site / private extension fields not covered
48 /// by the typed fields above. Preserves unknown fields across
49 /// deserialize/serialize round-trip per workspace extras-preservation
50 /// policy (see workspace AGENTS.md).
51 ///
52 /// **Constraint**: keys in `extra` MUST NOT collide with the
53 /// typed-field wire names above (the camelCase spelling — e.g.
54 /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
55 /// `"fromAccountId"`, etc.). On collision the typed-field value
56 /// wins on the wire and the `extra` value is silently dropped at
57 /// serialization. Place vendor extensions under vendor-prefixed
58 /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
59 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
60 pub extra: serde_json::Map<String, serde_json::Value>,
61}
62
63// ---------------------------------------------------------------------------
64// Constants
65// ---------------------------------------------------------------------------
66
67/// The call-id embedded in every single-method JMAP request produced by
68/// [`build_request`]. Pass directly to `jmap_base_client::extract_response`.
69pub(crate) const CALL_ID: &str = "r1";
70
71/// Capability URIs for JMAP Tasks method calls.
72pub(crate) const USING_TASKS: &[&str] = &[
73 "urn:ietf:params:jmap:core",
74 jmap_tasks_types::JMAP_TASKS_URI,
75];
76
77// ---------------------------------------------------------------------------
78// build_request helper
79// ---------------------------------------------------------------------------
80
81/// Build a single-method JMAP request.
82///
83/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
84/// Use the pre-defined constant [`USING_TASKS`] for standard calls.
85///
86/// The embedded call-id is [`CALL_ID`]; pass it directly to
87/// `jmap_base_client::extract_response`.
88pub(crate) fn build_request(
89 method: &str,
90 args: serde_json::Value,
91 using: &[&str],
92) -> jmap_types::JmapRequest {
93 let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
94 let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
95 jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
96}
97
98// ---------------------------------------------------------------------------
99// SessionClient — session-bound client
100// ---------------------------------------------------------------------------
101
102/// A `JmapClient` bound to a JMAP session.
103///
104/// Obtain via [`JmapTasksExt::with_tasks_session`](crate::JmapTasksExt::with_tasks_session).
105/// All JMAP Tasks methods are available on this type without needing to pass
106/// `&Session` on every call.
107///
108/// # Session lifecycle
109///
110/// `SessionClient` captures the `Session` at construction time. After
111/// re-fetching the session via `JmapClient::fetch_session`, construct a new
112/// `SessionClient` with the updated session. Reusing a stale `SessionClient`
113/// after session expiry will result in `unknownAccount` or similar errors
114/// from the server.
115///
116/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
117/// already implements `Clone` and `with_tasks_session` clones one
118/// internally), enabling parallel-task fan-out with one bound session.
119///
120/// `Debug` is implemented manually to redact the inner `JmapClient` (which
121/// holds an HTTP client and is intentionally not `Debug` in
122/// `jmap-base-client`); only the `Session` is shown. This lets callers
123/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
124/// impls of their own.
125///
126/// # Thread safety
127///
128/// `SessionClient` is `Send + Sync`. Both
129/// [`jmap_base_client::JmapClient`] (backed by `reqwest::Client`) and
130/// [`jmap_base_client::Session`] (plain serde-derived data) are
131/// `Send + Sync` per jmap-base-client's contract, so this type can be
132/// shared across async tasks via `Arc<SessionClient>` or cloned for
133/// per-task ownership.
134///
135/// A `Send + Sync` regression in a future jmap-base-client release
136/// would be a major-version-breaking change for this crate. A
137/// compile-time assertion in `methods/mod.rs` guards against the
138/// regression landing silently — see
139/// `_assert_session_client_send_sync`.
140#[non_exhaustive]
141#[derive(Clone)]
142pub struct SessionClient {
143 pub(crate) client: jmap_base_client::JmapClient,
144 pub(crate) session: jmap_base_client::Session,
145}
146
147impl std::fmt::Debug for SessionClient {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("SessionClient")
150 // The inner JmapClient is not Debug — show a placeholder so
151 // callers know it is present without leaking HTTP-client
152 // internals.
153 .field("client", &"<JmapClient>")
154 .field("session", &self.session)
155 .finish()
156 }
157}
158
159impl SessionClient {
160 /// Borrow the underlying [`JmapClient`](jmap_base_client::JmapClient).
161 ///
162 /// Useful for ad-hoc operations outside the typed JMAP method surface —
163 /// for example, calling `JmapClient::upload` / `JmapClient::download_blob`,
164 /// or constructing a `JmapClient::event_source` subscription using the
165 /// bound session's `event_source_url`.
166 pub fn client(&self) -> &jmap_base_client::JmapClient {
167 &self.client
168 }
169
170 /// Borrow the captured [`Session`](jmap_base_client::Session).
171 ///
172 /// `SessionClient` captures the `Session` at construction time. After
173 /// re-fetching the session via `JmapClient::fetch_session`, callers
174 /// should construct a new `SessionClient`. This accessor lets a caller
175 /// compare the captured session's `state` field against a freshly
176 /// fetched session to detect staleness, or inspect
177 /// `accountCapabilities` / `primary_accounts` for capability-specific
178 /// metadata not exposed via the typed JMAP method surface.
179 pub fn session(&self) -> &jmap_base_client::Session {
180 &self.session
181 }
182
183 /// Return the primary account id for `urn:ietf:params:jmap:tasks`,
184 /// or `Err(InvalidSession)` if the session has no primary account for
185 /// that capability.
186 pub fn tasks_account_id(&self) -> Result<&str, jmap_base_client::ClientError> {
187 self.session
188 .primary_account_id(jmap_tasks_types::JMAP_TASKS_URI)
189 .ok_or_else(|| {
190 jmap_base_client::ClientError::InvalidSession(
191 "no primary account for urn:ietf:params:jmap:tasks".into(),
192 )
193 })
194 }
195
196 /// Extract `(api_url, tasks_account_id)` from the bound session.
197 ///
198 /// Returns `Err(InvalidSession)` if there is no primary account for
199 /// `urn:ietf:params:jmap:tasks`.
200 pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
201 let api_url = self.session.api_url.as_str();
202 let account_id = self
203 .session
204 .primary_account_id(jmap_tasks_types::JMAP_TASKS_URI)
205 .ok_or_else(|| {
206 jmap_base_client::ClientError::InvalidSession(
207 "no primary account for urn:ietf:params:jmap:tasks".into(),
208 )
209 })?;
210 Ok((api_url, account_id))
211 }
212
213 /// Forward a JMAP request to the underlying HTTP client.
214 pub(crate) async fn call_internal(
215 &self,
216 api_url: &str,
217 req: &jmap_types::JmapRequest,
218 ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
219 self.client.call(api_url, req).await
220 }
221}
222
223/// Compile-time assertion that [`SessionClient`] is `Send + Sync`.
224///
225/// The `# Thread safety` section of [`SessionClient`]'s rustdoc promises
226/// auto-trait inheritance from
227/// [`jmap_base_client::JmapClient`] and
228/// [`jmap_base_client::Session`]. If a future jmap-base-client release
229/// adds a `!Sync` interior-mutability field to either, this assertion
230/// fails at compile time — flagging the regression at the dependency
231/// upgrade rather than at the downstream caller's "cannot send between
232/// threads safely" error.
233#[allow(dead_code)]
234fn _assert_session_client_send_sync() {
235 fn assert_send_sync<T: Send + Sync>() {}
236 assert_send_sync::<SessionClient>();
237}
238
239// ---------------------------------------------------------------------------
240// Tests
241// ---------------------------------------------------------------------------
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use serde_json::json;
247
248 /// Oracle: build_request produces the correct method name.
249 /// Expected: invocation[0] == method name, invocation[2] == CALL_ID.
250 #[test]
251 fn build_request_method_name_and_call_id() {
252 let req = build_request(
253 "TaskList/get",
254 json!({"accountId": "acc1", "ids": null}),
255 USING_TASKS,
256 );
257 let v = serde_json::to_value(&req).expect("serialize JmapRequest");
258
259 let calls = v["methodCalls"]
260 .as_array()
261 .expect("methodCalls must be array");
262 assert_eq!(calls.len(), 1, "must have exactly 1 method call");
263 assert_eq!(calls[0][0], json!("TaskList/get"), "method name must match");
264 assert_eq!(calls[0][2], json!("r1"), "call_id must be CALL_ID constant");
265 }
266
267 /// Oracle: USING_TASKS contains exactly the two JMAP Tasks capability URIs.
268 #[test]
269 fn using_tasks_contains_correct_uris() {
270 let req = build_request("TaskList/get", json!({}), USING_TASKS);
271 let v = serde_json::to_value(&req).expect("serialize");
272 let using = v["using"].as_array().expect("using must be array");
273 assert_eq!(using.len(), 2);
274 assert!(
275 using.contains(&json!("urn:ietf:params:jmap:core")),
276 "must include jmap:core"
277 );
278 assert!(
279 using.contains(&json!("urn:ietf:params:jmap:tasks")),
280 "must include jmap:tasks"
281 );
282 }
283
284 /// Oracle: CALL_ID constant is "r1".
285 #[test]
286 fn call_id_is_r1() {
287 assert_eq!(CALL_ID, "r1");
288 }
289
290 /// Oracle: GetResponse<T> deserializes from RFC 8620 §5.1 shape.
291 #[test]
292 fn get_response_deserializes() {
293 let json = json!({
294 "accountId": "acc1",
295 "state": "s42",
296 "list": [],
297 "notFound": ["missing1"]
298 });
299 let resp: GetResponse<serde_json::Value> =
300 serde_json::from_value(json).expect("GetResponse must deserialize");
301 assert_eq!(resp.account_id, "acc1");
302 assert_eq!(resp.state, "s42");
303 assert!(resp.list.is_empty());
304 assert_eq!(
305 resp.not_found.as_deref(),
306 Some(["missing1".into()].as_slice())
307 );
308 }
309
310 /// Oracle: SetResponse deserializes from RFC 8620 §5.3 shape.
311 #[test]
312 fn set_response_deserializes() {
313 let json = json!({
314 "accountId": "acc1",
315 "oldState": "s10",
316 "newState": "s11",
317 "created": null,
318 "updated": null,
319 "destroyed": ["id1"],
320 "notCreated": null,
321 "notUpdated": null,
322 "notDestroyed": null
323 });
324 let resp: SetResponse = serde_json::from_value(json).expect("SetResponse must deserialize");
325 assert_eq!(resp.new_state, "s11");
326 assert_eq!(resp.destroyed.as_deref(), Some(["id1".into()].as_slice()));
327 }
328
329 /// Oracle: TaskListSetParams with on_destroy_remove_tasks serializes
330 /// the field at the expected camelCase wire name.
331 /// Expected field name "onDestroyRemoveTasks" from
332 /// draft-ietf-jmap-tasks-06 §3.7.
333 #[test]
334 fn task_list_set_params_on_destroy_remove_tasks_serializes() {
335 let params = TaskListSetParams {
336 on_destroy_remove_tasks: Some(true),
337 extra: serde_json::Map::new(),
338 };
339 let out = serde_json::to_value(¶ms).expect("serialize TaskListSetParams");
340 assert_eq!(out["onDestroyRemoveTasks"], json!(true));
341 }
342
343 /// Oracle: TaskListSetParams default (all-None) serializes to an empty
344 /// object — every typed field is `skip_serializing_if = "Option::is_none"`
345 /// and `extra` is `skip_serializing_if = "Map::is_empty"`.
346 #[test]
347 fn task_list_set_params_default_serializes_empty() {
348 let params = TaskListSetParams::default();
349 let out = serde_json::to_value(¶ms).expect("serialize TaskListSetParams::default");
350 let obj = out.as_object().expect("must be Object");
351 assert!(
352 obj.is_empty(),
353 "all-None default must serialize to empty object, got: {out}"
354 );
355 }
356
357 /// `TaskListSetParams.extra` flattens into serialized JSON.
358 #[test]
359 fn task_list_set_params_propagates_vendor_extras() {
360 let mut params = TaskListSetParams::default();
361 params
362 .extra
363 .insert("acmeCorpCascade".into(), json!("strict"));
364 let v = serde_json::to_value(¶ms).expect("serialize TaskListSetParams");
365 assert_eq!(v["acmeCorpCascade"], json!("strict"));
366 }
367}