jmap_contacts_client/methods/card.rs
1//! JMAP Contacts — ContactCard/* method implementations on SessionClient.
2//!
3//! Each method follows the standard five-step pattern:
4//! 1. Validate arguments (defence-in-depth empty-state guards).
5//! 2. Call `self.session_parts()?` → `(api_url, account_id)`.
6//! 3. Build args JSON with `serde_json::json!({…})`.
7//! 4. Call `build_request(method_name, args, USING_CONTACTS)`.
8//! 5. Call `self.call_internal(api_url, &req).await?`.
9//! 6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
10
11use std::collections::HashMap;
12
13use jmap_types::{Id, PatchObject, State};
14
15use super::{ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetResponse};
16
17impl super::SessionClient {
18 /// Fetch ContactCard objects by IDs (RFC 9610 §3.1).
19 ///
20 /// If `ids` is `None`, the server returns all ContactCards for the account,
21 /// SUBJECT TO the server's `maxObjectsInGet` cap (RFC 8620 §5.1).
22 /// For production use, scope the result set via the corresponding
23 /// /query method first and pass explicit ids here to avoid
24 /// `requestTooLarge` errors when the account holds more objects
25 /// than the cap.
26 /// Pass `properties: None` to return all fields.
27 ///
28 /// # Errors
29 ///
30 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
31 /// if the bound session has no primary account for
32 /// `urn:ietf:params:jmap:contacts`.
33 /// - Any transport / protocol variant returned by
34 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
35 /// [`Http`](jmap_base_client::ClientError::Http),
36 /// [`Parse`](jmap_base_client::ClientError::Parse),
37 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
38 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
39 /// (wraps RFC 8620 §3.6.2 method-level errors such as
40 /// `accountNotFound`, `invalidArguments`, `serverFail`),
41 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
42 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
43 /// or
44 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
45 pub async fn contact_card_get(
46 &self,
47 ids: Option<&[Id]>,
48 properties: Option<&[&str]>,
49 ) -> Result<GetResponse<jmap_contacts_types::ContactCard>, jmap_base_client::ClientError> {
50 let (api_url, account_id) = self.session_parts()?;
51 // Omit `ids` / `properties` when None — see the matching comment on
52 // `address_book_get` for the rationale (consistent with set/changes/query).
53 let mut args = serde_json::json!({ "accountId": account_id });
54 if let Some(id_slice) = ids {
55 args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
56 }
57 if let Some(props) = properties {
58 args["properties"] =
59 serde_json::to_value(props).expect("&[&str] Serialize is infallible");
60 }
61 let req = super::build_request("ContactCard/get", args, super::USING_CONTACTS);
62 let resp = self.call_internal(api_url, &req).await?;
63 jmap_base_client::extract_response(&resp, super::CALL_ID)
64 }
65
66 /// Fetch changes to ContactCard objects since `since_state`
67 /// (RFC 9610 §3.2).
68 ///
69 /// If `has_more_changes` is true in the response, call again with
70 /// `new_state` as `since_state` until the flag is false.
71 ///
72 /// # Errors
73 ///
74 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
75 /// if `since_state` is the empty string (defence-in-depth —
76 /// `State` constructed via [`State::from`](jmap_types::State::from)
77 /// accepts empty strings, but an empty `sinceState` is never
78 /// useful and would otherwise generate a wasted round-trip).
79 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
80 /// if the bound session has no primary account for
81 /// `urn:ietf:params:jmap:contacts`.
82 /// - Any transport / protocol variant returned by
83 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
84 /// the matching error list on [`Self::contact_card_get`].
85 pub async fn contact_card_changes(
86 &self,
87 since_state: &State,
88 max_changes: Option<u64>,
89 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
90 // Defence-in-depth: see `address_book_changes`.
91 if since_state.as_ref().is_empty() {
92 return Err(jmap_base_client::ClientError::InvalidArgument(
93 "contact_card_changes: since_state may not be empty".into(),
94 ));
95 }
96 let (api_url, account_id) = self.session_parts()?;
97 let mut args = serde_json::json!({
98 "accountId": account_id,
99 "sinceState": since_state,
100 });
101 if let Some(mc) = max_changes {
102 args["maxChanges"] = mc.into();
103 }
104 let req = super::build_request("ContactCard/changes", args, super::USING_CONTACTS);
105 let resp = self.call_internal(api_url, &req).await?;
106 jmap_base_client::extract_response(&resp, super::CALL_ID)
107 }
108
109 /// Create, update, or destroy ContactCard objects
110 /// (RFC 9610 §3.3).
111 ///
112 /// Pass `create`, `update`, and/or `destroy` as needed. All three are
113 /// optional; pass `None` to omit any operation from the request.
114 ///
115 /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). Wire
116 /// format is unchanged from a plain JSON object because [`PatchObject`]
117 /// is `#[serde(transparent)]`; the typed parameter binds the JSON Pointer
118 /// key + null-leaf removal contract to the type system.
119 ///
120 /// # Errors
121 ///
122 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
123 /// if the bound session has no primary account for
124 /// `urn:ietf:params:jmap:contacts`.
125 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
126 /// if `update` is `Some` and `serde_json::to_value` fails on the
127 /// patch map (pathological conditions only; see
128 /// [`Self::address_book_set`] for the memory-cost discussion that
129 /// applies identically here).
130 /// - Any transport / protocol variant returned by
131 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
132 /// the matching error list on [`Self::contact_card_get`].
133 pub async fn contact_card_set(
134 &self,
135 create: Option<serde_json::Value>,
136 update: Option<HashMap<Id, PatchObject>>,
137 destroy: Option<Vec<Id>>,
138 ) -> Result<SetResponse<jmap_contacts_types::ContactCard>, jmap_base_client::ClientError> {
139 if create.is_none() && update.is_none() && destroy.is_none() {
140 return Err(jmap_base_client::ClientError::InvalidArgument(
141 "contact_card_set: at least one of create, update, destroy must be Some \
142 (an all-None /set is a no-op round-trip)"
143 .into(),
144 ));
145 }
146 let (api_url, account_id) = self.session_parts()?;
147 let mut args = serde_json::json!({
148 "accountId": account_id,
149 });
150 if let Some(c) = create {
151 args["create"] = c;
152 }
153 if let Some(u) = update {
154 args["update"] = serde_json::to_value(&u).map_err(|e| {
155 jmap_base_client::ClientError::InvalidArgument(format!(
156 "contact_card_set: serializing update map failed: {e}"
157 ))
158 })?;
159 }
160 if let Some(d) = destroy {
161 args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
162 }
163 let req = super::build_request("ContactCard/set", args, super::USING_CONTACTS);
164 let resp = self.call_internal(api_url, &req).await?;
165 jmap_base_client::extract_response(&resp, super::CALL_ID)
166 }
167
168 /// Copy ContactCards from another account (RFC 8620 §5.4 /copy).
169 ///
170 /// `from_account_id` is the source account. `create` is a map of
171 /// caller-supplied creation keys to copy descriptors. The server assigns
172 /// new IDs in the destination account.
173 ///
174 /// # Errors
175 ///
176 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
177 /// if the bound session has no primary account for
178 /// `urn:ietf:params:jmap:contacts`.
179 /// - Any transport / protocol variant returned by
180 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
181 /// the matching error list on [`Self::contact_card_get`]. RFC 8620
182 /// §5.4 /copy adds method-level errors `fromAccountNotFound`,
183 /// `fromAccountNotSupportedByMethod`, and `anchorNotFound`; they
184 /// surface as
185 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
186 pub async fn contact_card_copy(
187 &self,
188 from_account_id: &Id,
189 create: serde_json::Value,
190 ) -> Result<SetResponse<jmap_contacts_types::ContactCard>, jmap_base_client::ClientError> {
191 let (api_url, account_id) = self.session_parts()?;
192 let args = serde_json::json!({
193 "fromAccountId": from_account_id,
194 "accountId": account_id,
195 "create": create,
196 });
197 let req = super::build_request("ContactCard/copy", args, super::USING_CONTACTS);
198 let resp = self.call_internal(api_url, &req).await?;
199 jmap_base_client::extract_response(&resp, super::CALL_ID)
200 }
201
202 /// Query ContactCard IDs with optional filter and sort
203 /// (RFC 9610 §3.4).
204 ///
205 /// Pass `filter: None` and `sort: None` to return all ContactCards with
206 /// server-default ordering. Use `position` and `limit` for pagination.
207 ///
208 /// # Errors
209 ///
210 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
211 /// if the bound session has no primary account for
212 /// `urn:ietf:params:jmap:contacts`.
213 /// - Any transport / protocol variant returned by
214 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
215 /// the matching error list on [`Self::contact_card_get`]. RFC 8620
216 /// §5.5 defines additional /query method-level errors
217 /// (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
218 /// `tooManyChanges`) that surface as
219 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
220 pub async fn contact_card_query(
221 &self,
222 filter: Option<serde_json::Value>,
223 sort: Option<serde_json::Value>,
224 position: Option<u64>,
225 limit: Option<u64>,
226 ) -> Result<QueryResponse, jmap_base_client::ClientError> {
227 let (api_url, account_id) = self.session_parts()?;
228 let mut args = serde_json::json!({
229 "accountId": account_id,
230 });
231 if let Some(f) = filter {
232 args["filter"] = f;
233 }
234 if let Some(s) = sort {
235 args["sort"] = s;
236 }
237 if let Some(p) = position {
238 args["position"] = p.into();
239 }
240 if let Some(l) = limit {
241 args["limit"] = l.into();
242 }
243 let req = super::build_request("ContactCard/query", args, super::USING_CONTACTS);
244 let resp = self.call_internal(api_url, &req).await?;
245 jmap_base_client::extract_response(&resp, super::CALL_ID)
246 }
247
248 /// Fetch query-result changes for ContactCard since `since_query_state`
249 /// (RFC 9610 §3.5).
250 ///
251 /// Returns which ContactCard IDs were removed from or added to the query
252 /// result set since the given state. `max_changes` may be `None`.
253 ///
254 /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
255 /// original `ContactCard/query` call that returned `since_query_state`
256 /// — RFC 8620 §5.6 is explicit that the server uses them to compute
257 /// which entries entered or left the result set.
258 ///
259 /// `up_to_id` is the highest-index id the client has cached;
260 /// `calculate_total` requests the new total result count.
261 ///
262 /// # Errors
263 ///
264 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
265 /// if `since_query_state` is the empty string (defence-in-depth
266 /// empty-state guard; see [`Self::contact_card_changes`]).
267 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
268 /// if the bound session has no primary account for
269 /// `urn:ietf:params:jmap:contacts`.
270 /// - Any transport / protocol variant returned by
271 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
272 /// the matching error list on [`Self::contact_card_get`]. RFC 8620
273 /// §5.6 also defines `cannotCalculateChanges` (returned when the
274 /// server cannot honour the request given the supplied filter /
275 /// sort); it surfaces as
276 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
277 pub async fn contact_card_query_changes(
278 &self,
279 since_query_state: &State,
280 max_changes: Option<u64>,
281 filter: Option<serde_json::Value>,
282 sort: Option<serde_json::Value>,
283 up_to_id: Option<&Id>,
284 calculate_total: Option<bool>,
285 ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
286 // Defence-in-depth: see `contact_card_changes`.
287 if since_query_state.as_ref().is_empty() {
288 return Err(jmap_base_client::ClientError::InvalidArgument(
289 "contact_card_query_changes: since_query_state may not be empty".into(),
290 ));
291 }
292 let (api_url, account_id) = self.session_parts()?;
293 let mut args = serde_json::json!({
294 "accountId": account_id,
295 "sinceQueryState": since_query_state,
296 });
297 if let Some(f) = filter {
298 args["filter"] = f;
299 }
300 if let Some(s) = sort {
301 args["sort"] = s;
302 }
303 if let Some(mc) = max_changes {
304 args["maxChanges"] = mc.into();
305 }
306 if let Some(uti) = up_to_id {
307 args["upToId"] = serde_json::to_value(uti).expect("Id Serialize is infallible");
308 }
309 if let Some(ct) = calculate_total {
310 args["calculateTotal"] = ct.into();
311 }
312 let req = super::build_request("ContactCard/queryChanges", args, super::USING_CONTACTS);
313 let resp = self.call_internal(api_url, &req).await?;
314 jmap_base_client::extract_response(&resp, super::CALL_ID)
315 }
316}
317
318// ---------------------------------------------------------------------------
319// Tests
320// ---------------------------------------------------------------------------
321
322#[cfg(test)]
323mod tests {
324 use serde_json::json;
325
326 // Inline guard smoke tests (e.g.
327 // `contact_card_get_empty_id_returns_invalid_argument`,
328 // `contact_card_changes_empty_since_state_returns_invalid_argument`,
329 // `contact_card_copy_empty_from_account_id_returns_invalid_argument`,
330 // `contact_card_copy_non_empty_from_account_id_passes_guard`,
331 // `contact_card_query_changes_empty_state_returns_invalid_argument`)
332 // were removed by the JMAP-6by7.4 typed-Id refactor. They were
333 // vacuous because they only iterated a local `&[""]` slice (or
334 // duplicated the guard's `is_empty()` check) and asserted
335 // `is_empty()` found the empty value, without invoking any
336 // production method. Under typed `&Id` / `&[Id]` / `&State`
337 // parameters, an empty-Id input is impossible to express through
338 // the API (`Id::new_validated("")` returns `Err` at the call site)
339 // so the bug they pretended to test is unrepresentable.
340 //
341 // Additionally, `contact_card_get_request_shape`,
342 // `contact_card_changes_request_includes_since_state`,
343 // `contact_card_copy_request_includes_from_account_id`,
344 // `contact_card_query_request_includes_filter`,
345 // `contact_card_query_request_includes_sort`, and
346 // `contact_card_query_changes_request_includes_since_query_state`
347 // were vacuous: they hand-built `args` Values and fed them to
348 // `build_request`, never exercising the production `contact_card_*`
349 // builders. Deleted in JMAP-tco1.15.
350 //
351 // Real production-path coverage:
352 // - contact_card_get_round_trip
353 // - contact_card_changes_sends_since_state
354 // - contact_card_set_create_round_trip
355 // - contact_card_copy_round_trip
356 // in tests/contactcard_tests.rs, and
357 // - contact_card_query_with_filter
358 // - contact_card_query_changes_round_trip
359 // in tests/contactcard_query_tests.rs (wiremock-backed end-to-end).
360 //
361 // Specific-flag passthrough coverage that may be lost is tracked
362 // under JMAP-uuoi for follow-up wiremock smoke tests.
363 //
364 // `build_request`, `CALL_ID`, and `USING_CONTACTS` themselves have
365 // their own focused tests in `methods/mod.rs`.
366
367 /// Oracle: ContactCard deserialization from RFC 9610 §4.1 example.
368 /// Expected JSON taken verbatim from spec §4.1.
369 #[test]
370 fn contact_card_deserializes_from_spec_example() {
371 let json = json!({
372 "id": "3",
373 "addressBookIds": {
374 "062adcfa-105d-455c-bc60-6db68b69c3f3": true
375 },
376 "name": {
377 "components": [
378 { "kind": "given", "value": "Joe" },
379 { "kind": "surname", "value": "Bloggs" }
380 ],
381 "isOrdered": true
382 },
383 "emails": {
384 "0": {
385 "contexts": { "private": true },
386 "address": "joe.bloggs@example.com"
387 }
388 }
389 });
390 let card: jmap_contacts_types::ContactCard =
391 serde_json::from_value(json).expect("ContactCard must deserialize");
392
393 let id = card.id.as_ref().expect("id must be present");
394 assert_eq!(id.as_ref(), "3");
395
396 let ab_ids = card
397 .address_book_ids
398 .as_ref()
399 .expect("addressBookIds must be present");
400 let ab_key: jmap_types::Id = jmap_types::Id::from("062adcfa-105d-455c-bc60-6db68b69c3f3");
401 assert!(ab_ids[&ab_key]);
402
403 let emails = card.emails.as_ref().expect("emails must be present");
404 assert_eq!(emails["0"]["address"], "joe.bloggs@example.com");
405 }
406
407 /// Oracle: GetResponse<ContactCard> deserializes from RFC 8620 §5.1 shape.
408 #[test]
409 fn get_response_contact_card_deserializes() {
410 use super::super::GetResponse;
411
412 let json = json!({
413 "accountId": "acc1",
414 "state": "s7",
415 "list": [
416 {
417 "id": "card1",
418 "addressBookIds": { "ab1": true }
419 }
420 ],
421 "notFound": null
422 });
423 let resp: GetResponse<jmap_contacts_types::ContactCard> =
424 serde_json::from_value(json).expect("GetResponse<ContactCard> must deserialize");
425 assert_eq!(resp.account_id, "acc1");
426 assert_eq!(resp.state, "s7");
427 assert_eq!(resp.list.len(), 1);
428 assert!(resp.not_found.is_none());
429 }
430
431 /// Oracle: SetResponse<ContactCard> deserializes with created entry.
432 #[test]
433 fn set_response_contact_card_with_created_deserializes() {
434 use super::super::SetResponse;
435
436 let json = json!({
437 "accountId": "acc1",
438 "oldState": "s1",
439 "newState": "s2",
440 "created": {
441 "newCard": {
442 "id": "server-assigned-id",
443 "addressBookIds": { "ab1": true }
444 }
445 },
446 "updated": null,
447 "destroyed": null,
448 "notCreated": null,
449 "notUpdated": null,
450 "notDestroyed": null
451 });
452 let resp: SetResponse<jmap_contacts_types::ContactCard> =
453 serde_json::from_value(json).expect("SetResponse<ContactCard> must deserialize");
454 assert_eq!(resp.new_state, "s2");
455 let created = resp.created.expect("created must be present");
456 assert!(
457 created.contains_key("newCard"),
458 "created must contain 'newCard'"
459 );
460 let card = &created["newCard"];
461 assert_eq!(
462 card.id.as_ref().map(|id| id.as_ref()),
463 Some("server-assigned-id")
464 );
465 }
466}