Skip to main content

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}