jmap_tasks_client/methods/task.rs
1//! JMAP Tasks — Task/* method implementations on SessionClient.
2
3use std::collections::HashMap;
4
5use jmap_types::{Id, PatchObject, State};
6
7use super::{ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetResponse};
8
9impl super::SessionClient {
10 /// Fetch Task objects by IDs (draft-tasks-06 §4.5).
11 ///
12 /// If `ids` is `None`, the server returns all Tasks for the account,
13 /// SUBJECT TO the server's `maxObjectsInGet` cap (RFC 8620 §5.1).
14 /// For production use, scope the result set via the corresponding
15 /// /query method first and pass explicit ids here to avoid
16 /// `requestTooLarge` errors when the account holds more objects
17 /// than the cap.
18 /// Pass `properties: None` to return all fields.
19 ///
20 /// # Errors
21 ///
22 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
23 /// if the bound session has no primary account for
24 /// `urn:ietf:params:jmap:tasks`.
25 /// - Any transport / protocol variant returned by
26 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
27 /// [`Http`](jmap_base_client::ClientError::Http),
28 /// [`Parse`](jmap_base_client::ClientError::Parse),
29 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
30 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
31 /// (wraps RFC 8620 §3.6.2 method-level errors such as
32 /// `accountNotFound`, `invalidArguments`, `serverFail`),
33 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
34 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
35 /// or
36 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
37 pub async fn task_get(
38 &self,
39 ids: Option<&[Id]>,
40 properties: Option<&[&str]>,
41 ) -> Result<GetResponse<jmap_tasks_types::Task>, jmap_base_client::ClientError> {
42 let (api_url, account_id) = self.session_parts()?;
43 // Omit `ids` / `properties` when None — see the matching comment on
44 // `task_list_get` for the rationale (consistent with set/changes/query).
45 let mut args = serde_json::json!({ "accountId": account_id });
46 if let Some(id_slice) = ids {
47 args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
48 }
49 if let Some(props) = properties {
50 args["properties"] =
51 serde_json::to_value(props).expect("&[&str] Serialize is infallible");
52 }
53 let req = super::build_request("Task/get", args, super::USING_TASKS);
54 let resp = self.call_internal(api_url, &req).await?;
55 jmap_base_client::extract_response(&resp, super::CALL_ID)
56 }
57
58 /// Fetch changes to Task objects since `since_state` (draft-tasks-06 §4.6).
59 ///
60 /// # Errors
61 ///
62 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
63 /// if `since_state` is the empty string (defence-in-depth —
64 /// `State` constructed via [`State::from`](jmap_types::State::from)
65 /// accepts empty strings, but an empty `sinceState` is never
66 /// useful and would otherwise generate a wasted round-trip).
67 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
68 /// if the bound session has no primary account for
69 /// `urn:ietf:params:jmap:tasks`.
70 /// - Any transport / protocol variant returned by
71 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
72 /// the matching error list on [`Self::task_get`].
73 pub async fn task_changes(
74 &self,
75 since_state: &State,
76 max_changes: Option<u64>,
77 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
78 // Defence-in-depth: see task_list_changes.
79 if since_state.as_ref().is_empty() {
80 return Err(jmap_base_client::ClientError::InvalidArgument(
81 "task_changes: since_state may not be empty".into(),
82 ));
83 }
84 let (api_url, account_id) = self.session_parts()?;
85 let mut args = serde_json::json!({
86 "accountId": account_id,
87 "sinceState": since_state,
88 });
89 if let Some(mc) = max_changes {
90 args["maxChanges"] = mc.into();
91 }
92 let req = super::build_request("Task/changes", args, super::USING_TASKS);
93 let resp = self.call_internal(api_url, &req).await?;
94 jmap_base_client::extract_response(&resp, super::CALL_ID)
95 }
96
97 /// Create, update, or destroy Task objects (draft-tasks-06 §4.7).
98 ///
99 /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). Wire
100 /// format is unchanged from a plain JSON object because [`PatchObject`]
101 /// is `#[serde(transparent)]`; the typed parameter binds the JSON Pointer
102 /// key + null-leaf removal contract to the type system.
103 ///
104 /// # Errors
105 ///
106 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
107 /// if the bound session has no primary account for
108 /// `urn:ietf:params:jmap:tasks`.
109 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
110 /// if `update` is `Some` and `serde_json::to_value` fails on the
111 /// patch map (pathological conditions only — allocation failure,
112 /// or a `PatchObject` whose JSON tree exceeds `serde_json`'s
113 /// recursion limit). The transient memory peak for very large
114 /// `update` maps is roughly 3-4× the `HashMap`'s in-memory size
115 /// (source map + `serde_json::Value` tree + serialized `Vec<u8>`
116 /// body); callers dealing with thousands of patches per call may
117 /// prefer to batch.
118 /// - Any transport / protocol variant returned by
119 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
120 /// the matching error list on [`Self::task_get`].
121 pub async fn task_set(
122 &self,
123 create: Option<serde_json::Value>,
124 update: Option<HashMap<Id, PatchObject>>,
125 destroy: Option<Vec<Id>>,
126 ) -> Result<SetResponse<jmap_tasks_types::Task>, jmap_base_client::ClientError> {
127 if create.is_none() && update.is_none() && destroy.is_none() {
128 return Err(jmap_base_client::ClientError::InvalidArgument(
129 "task_set: at least one of create, update, destroy must be Some \
130 (an all-None /set is a no-op round-trip)"
131 .into(),
132 ));
133 }
134 let (api_url, account_id) = self.session_parts()?;
135 let mut args = serde_json::json!({
136 "accountId": account_id,
137 });
138 if let Some(c) = create {
139 args["create"] = c;
140 }
141 if let Some(u) = update {
142 args["update"] = serde_json::to_value(&u).map_err(|e| {
143 jmap_base_client::ClientError::InvalidArgument(format!(
144 "task_set: serializing update map failed: {e}"
145 ))
146 })?;
147 }
148 if let Some(d) = destroy {
149 args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
150 }
151 let req = super::build_request("Task/set", args, super::USING_TASKS);
152 let resp = self.call_internal(api_url, &req).await?;
153 jmap_base_client::extract_response(&resp, super::CALL_ID)
154 }
155
156 /// Copy Tasks from another account (draft-tasks-06 §4.8).
157 ///
158 /// `from_account_id` is the source account. The tasks are copied into the
159 /// current primary Tasks account.
160 ///
161 /// # Errors
162 ///
163 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
164 /// if the bound session has no primary account for
165 /// `urn:ietf:params:jmap:tasks`.
166 /// - Any transport / protocol variant returned by
167 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
168 /// the matching error list on [`Self::task_get`]. RFC 8620 §5.4
169 /// /copy adds method-level errors `fromAccountNotFound`,
170 /// `fromAccountNotSupportedByMethod`, and `anchorNotFound`; they
171 /// surface as
172 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
173 pub async fn task_copy(
174 &self,
175 from_account_id: &Id,
176 create: serde_json::Value,
177 ) -> Result<SetResponse<jmap_tasks_types::Task>, jmap_base_client::ClientError> {
178 let (api_url, account_id) = self.session_parts()?;
179 let args = serde_json::json!({
180 "fromAccountId": from_account_id,
181 "accountId": account_id,
182 "create": create,
183 });
184 let req = super::build_request("Task/copy", args, super::USING_TASKS);
185 let resp = self.call_internal(api_url, &req).await?;
186 jmap_base_client::extract_response(&resp, super::CALL_ID)
187 }
188
189 /// Query Task IDs with optional filter and sort (draft-tasks-06 §4.13).
190 ///
191 /// # Errors
192 ///
193 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
194 /// if the bound session has no primary account for
195 /// `urn:ietf:params:jmap:tasks`.
196 /// - Any transport / protocol variant returned by
197 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
198 /// the matching error list on [`Self::task_get`]. RFC 8620 §5.5
199 /// defines additional /query method-level errors
200 /// (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
201 /// `tooManyChanges`) that surface as
202 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
203 pub async fn task_query(
204 &self,
205 filter: Option<serde_json::Value>,
206 sort: Option<serde_json::Value>,
207 position: Option<u64>,
208 limit: Option<u64>,
209 ) -> Result<QueryResponse, jmap_base_client::ClientError> {
210 let (api_url, account_id) = self.session_parts()?;
211 let mut args = serde_json::json!({
212 "accountId": account_id,
213 });
214 if let Some(f) = filter {
215 args["filter"] = f;
216 }
217 if let Some(s) = sort {
218 args["sort"] = s;
219 }
220 if let Some(p) = position {
221 args["position"] = p.into();
222 }
223 if let Some(l) = limit {
224 args["limit"] = l.into();
225 }
226 let req = super::build_request("Task/query", args, super::USING_TASKS);
227 let resp = self.call_internal(api_url, &req).await?;
228 jmap_base_client::extract_response(&resp, super::CALL_ID)
229 }
230
231 /// Fetch query-result changes for Task since `since_query_state`
232 /// (draft-tasks-06 §4.14).
233 ///
234 /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
235 /// original `Task/query` call that returned `since_query_state` —
236 /// RFC 8620 §5.6 is explicit that the server uses them to compute
237 /// which entries entered or left the result set.
238 ///
239 /// `up_to_id` is the highest-index id the client has cached;
240 /// `calculate_total` requests the new total result count.
241 ///
242 /// # Errors
243 ///
244 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
245 /// if `since_query_state` is the empty string (defence-in-depth
246 /// empty-state guard; see [`Self::task_changes`]).
247 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
248 /// if the bound session has no primary account for
249 /// `urn:ietf:params:jmap:tasks`.
250 /// - Any transport / protocol variant returned by
251 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
252 /// the matching error list on [`Self::task_get`]. RFC 8620 §5.6
253 /// also defines `cannotCalculateChanges` (returned when the
254 /// server cannot honour the request given the supplied filter /
255 /// sort); it surfaces as
256 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
257 pub async fn task_query_changes(
258 &self,
259 since_query_state: &State,
260 max_changes: Option<u64>,
261 filter: Option<serde_json::Value>,
262 sort: Option<serde_json::Value>,
263 up_to_id: Option<&Id>,
264 calculate_total: Option<bool>,
265 ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
266 // Defence-in-depth: see task_list_changes.
267 if since_query_state.as_ref().is_empty() {
268 return Err(jmap_base_client::ClientError::InvalidArgument(
269 "task_query_changes: since_query_state may not be empty".into(),
270 ));
271 }
272 let (api_url, account_id) = self.session_parts()?;
273 let mut args = serde_json::json!({
274 "accountId": account_id,
275 "sinceQueryState": since_query_state,
276 });
277 if let Some(f) = filter {
278 args["filter"] = f;
279 }
280 if let Some(s) = sort {
281 args["sort"] = s;
282 }
283 if let Some(mc) = max_changes {
284 args["maxChanges"] = mc.into();
285 }
286 if let Some(uti) = up_to_id {
287 args["upToId"] = serde_json::to_value(uti).expect("Id Serialize is infallible");
288 }
289 if let Some(ct) = calculate_total {
290 args["calculateTotal"] = ct.into();
291 }
292 let req = super::build_request("Task/queryChanges", args, super::USING_TASKS);
293 let resp = self.call_internal(api_url, &req).await?;
294 jmap_base_client::extract_response(&resp, super::CALL_ID)
295 }
296}
297
298// ---------------------------------------------------------------------------
299// Tests — see tests/task_tests.rs (wiremock-backed end-to-end)
300// ---------------------------------------------------------------------------
301//
302// `task_copy_request_includes_from_account_id` was vacuous: it hand-built
303// `args` Values and fed them to `build_request`, never exercising the
304// production `task_copy` builder. Deleted in JMAP-tco1.20.
305//
306// Real production-path coverage:
307// - task_get_sends_ids_and_properties
308// - task_get_all_ids_null
309// - task_changes_paginated
310// - task_set_create_round_trip
311// - task_copy_includes_from_account_id
312// - task_query_with_filter
313// - task_query_changes_round_trip
314// in tests/task_tests.rs.
315//
316// Specific-flag passthrough coverage that may be lost is tracked
317// under JMAP-uuoi for follow-up wiremock smoke tests.
318//
319// `build_request`, `CALL_ID`, and `USING_TASKS` themselves have their
320// own focused tests in `methods/mod.rs`.
321//
322// The InvalidArgument guards for empty since_state, from_account_id, and
323// since_query_state live in task_changes / task_copy / task_query_changes
324// production code; testing them requires a wiremock-backed async harness.
325// See JMAP-sc1b.64.
326//
327// The `task_get_empty_id_returns_invalid_argument` inline smoke test was
328// removed by the JMAP-6by7.5 typed-Id refactor. It was vacuous because
329// it only iterated a local `&[""]` slice and asserted `is_empty()` found
330// the empty value, without invoking any production method. Under typed
331// `&[Id]` parameters, an empty-Id input is impossible to express through
332// the API (`Id::new_validated("")` returns `Err` at the call site) so the
333// bug it pretended to test is unrepresentable.