Skip to main content

jmap_contacts_client/methods/
mod.rs

1//! Typed JMAP Contacts 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, §5.4 /copy, §5.6 /queryChanges). Method
6//! implementations live in sub-modules and operate on `SessionClient`.
7
8pub mod addressbook;
9pub mod card;
10
11// ---------------------------------------------------------------------------
12// Response types (RFC 8620 §5)
13// ---------------------------------------------------------------------------
14//
15// Re-exported from `jmap-types::methods` so all `jmap-*-client` crates share
16// one canonical set of /get, /set, /changes, /query, /queryChanges shapes.
17// The wire format is identical to the previous local definitions.
18
19pub use jmap_types::{
20    AddedItem, ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetError,
21    SetResponse,
22};
23
24// ---------------------------------------------------------------------------
25// AddressBookSetParams — extra arguments for AddressBook/set
26// (RFC 9610 §2.3)
27// ---------------------------------------------------------------------------
28
29/// Extra method-level arguments for `AddressBook/set`
30/// (RFC 9610 §2.3).
31///
32/// Both fields are optional. Pass `None` (or `Default::default()`) when not
33/// needed.
34#[derive(Debug, Default, Clone, serde::Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct AddressBookSetParams {
37    /// If `true`, ContactCards that belong *only* to a destroyed AddressBook
38    /// are also destroyed. Cards shared with other books are simply detached.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub on_destroy_remove_contents: Option<bool>,
41
42    /// A `serde_json::Value` holding the `onSuccessSetIsDefault` argument.
43    /// When `Some`, the server sets the indicated AddressBook as the default
44    /// after all other operations succeed (RFC 9610 §2.3).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub on_success_set_is_default: Option<serde_json::Value>,
47
48    /// Catch-all for vendor / site / private extension fields not covered
49    /// by the typed fields above. Preserves unknown fields across
50    /// deserialize/serialize round-trip per workspace extras-preservation
51    /// policy (see workspace AGENTS.md).
52    ///
53    /// **Constraint**: keys in `extra` MUST NOT collide with the
54    /// typed-field wire names above (the camelCase spelling — e.g.
55    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
56    /// `"fromAccountId"`, etc.). On collision the typed-field value
57    /// wins on the wire and the `extra` value is silently dropped at
58    /// serialization. Place vendor extensions under vendor-prefixed
59    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
60    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
61    pub extra: serde_json::Map<String, serde_json::Value>,
62}
63
64// ---------------------------------------------------------------------------
65// Constants
66// ---------------------------------------------------------------------------
67
68/// The call-id embedded in every single-method JMAP request produced by
69/// [`build_request`]. Pass directly to `jmap_base_client::extract_response`.
70pub(crate) const CALL_ID: &str = "r1";
71
72/// Capability URIs for JMAP Contacts method calls
73/// (RFC 9610 §1.4).
74pub(crate) const USING_CONTACTS: &[&str] = &[
75    "urn:ietf:params:jmap:core",
76    jmap_contacts_types::JMAP_CONTACTS_URI,
77];
78
79// ---------------------------------------------------------------------------
80// build_request helper
81// ---------------------------------------------------------------------------
82
83/// Build a single-method JMAP request.
84///
85/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
86/// Use the pre-defined constant [`USING_CONTACTS`] for standard calls.
87///
88/// The embedded call-id is [`CALL_ID`]; pass it directly to
89/// `jmap_base_client::extract_response`.
90pub(crate) fn build_request(
91    method: &str,
92    args: serde_json::Value,
93    using: &[&str],
94) -> jmap_types::JmapRequest {
95    let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
96    let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
97    jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
98}
99
100// ---------------------------------------------------------------------------
101// SessionClient — session-bound client
102// ---------------------------------------------------------------------------
103
104/// A `JmapClient` bound to a JMAP session.
105///
106/// Obtain via [`JmapContactsExt::with_contacts_session`](crate::JmapContactsExt::with_contacts_session).
107/// All JMAP Contacts methods are available on this type without needing to
108/// pass `&Session` on every call.
109///
110/// # Session lifecycle
111///
112/// `SessionClient` captures the `Session` at construction time. After
113/// re-fetching the session via `JmapClient::fetch_session`, construct a new
114/// `SessionClient` with the updated session. Reusing a stale `SessionClient`
115/// after session expiry will result in `unknownAccount` or similar errors
116/// from the server.
117///
118/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
119/// already implements `Clone` and `with_contacts_session` clones one
120/// internally), enabling parallel-task fan-out with one bound session.
121///
122/// `Debug` is implemented manually to redact the inner `JmapClient` (which
123/// holds an HTTP client and is intentionally not `Debug` in
124/// `jmap-base-client`); only the `Session` is shown. This lets callers
125/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
126/// impls of their own.
127///
128/// # Thread safety
129///
130/// `SessionClient` is `Send + Sync`. Both
131/// [`jmap_base_client::JmapClient`] (backed by `reqwest::Client`) and
132/// [`jmap_base_client::Session`] (plain serde-derived data) are
133/// `Send + Sync` per jmap-base-client's contract, so this type can be
134/// shared across async tasks via `Arc<SessionClient>` or cloned for
135/// per-task ownership.
136///
137/// A `Send + Sync` regression in a future jmap-base-client release
138/// would be a major-version-breaking change for this crate. A
139/// compile-time assertion in `methods/mod.rs` guards against the
140/// regression landing silently — see
141/// `_assert_session_client_send_sync`.
142#[non_exhaustive]
143#[derive(Clone)]
144pub struct SessionClient {
145    pub(crate) client: jmap_base_client::JmapClient,
146    pub(crate) session: jmap_base_client::Session,
147}
148
149impl std::fmt::Debug for SessionClient {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("SessionClient")
152            // The inner JmapClient is not Debug — show a placeholder so
153            // callers know it is present without leaking HTTP-client
154            // internals.
155            .field("client", &"<JmapClient>")
156            .field("session", &self.session)
157            .finish()
158    }
159}
160
161impl SessionClient {
162    /// Borrow the underlying [`JmapClient`](jmap_base_client::JmapClient).
163    ///
164    /// Useful for ad-hoc operations outside the typed JMAP method surface —
165    /// for example, calling `JmapClient::upload` / `JmapClient::download_blob`,
166    /// or constructing a `JmapClient::event_source` subscription using the
167    /// bound session's `event_source_url`.
168    pub fn client(&self) -> &jmap_base_client::JmapClient {
169        &self.client
170    }
171
172    /// Borrow the captured [`Session`](jmap_base_client::Session).
173    ///
174    /// `SessionClient` captures the `Session` at construction time. After
175    /// re-fetching the session via `JmapClient::fetch_session`, callers
176    /// should construct a new `SessionClient`. This accessor lets a caller
177    /// compare the captured session's `state` field against a freshly
178    /// fetched session to detect staleness, or inspect
179    /// `accountCapabilities` / `primary_accounts` for capability-specific
180    /// metadata not exposed via the typed JMAP method surface.
181    pub fn session(&self) -> &jmap_base_client::Session {
182        &self.session
183    }
184
185    /// Return the primary account id for `urn:ietf:params:jmap:contacts`,
186    /// or `Err(InvalidSession)` if the session has no primary account for
187    /// that capability.
188    pub fn contacts_account_id(&self) -> Result<&str, jmap_base_client::ClientError> {
189        self.session
190            .primary_account_id(jmap_contacts_types::JMAP_CONTACTS_URI)
191            .ok_or_else(|| {
192                jmap_base_client::ClientError::InvalidSession(
193                    "no primary account for urn:ietf:params:jmap:contacts".into(),
194                )
195            })
196    }
197
198    /// Extract `(api_url, contacts_account_id)` from the bound session.
199    ///
200    /// Returns `Err(InvalidSession)` if there is no primary account for
201    /// `urn:ietf:params:jmap:contacts`.
202    pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
203        let api_url = self.session.api_url.as_str();
204        let account_id = self
205            .session
206            .primary_account_id(jmap_contacts_types::JMAP_CONTACTS_URI)
207            .ok_or_else(|| {
208                jmap_base_client::ClientError::InvalidSession(
209                    "no primary account for urn:ietf:params:jmap:contacts".into(),
210                )
211            })?;
212        Ok((api_url, account_id))
213    }
214
215    /// Forward a JMAP request to the underlying HTTP client.
216    pub(crate) async fn call_internal(
217        &self,
218        api_url: &str,
219        req: &jmap_types::JmapRequest,
220    ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
221        self.client.call(api_url, req).await
222    }
223}
224
225/// Compile-time assertion that [`SessionClient`] is `Send + Sync`.
226///
227/// The `# Thread safety` section of [`SessionClient`]'s rustdoc promises
228/// auto-trait inheritance from
229/// [`jmap_base_client::JmapClient`] and
230/// [`jmap_base_client::Session`]. If a future jmap-base-client release
231/// adds a `!Sync` interior-mutability field to either, this assertion
232/// fails at compile time — flagging the regression at the dependency
233/// upgrade rather than at the downstream caller's "cannot send between
234/// threads safely" error.
235#[allow(dead_code)]
236fn _assert_session_client_send_sync() {
237    fn assert_send_sync<T: Send + Sync>() {}
238    assert_send_sync::<SessionClient>();
239}
240
241// ---------------------------------------------------------------------------
242// Tests
243// ---------------------------------------------------------------------------
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use serde_json::json;
249
250    /// Oracle: USING_CONTACTS contains exactly the two capability URIs from
251    /// RFC 9610 §1.4.
252    /// Expected values are taken directly from the spec.
253    #[test]
254    fn using_contacts_contains_correct_uris() {
255        let req = build_request("AddressBook/get", json!({}), USING_CONTACTS);
256        let v = serde_json::to_value(&req).expect("serialize");
257        let using = v["using"].as_array().expect("using must be array");
258        assert_eq!(using.len(), 2, "must have exactly 2 capability URIs");
259        assert!(
260            using.contains(&json!("urn:ietf:params:jmap:core")),
261            "must include jmap:core"
262        );
263        assert!(
264            using.contains(&json!("urn:ietf:params:jmap:contacts")),
265            "must include jmap:contacts"
266        );
267    }
268
269    /// Oracle: build_request produces correct method name and CALL_ID.
270    /// Expected: invocation[0] == method, invocation[2] == CALL_ID constant.
271    #[test]
272    fn build_request_method_name_and_call_id() {
273        let req = build_request(
274            "AddressBook/get",
275            json!({"accountId": "acc1", "ids": null}),
276            USING_CONTACTS,
277        );
278        let v = serde_json::to_value(&req).expect("serialize JmapRequest");
279
280        let calls = v["methodCalls"]
281            .as_array()
282            .expect("methodCalls must be array");
283        assert_eq!(calls.len(), 1, "must have exactly 1 method call");
284        assert_eq!(
285            calls[0][0],
286            json!("AddressBook/get"),
287            "method name must match"
288        );
289        assert_eq!(calls[0][2], json!("r1"), "call_id must be CALL_ID constant");
290    }
291
292    /// Oracle: AddressBookSetParams with on_destroy_remove_contents=true serializes
293    /// the camelCase field name.
294    /// Expected: JSON key is "onDestroyRemoveContents" per RFC 9610 §2.3.
295    #[test]
296    fn address_book_set_params_serializes_on_destroy_remove_contents() {
297        let params = AddressBookSetParams {
298            on_destroy_remove_contents: Some(true),
299            on_success_set_is_default: None,
300            extra: serde_json::Map::new(),
301        };
302        let v = serde_json::to_value(&params).expect("serialize");
303        assert_eq!(
304            v["onDestroyRemoveContents"],
305            json!(true),
306            "onDestroyRemoveContents must be present and true"
307        );
308        assert!(
309            v.get("onSuccessSetIsDefault").is_none(),
310            "onSuccessSetIsDefault must be absent when None"
311        );
312    }
313
314    /// Oracle: AddressBookSetParams default (all None) serializes to `{}`.
315    /// Expected: skip_serializing_if omits both None fields.
316    #[test]
317    fn address_book_set_params_default_is_empty_object() {
318        let params = AddressBookSetParams::default();
319        let v = serde_json::to_value(&params).expect("serialize");
320        assert_eq!(
321            v,
322            json!({}),
323            "default params must serialize to empty object"
324        );
325    }
326
327    /// Oracle: AddressBookSetParams with on_success_set_is_default serializes it.
328    /// Expected: JSON key is "onSuccessSetIsDefault".
329    #[test]
330    fn address_book_set_params_serializes_on_success_set_is_default() {
331        let params = AddressBookSetParams {
332            on_destroy_remove_contents: None,
333            on_success_set_is_default: Some(json!({"newDefaultId": true})),
334            extra: serde_json::Map::new(),
335        };
336        let v = serde_json::to_value(&params).expect("serialize");
337        assert!(
338            v.get("onDestroyRemoveContents").is_none(),
339            "onDestroyRemoveContents must be absent when None"
340        );
341        assert_eq!(
342            v["onSuccessSetIsDefault"],
343            json!({"newDefaultId": true}),
344            "onSuccessSetIsDefault must be present"
345        );
346    }
347
348    /// Oracle: session_parts returns None when contacts capability absent.
349    /// Expected: primary_account_id returns None for an absent key.
350    #[test]
351    fn session_parts_err_no_primary_account() {
352        let session_json = json!({
353            "capabilities": {},
354            "accounts": {},
355            "primaryAccounts": {},
356            "username": "user@example.com",
357            "apiUrl": "https://jmap.example.com/api/",
358            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
359            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
360            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
361            "state": "s1"
362        });
363        let session: jmap_base_client::Session =
364            serde_json::from_value(session_json).expect("session must deserialize");
365        let result = session.primary_account_id("urn:ietf:params:jmap:contacts");
366        assert!(
367            result.is_none(),
368            "must return None when contacts capability is not in primaryAccounts"
369        );
370    }
371
372    /// Oracle: GetResponse<T> deserializes from RFC 8620 §5.1 shape.
373    #[test]
374    fn get_response_deserializes() {
375        let json = json!({
376            "accountId": "acc1",
377            "state": "s42",
378            "list": [],
379            "notFound": ["missing1"]
380        });
381        let resp: GetResponse<serde_json::Value> =
382            serde_json::from_value(json).expect("GetResponse must deserialize");
383        assert_eq!(resp.account_id, "acc1");
384        assert_eq!(resp.state, "s42");
385        assert!(resp.list.is_empty());
386        assert_eq!(
387            resp.not_found.as_deref(),
388            Some(["missing1".into()].as_slice())
389        );
390    }
391
392    /// Oracle: ChangesResponse deserializes from RFC 8620 §5.2 shape.
393    #[test]
394    fn changes_response_deserializes() {
395        let json = json!({
396            "accountId": "acc1",
397            "oldState": "s10",
398            "newState": "s11",
399            "hasMoreChanges": false,
400            "created": ["id1"],
401            "updated": ["id2"],
402            "destroyed": []
403        });
404        let resp: ChangesResponse =
405            serde_json::from_value(json).expect("ChangesResponse must deserialize");
406        assert_eq!(resp.old_state, "s10");
407        assert_eq!(resp.new_state, "s11");
408        assert!(!resp.has_more_changes);
409        assert_eq!(resp.created.len(), 1);
410        assert_eq!(resp.updated.len(), 1);
411        assert!(resp.destroyed.is_empty());
412    }
413
414    /// Oracle: SetResponse deserializes from RFC 8620 §5.3 shape.
415    #[test]
416    fn set_response_deserializes() {
417        let json = json!({
418            "accountId": "acc1",
419            "oldState": "s10",
420            "newState": "s11",
421            "created": null,
422            "updated": null,
423            "destroyed": ["id1"],
424            "notCreated": null,
425            "notUpdated": null,
426            "notDestroyed": null
427        });
428        let resp: SetResponse = serde_json::from_value(json).expect("SetResponse must deserialize");
429        assert_eq!(resp.new_state, "s11");
430        assert_eq!(resp.destroyed.as_deref(), Some(["id1".into()].as_slice()));
431    }
432
433    /// Oracle: QueryChangesResponse deserializes from RFC 8620 §5.6 shape.
434    #[test]
435    fn query_changes_response_deserializes() {
436        let json = json!({
437            "accountId": "acc1",
438            "oldQueryState": "qs1",
439            "newQueryState": "qs2",
440            "total": 5,
441            "removed": ["id3"],
442            "added": [{"id": "id4", "index": 0}]
443        });
444        let resp: QueryChangesResponse =
445            serde_json::from_value(json).expect("QueryChangesResponse must deserialize");
446        assert_eq!(resp.old_query_state, "qs1");
447        assert_eq!(resp.new_query_state, "qs2");
448        assert_eq!(resp.total, Some(5));
449        assert_eq!(resp.removed.len(), 1);
450        assert_eq!(resp.added.len(), 1);
451        assert_eq!(resp.added[0].index, 0);
452    }
453
454    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
455    //
456    // For Serialize-only method-argument structs, the test constructs a
457    // struct with a vendor field in `extra` and asserts that the field
458    // flattens into the serialized JSON. Uses synthetic `acmeCorp*` keys
459    // that are guaranteed not to appear in any RFC 9610 typed field — so
460    // the tests are independent of the crate under test.
461
462    /// `AddressBookSetParams.extra` flattens into serialized JSON.
463    #[test]
464    fn address_book_set_params_propagates_vendor_extras() {
465        let mut params = AddressBookSetParams::default();
466        params
467            .extra
468            .insert("acmeCorpCascade".into(), json!("strict"));
469        let v = serde_json::to_value(&params).expect("serialize AddressBookSetParams");
470        assert_eq!(v["acmeCorpCascade"], json!("strict"));
471    }
472}