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(¶ms).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(¶ms).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(¶ms).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(¶ms).expect("serialize AddressBookSetParams");
470 assert_eq!(v["acmeCorpCascade"], json!("strict"));
471 }
472}