Skip to main content

jmap_base_client/
request.rs

1// request.rs — JmapRequestBuilder, Session, AccountInfo, WebSocketCapability
2//
3// Types that belong to the base JMAP client layer (RFC 8620 §2, §3.3, RFC 8887).
4// Chat-specific and Mail-specific types live in their own crates.
5//
6// Types already in jmap-types and NOT redefined here:
7//   Id, UTCDate, State, Date, JmapRequest, JmapResponse, Invocation, ResultReference
8
9use std::collections::HashMap;
10use std::collections::HashSet;
11
12use serde::Deserialize;
13
14use jmap_types::{Invocation, JmapRequest, State};
15
16use crate::error::ClientError;
17
18// ---------------------------------------------------------------------------
19// JmapRequestBuilder (RFC 8620 §3.3)
20// ---------------------------------------------------------------------------
21
22/// Fluent builder for multi-method [`JmapRequest`] objects.
23///
24/// Collects method calls and produces a [`JmapRequest`] ready for dispatch.
25///
26/// The `using` capability URIs passed to `new` apply to the whole request;
27/// callers must include every capability required by the methods they add.
28///
29/// Spec: RFC 8620 §3.3
30#[derive(Debug)]
31pub struct JmapRequestBuilder {
32    using: Vec<String>,
33    method_calls: Vec<Invocation>,
34    call_ids: HashSet<String>,
35}
36
37impl JmapRequestBuilder {
38    /// Create a new builder with the given capability URIs.
39    ///
40    /// The `using` list MUST include `"urn:ietf:params:jmap:core"` (always
41    /// required by RFC 8620 §3.3) plus every capability URI needed by the
42    /// methods added via [`add_call`](JmapRequestBuilder::add_call). An
43    /// incorrect or empty `using` list will cause the server to return an
44    /// `"unknownCapability"` error — the builder does not validate it.
45    pub fn new(using: &[&str]) -> Self {
46        Self {
47            using: using.iter().map(|&s| s.to_owned()).collect(),
48            method_calls: Vec::new(),
49            call_ids: HashSet::new(),
50        }
51    }
52
53    /// Add one method call to the request.
54    ///
55    /// `call_id` must be unique within this request; callers use it to match
56    /// responses back to the originating call.
57    ///
58    /// Returns `Err(ClientError::InvalidArgument)` if `call_id` has already
59    /// been used in this builder. Duplicate call IDs violate RFC 8620 §3.5.
60    pub fn add_call(
61        &mut self,
62        method: impl Into<String>,
63        args: serde_json::Value,
64        call_id: impl Into<String>,
65    ) -> Result<&mut Self, ClientError> {
66        let call_id = call_id.into();
67        if !self.call_ids.insert(call_id.clone()) {
68            return Err(ClientError::InvalidArgument(format!(
69                "JmapRequestBuilder: duplicate call_id {:?}",
70                call_id
71            )));
72        }
73        self.method_calls.push((method.into(), args, call_id));
74        Ok(self)
75    }
76
77    /// Consume the builder and produce the [`JmapRequest`].
78    ///
79    /// Returns `Err(ClientError::InvalidArgument)` if no method calls have
80    /// been added. An empty `methodCalls` array is invalid per RFC 8620 §3.3.
81    pub fn build(self) -> Result<JmapRequest, ClientError> {
82        if self.method_calls.is_empty() {
83            return Err(ClientError::InvalidArgument("no method calls added".into()));
84        }
85        Ok(JmapRequest::new(self.using, self.method_calls, None))
86    }
87}
88
89// ---------------------------------------------------------------------------
90// Session (RFC 8620 §2)
91// ---------------------------------------------------------------------------
92
93/// JMAP Session object returned by `GET /.well-known/jmap` (RFC 8620 §2).
94///
95/// Contains only the base RFC 8620 fields. Extension-specific fields
96/// (e.g. JMAP Chat `ownerUserId`) are surfaced by extension crates that
97/// parse the `capabilities` and `accounts` maps.
98#[non_exhaustive]
99#[derive(Debug, Clone, PartialEq, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct Session {
102    /// Map of capability URI → capability object (RFC 8620 §2).
103    ///
104    /// Values are kept as raw JSON so callers can extract extension-specific
105    /// capability objects without this crate knowing their schema.
106    pub capabilities: HashMap<String, serde_json::Value>,
107
108    /// Map of account ID → [`AccountInfo`] (RFC 8620 §2).
109    pub accounts: HashMap<String, AccountInfo>,
110
111    /// Map of capability URI → primary account ID (RFC 8620 §2).
112    pub primary_accounts: HashMap<String, String>,
113
114    /// Username associated with the current credentials (RFC 8620 §2).
115    pub username: String,
116
117    /// URL for JMAP API POST requests (RFC 8620 §2).
118    pub api_url: String,
119
120    /// URL template for blob downloads (RFC 8620 §2).
121    ///
122    /// URI Template (level 1) containing variables `accountId`, `blobId`,
123    /// `type`, and `name`.
124    pub download_url: String,
125
126    /// URL template for blob uploads (RFC 8620 §2).
127    ///
128    /// URI Template (level 1) containing variable `accountId`.
129    pub upload_url: String,
130
131    /// URL template for SSE push event stream (RFC 8620 §2, §7.3).
132    ///
133    /// URI Template (level 1) containing variables `types`, `closeafter`,
134    /// and `ping`.
135    pub event_source_url: String,
136
137    /// Opaque session state token (RFC 8620 §2).
138    ///
139    /// Changes whenever any session property changes. Returned in every API
140    /// response as `sessionState`; clients compare to detect staleness.
141    pub state: State,
142}
143
144impl Session {
145    /// Returns the primary account ID for the given capability URI, if set.
146    ///
147    /// Example: `session.primary_account_id("urn:ietf:params:jmap:mail")`
148    pub fn primary_account_id(&self, capability: &str) -> Option<&str> {
149        self.primary_accounts.get(capability).map(String::as_str)
150    }
151
152    /// Returns the parsed [`WebSocketCapability`] for the JMAP WebSocket
153    /// transport, if advertised (RFC 8887).
154    ///
155    /// - `Ok(None)` — server does not advertise JMAP WebSocket support.
156    /// - `Ok(Some(...))` — WebSocket is supported; use `result.url` to connect.
157    /// - `Err` — capability key is present but the value is malformed.
158    pub fn websocket_capability(&self) -> Result<Option<WebSocketCapability>, ClientError> {
159        let Some(raw) = self.capabilities.get("urn:ietf:params:jmap:websocket") else {
160            return Ok(None);
161        };
162        serde_json::from_value::<WebSocketCapability>(raw.clone())
163            .map(Some)
164            .map_err(ClientError::Parse)
165    }
166}
167
168// ---------------------------------------------------------------------------
169// AccountInfo (RFC 8620 §2 Account object)
170// ---------------------------------------------------------------------------
171
172/// Per-account metadata in a JMAP Session (RFC 8620 §2).
173#[non_exhaustive]
174#[derive(Debug, Clone, PartialEq, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct AccountInfo {
177    /// Human-readable account name (e.g. the owner's email address).
178    pub name: String,
179
180    /// `true` if this is the authenticated user's own personal account.
181    pub is_personal: bool,
182
183    /// `true` if the entire account is read-only for the current user.
184    pub is_read_only: bool,
185
186    /// Map of capability URI → capability object for this account.
187    ///
188    /// Values are kept as raw JSON so extension crates can extract
189    /// their own capability objects.
190    pub account_capabilities: HashMap<String, serde_json::Value>,
191}
192
193// ---------------------------------------------------------------------------
194// WebSocketCapability (RFC 8887)
195// ---------------------------------------------------------------------------
196
197/// Capability object for `"urn:ietf:params:jmap:websocket"` (RFC 8887).
198///
199/// Advertised in `Session.capabilities` when the server supports JMAP over
200/// WebSocket. The `url` field is the `wss://` endpoint to connect to.
201#[non_exhaustive]
202#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct WebSocketCapability {
205    /// The WebSocket endpoint URL (`wss://`).
206    pub url: String,
207
208    /// Whether the server supports push notifications over this WebSocket.
209    #[serde(default)]
210    pub supports_push: bool,
211}
212
213// ---------------------------------------------------------------------------
214// Tests
215// ---------------------------------------------------------------------------
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use serde_json::json;
221
222    // -----------------------------------------------------------------------
223    // JmapRequestBuilder
224    // -----------------------------------------------------------------------
225
226    /// Oracle: RFC 8620 §3.3 — a request with two method calls serializes to
227    /// a JSON object with a "methodCalls" array containing two 3-element arrays.
228    /// The expected JSON shape is derived directly from the RFC §3.3 example.
229    #[test]
230    fn builder_two_calls_serializes_correctly() {
231        let mut builder =
232            JmapRequestBuilder::new(&["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]);
233        builder
234            .add_call(
235                "Mailbox/get",
236                json!({"accountId": "A13824", "ids": null}),
237                "r1",
238            )
239            .expect("add_call r1 must succeed");
240        builder
241            .add_call(
242                "Email/get",
243                json!({"accountId": "A13824", "ids": ["e001"]}),
244                "r2",
245            )
246            .expect("add_call r2 must succeed");
247        let req = builder.build().expect("build must succeed with two calls");
248
249        let v = serde_json::to_value(&req).expect("serialize JmapRequest");
250
251        // Oracle: RFC 8620 §3.3 — "using" must be present
252        assert!(v.get("using").is_some(), "must have 'using' field");
253        let using = v["using"].as_array().expect("using must be array");
254        assert_eq!(using.len(), 2);
255        assert!(using.contains(&json!("urn:ietf:params:jmap:core")));
256        assert!(using.contains(&json!("urn:ietf:params:jmap:mail")));
257
258        // Oracle: RFC 8620 §3.3 — "methodCalls" must be present
259        let calls = v["methodCalls"]
260            .as_array()
261            .expect("methodCalls must be array");
262        assert_eq!(calls.len(), 2, "must have exactly 2 method calls");
263
264        // Oracle: RFC 8620 §3.2 — each invocation is [methodName, args, callId]
265        assert_eq!(calls[0][0], json!("Mailbox/get"));
266        assert_eq!(calls[0][2], json!("r1"));
267        assert_eq!(calls[1][0], json!("Email/get"));
268        assert_eq!(calls[1][2], json!("r2"));
269    }
270
271    /// Oracle: RFC 8620 §3.3 — build() with no method calls is invalid;
272    /// must return Err(InvalidArgument) rather than produce an empty batch.
273    #[test]
274    fn builder_returns_err_on_empty_build() {
275        let result = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]).build();
276        assert!(
277            matches!(result, Err(ClientError::InvalidArgument(_))),
278            "empty build must return Err(InvalidArgument), got {result:?}"
279        );
280    }
281
282    /// Oracle: RFC 8620 §3.5 — call IDs must be unique within a request.
283    /// Duplicate call ID returns Err(ClientError::InvalidArgument).
284    #[test]
285    fn builder_returns_err_on_duplicate_call_id() {
286        let mut builder = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]);
287        builder
288            .add_call("Foo/get", json!({}), "r1")
289            .expect("first add_call must succeed");
290        let result = builder.add_call("Bar/get", json!({}), "r1"); // duplicate
291        assert!(
292            matches!(result, Err(ClientError::InvalidArgument(_))),
293            "duplicate call_id must return Err(InvalidArgument), got {result:?}"
294        );
295    }
296
297    // -----------------------------------------------------------------------
298    // Session
299    // -----------------------------------------------------------------------
300
301    /// Oracle: RFC 8620 §2.1 example Session JSON, transcribed from the RFC text.
302    /// All field names and values come from the RFC, not from the code under test.
303    #[test]
304    fn session_deserializes_rfc8620_example() {
305        // RFC 8620 §2.1 example — hand-transcribed from spec text.
306        let raw = r#"{
307            "capabilities": {
308                "urn:ietf:params:jmap:core": {
309                    "maxSizeUpload": 50000000,
310                    "maxConcurrentUpload": 8,
311                    "maxSizeRequest": 10000000,
312                    "maxConcurrentRequest": 8,
313                    "maxCallsInRequest": 32,
314                    "maxObjectsInGet": 256,
315                    "maxObjectsInSet": 128,
316                    "collationAlgorithms": [
317                        "i;ascii-numeric",
318                        "i;ascii-casemap",
319                        "i;unicode-casemap"
320                    ]
321                },
322                "urn:ietf:params:jmap:mail": {},
323                "urn:ietf:params:jmap:contacts": {},
324                "https://example.com/apis/foobar": {
325                    "maxFoosFinangled": 42
326                }
327            },
328            "accounts": {
329                "A13824": {
330                    "name": "john@example.com",
331                    "isPersonal": true,
332                    "isReadOnly": false,
333                    "accountCapabilities": {
334                        "urn:ietf:params:jmap:mail": {
335                            "maxMailboxesPerEmail": null,
336                            "maxMailboxDepth": 10
337                        },
338                        "urn:ietf:params:jmap:contacts": {}
339                    }
340                },
341                "A97813": {
342                    "name": "jane@example.com",
343                    "isPersonal": false,
344                    "isReadOnly": true,
345                    "accountCapabilities": {
346                        "urn:ietf:params:jmap:mail": {
347                            "maxMailboxesPerEmail": 1,
348                            "maxMailboxDepth": 10
349                        }
350                    }
351                }
352            },
353            "primaryAccounts": {
354                "urn:ietf:params:jmap:mail": "A13824",
355                "urn:ietf:params:jmap:contacts": "A13824"
356            },
357            "username": "john@example.com",
358            "apiUrl": "https://jmap.example.com/api/",
359            "downloadUrl": "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}",
360            "uploadUrl": "https://jmap.example.com/upload/{accountId}/",
361            "eventSourceUrl": "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
362            "state": "75128aab4b1b"
363        }"#;
364
365        let session: Session =
366            serde_json::from_str(raw).expect("RFC 8620 §2.1 example must deserialize");
367
368        // Oracle: RFC 8620 §2.1
369        assert_eq!(session.username, "john@example.com");
370        assert_eq!(session.api_url, "https://jmap.example.com/api/");
371        assert_eq!(
372            session.upload_url,
373            "https://jmap.example.com/upload/{accountId}/"
374        );
375        assert_eq!(
376            session.download_url,
377            "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
378        );
379        assert_eq!(
380            session.event_source_url,
381            "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
382        );
383        assert_eq!(session.state, "75128aab4b1b");
384
385        // Oracle: RFC 8620 §2.1 — capabilities map
386        assert!(
387            session
388                .capabilities
389                .contains_key("urn:ietf:params:jmap:core"),
390            "must have core capability"
391        );
392        assert!(
393            session
394                .capabilities
395                .contains_key("urn:ietf:params:jmap:mail"),
396            "must have mail capability"
397        );
398        assert!(
399            session
400                .capabilities
401                .contains_key("https://example.com/apis/foobar"),
402            "must have vendor capability"
403        );
404
405        // Oracle: RFC 8620 §2.1 — accounts map
406        assert!(
407            session.accounts.contains_key("A13824"),
408            "must have account A13824"
409        );
410        assert!(
411            session.accounts.contains_key("A97813"),
412            "must have account A97813"
413        );
414
415        // Oracle: RFC 8620 §2.1 — primaryAccounts
416        assert_eq!(
417            session.primary_account_id("urn:ietf:params:jmap:mail"),
418            Some("A13824")
419        );
420        assert_eq!(
421            session.primary_account_id("urn:ietf:params:jmap:contacts"),
422            Some("A13824")
423        );
424        assert_eq!(
425            session.primary_account_id("urn:ietf:params:jmap:core"),
426            None
427        );
428    }
429
430    // -----------------------------------------------------------------------
431    // AccountInfo
432    // -----------------------------------------------------------------------
433
434    /// Oracle: RFC 8620 §2.1 example — account A13824 (john@example.com).
435    /// Field names and values transcribed directly from the RFC.
436    #[test]
437    fn account_info_deserializes_rfc8620_example() {
438        // RFC 8620 §2.1 example account entry
439        let raw = r#"{
440            "name": "john@example.com",
441            "isPersonal": true,
442            "isReadOnly": false,
443            "accountCapabilities": {
444                "urn:ietf:params:jmap:mail": {
445                    "maxMailboxesPerEmail": null,
446                    "maxMailboxDepth": 10
447                },
448                "urn:ietf:params:jmap:contacts": {}
449            }
450        }"#;
451
452        let account: AccountInfo =
453            serde_json::from_str(raw).expect("RFC 8620 §2.1 AccountInfo must deserialize");
454
455        // Oracle: RFC 8620 §2 Account object fields
456        assert_eq!(account.name, "john@example.com");
457        assert!(account.is_personal, "isPersonal must be true");
458        assert!(!account.is_read_only, "isReadOnly must be false");
459        assert!(
460            account
461                .account_capabilities
462                .contains_key("urn:ietf:params:jmap:mail"),
463            "must have mail capability"
464        );
465        assert!(
466            account
467                .account_capabilities
468                .contains_key("urn:ietf:params:jmap:contacts"),
469            "must have contacts capability"
470        );
471
472        // Oracle: RFC 8620 §2.1 — read-only account (A97813 / jane@example.com)
473        let raw2 = r#"{
474            "name": "jane@example.com",
475            "isPersonal": false,
476            "isReadOnly": true,
477            "accountCapabilities": {
478                "urn:ietf:params:jmap:mail": {
479                    "maxMailboxesPerEmail": 1,
480                    "maxMailboxDepth": 10
481                }
482            }
483        }"#;
484        let account2: AccountInfo = serde_json::from_str(raw2)
485            .expect("RFC 8620 §2.1 read-only AccountInfo must deserialize");
486
487        assert_eq!(account2.name, "jane@example.com");
488        assert!(!account2.is_personal, "isPersonal must be false");
489        assert!(account2.is_read_only, "isReadOnly must be true");
490    }
491
492    // -----------------------------------------------------------------------
493    // WebSocketCapability
494    // -----------------------------------------------------------------------
495
496    /// Oracle: RFC 8887 §3 — WebSocketCapability has url and supportsPush fields.
497    /// Transcribed from the RFC 8887 capability object definition.
498    #[test]
499    fn websocket_capability_deserializes() {
500        let raw = r#"{"url": "wss://jmap.example.com/ws", "supportsPush": true}"#;
501        let cap: WebSocketCapability =
502            serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
503        assert_eq!(cap.url, "wss://jmap.example.com/ws");
504        assert!(cap.supports_push);
505    }
506
507    /// Oracle: RFC 8887 §3 — supportsPush defaults to false when absent.
508    #[test]
509    fn websocket_capability_supports_push_defaults_false() {
510        let raw = r#"{"url": "wss://jmap.example.com/ws"}"#;
511        let cap: WebSocketCapability =
512            serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
513        assert_eq!(cap.url, "wss://jmap.example.com/ws");
514        assert!(!cap.supports_push, "supportsPush must default to false");
515    }
516
517    /// Oracle: Session.websocket_capability() returns Ok(None) when key absent.
518    #[test]
519    fn session_websocket_capability_absent_returns_ok_none() {
520        let raw = r#"{
521            "capabilities": {},
522            "accounts": {},
523            "primaryAccounts": {},
524            "username": "u@example.com",
525            "apiUrl": "https://jmap.example.com/api/",
526            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
527            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
528            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
529            "state": "s1"
530        }"#;
531        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
532        let result = session.websocket_capability();
533        assert!(
534            matches!(result, Ok(None)),
535            "expected Ok(None), got {result:?}"
536        );
537    }
538
539    /// Oracle: Session.websocket_capability() returns Ok(Some) when key present and valid.
540    #[test]
541    fn session_websocket_capability_present_and_valid() {
542        let raw = r#"{
543            "capabilities": {
544                "urn:ietf:params:jmap:websocket": {
545                    "url": "wss://jmap.example.com/ws",
546                    "supportsPush": true
547                }
548            },
549            "accounts": {},
550            "primaryAccounts": {},
551            "username": "u@example.com",
552            "apiUrl": "https://jmap.example.com/api/",
553            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
554            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
555            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
556            "state": "s1"
557        }"#;
558        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
559        let ws = session
560            .websocket_capability()
561            .expect("must not error")
562            .expect("websocket capability must be present");
563        assert_eq!(ws.url, "wss://jmap.example.com/ws");
564        assert!(ws.supports_push);
565    }
566}