1use 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#[derive(Debug)]
31pub struct JmapRequestBuilder {
32 using: Vec<String>,
33 method_calls: Vec<Invocation>,
34 call_ids: HashSet<String>,
35}
36
37impl JmapRequestBuilder {
38 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 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 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#[non_exhaustive]
99#[derive(Debug, Clone, PartialEq, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct Session {
102 pub capabilities: HashMap<String, serde_json::Value>,
107
108 pub accounts: HashMap<String, AccountInfo>,
110
111 pub primary_accounts: HashMap<String, String>,
113
114 pub username: String,
116
117 pub api_url: String,
119
120 pub download_url: String,
125
126 pub upload_url: String,
130
131 pub event_source_url: String,
136
137 pub state: State,
142}
143
144impl Session {
145 pub fn primary_account_id(&self, capability: &str) -> Option<&str> {
149 self.primary_accounts.get(capability).map(String::as_str)
150 }
151
152 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#[non_exhaustive]
174#[derive(Debug, Clone, PartialEq, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct AccountInfo {
177 pub name: String,
179
180 pub is_personal: bool,
182
183 pub is_read_only: bool,
185
186 pub account_capabilities: HashMap<String, serde_json::Value>,
191}
192
193#[non_exhaustive]
202#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct WebSocketCapability {
205 pub url: String,
207
208 #[serde(default)]
210 pub supports_push: bool,
211}
212
213#[cfg(test)]
218mod tests {
219 use super::*;
220 use serde_json::json;
221
222 #[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 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 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 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 #[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 #[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"); assert!(
292 matches!(result, Err(ClientError::InvalidArgument(_))),
293 "duplicate call_id must return Err(InvalidArgument), got {result:?}"
294 );
295 }
296
297 #[test]
304 fn session_deserializes_rfc8620_example() {
305 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 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 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 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 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 #[test]
437 fn account_info_deserializes_rfc8620_example() {
438 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 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 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 #[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 #[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 #[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 #[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}