/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use indexmap::IndexSet; use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; use crate::jmap::error::JmapError; use crate::jmap::http::{HttpClient, format_retry_wait}; use crate::jmap::retry::{Disposition, MethodCallKind, backoff_delay, jmap_method_disposition}; use crate::jmap::session::Limits; use crate::jmap::wire::JmapId; use crate::logging::Logger; pub const URN_CORE: &str = "urn:ietf:params:jmap:core"; /// The capability inbuxa advertises for its registry types (`x:Account`, /// `x:Domain` and the rest of the `x:` objects). pub const URN_INBUXA_REGISTRY: &str = "urn:inbuxa:jmap:registry"; /// The same registry as Stalwart advertises it, so a Stalwart server can be /// migrated from. pub const URN_STALWART_REGISTRY: &str = "urn:stalwart:jmap"; /// The capability a method needs. Registry (`x:`) methods have no fixed /// capability: the connected server names it, and `registry` carries that /// name (see `Session::registry_urn`). Without one they cannot be sent. pub fn using_urn(method: &str, registry: Option<&'static str>) -> Result<&'static str, JmapError> { let prefix = method.split('/').next().unwrap_or(method); if prefix.starts_with("x:") { return registry.ok_or_else(|| { JmapError::MissingCapability(format!( "{method} needs the server's registry capability, but it advertises neither \ {URN_INBUXA_REGISTRY} nor {URN_STALWART_REGISTRY}" )) }); } Ok(match prefix { "Mailbox" | "Email" => "urn:ietf:params:jmap:mail", "Identity" => "urn:ietf:params:jmap:submission", "SieveScript" => "urn:ietf:params:jmap:sieve", "AddressBook" | "ContactCard" => "urn:ietf:params:jmap:contacts", "Calendar" | "CalendarEvent" | "ParticipantIdentity" => "urn:ietf:params:jmap:calendars", "FileNode" => "urn:ietf:params:jmap:filenode", "Principal" => "urn:ietf:params:jmap:principals", _ => URN_CORE, }) } #[derive(Debug, Clone)] pub struct MethodCall { pub name: String, pub args: Value, pub call_id: String, } #[derive(Debug, Clone, Default)] pub struct Request { calls: Vec, registry: Option<&'static str>, } impl Request { pub fn new() -> Request { Request::default() } /// The registry capability to use for any `x:` call in this request, /// normally `session.registry_urn()`. pub fn registry(&mut self, urn: Option<&'static str>) -> &mut Request { self.registry = urn; self } pub fn call( &mut self, name: impl Into, args: Value, call_id: impl Into, ) -> &mut Request { self.calls.push(MethodCall { name: name.into(), args, call_id: call_id.into(), }); self } pub fn len(&self) -> usize { self.calls.len() } pub fn is_empty(&self) -> bool { self.calls.is_empty() } pub fn using(&self) -> Result, JmapError> { let mut set: IndexSet = IndexSet::new(); set.insert(URN_CORE.to_owned()); for c in &self.calls { set.insert(using_urn(&c.name, self.registry)?.to_owned()); } Ok(set.into_iter().collect()) } fn envelope(&self) -> Result { let method_calls: Vec = self .calls .iter() .map(|c| json!([c.name, c.args, c.call_id])) .collect(); Ok(json!({ "using": self.using()?, "methodCalls": method_calls })) } pub fn fits(&self, limits: &Limits) -> Result<(), JmapError> { if self.calls.len() as u64 > limits.max_calls_in_request { return Err(JmapError::RequestTooLarge); } let size = serde_json::to_vec(&self.envelope()?)?.len() as u64; if size > limits.max_size_request { return Err(JmapError::RequestTooLarge); } Ok(()) } pub fn send(&self, client: &HttpClient, api_url: &str) -> Result { let value = client.post_json(api_url, &self.envelope()?)?; Response::parse(value) } } #[derive(Debug)] pub struct Response { pub method_responses: Vec, } impl Response { fn parse(value: Value) -> Result { let arr = value .get("methodResponses") .and_then(Value::as_array) .ok_or_else(|| JmapError::malformed("response has no methodResponses array"))?; let mut out = Vec::with_capacity(arr.len()); for entry in arr { let triple = entry.as_array().filter(|t| t.len() == 3).ok_or_else(|| { JmapError::malformed("methodResponse is not a [name,args,id] triple") })?; let name = triple[0] .as_str() .ok_or_else(|| JmapError::malformed("methodResponse name is not a string"))? .to_owned(); let call_id = triple[2] .as_str() .ok_or_else(|| JmapError::malformed("methodResponse callId is not a string"))? .to_owned(); out.push(MethodCall { name, args: triple[1].clone(), call_id, }); } Ok(Response { method_responses: out, }) } pub fn first(&self) -> Result<&MethodCall, JmapError> { self.method_responses .first() .ok_or_else(|| JmapError::malformed("empty methodResponses")) } pub fn by_call_id(&self, call_id: &str) -> Result<&MethodCall, JmapError> { self.method_responses .iter() .find(|m| m.call_id == call_id) .ok_or_else(|| JmapError::malformed(format!("no response for callId {call_id}"))) } } pub fn check_method_error(mr: &MethodCall) -> Result<(), JmapError> { if mr.name != "error" { return Ok(()); } let error_type = mr .args .get("type") .and_then(Value::as_str) .unwrap_or("unknown") .to_owned(); if error_type == "anchorNotFound" { return Err(JmapError::AnchorNotFound); } if error_type == "cannotCalculateChanges" { return Err(JmapError::CannotCalculateChanges); } if error_type == "unknownMethod" { return Err(JmapError::UnknownMethod); } let description = mr .args .get("description") .and_then(Value::as_str) .map(str::to_owned); Err(JmapError::Method { call_id: mr.call_id.clone(), error_type, description, }) } fn method_error_is_retryable(err: &JmapError, kind: MethodCallKind) -> bool { match err { JmapError::Method { error_type, .. } => { jmap_method_disposition(error_type, kind) == Disposition::Retryable } _ => false, } } pub fn retry_method_call( client: &HttpClient, kind: MethodCallKind, logger: &Logger, mut attempt: impl FnMut() -> Result, ) -> Result { let policy = *client.retry(); let mut tries = 0u32; loop { let err = match attempt() { Ok(value) => return Ok(value), Err(e) => e, }; if tries >= policy.max_retries || !method_error_is_retryable(&err, kind) { return Err(err); } tries += 1; let delay = backoff_delay(&policy, tries); client.rate_limit().cooldown().arm(delay); logger.warn(&format!( "{err}; retrying in {} ({tries}/{})", format_retry_wait(delay), policy.max_retries )); std::thread::sleep(delay); } } fn ids_array(ids: &[JmapId]) -> Value { Value::Array(ids.iter().map(|i| Value::String(i.0.clone())).collect()) } pub fn query_all_ids( client: &HttpClient, api_url: &str, account_id: &str, type_name: &str, limits: &Limits, ) -> Result, JmapError> { let mut restarts = 0u32; loop { match query_pages(client, api_url, account_id, type_name, limits) { Ok(ids) => return Ok(ids), Err(JmapError::AnchorNotFound) if restarts < 2 => { restarts += 1; } Err(e) => return Err(e), } } } fn query_pages( client: &HttpClient, api_url: &str, account_id: &str, type_name: &str, limits: &Limits, ) -> Result, JmapError> { let limit = limits.max_objects_in_get.max(1); let mut collected: Vec = Vec::new(); let mut page_size: Option = None; let mut anchor: Option = None; loop { let mut args = Map::new(); args.insert("accountId".to_owned(), Value::String(account_id.to_owned())); args.insert("limit".to_owned(), Value::from(limit)); if let Some(a) = &anchor { args.insert("anchor".to_owned(), Value::String(a.clone())); args.insert("anchorOffset".to_owned(), Value::from(1)); } let mut req = Request::new(); req.call(format!("{type_name}/query"), Value::Object(args), "q"); let resp = req.send(client, api_url)?; let mr = resp.first()?; check_method_error(mr)?; let ids = mr .args .get("ids") .and_then(Value::as_array) .ok_or_else(|| JmapError::malformed("query response has no ids array"))?; let this_len = ids.len(); for v in ids { let s = v .as_str() .ok_or_else(|| JmapError::malformed("query id is not a string"))?; collected.push(JmapId(s.to_owned())); } match page_size { None => { if this_len == 0 { break; } page_size = Some(this_len); } Some(full) => { if this_len == 0 || this_len < full { break; } } } let last = collected .last() .map(|i| i.0.clone()) .ok_or_else(|| JmapError::malformed("non-empty page yielded no anchor"))?; anchor = Some(last); } Ok(collected) } #[derive(Debug)] pub struct GetResult { pub list: Vec, pub not_found: Vec, pub state: Option, } impl Default for GetResult { fn default() -> Self { GetResult { list: Vec::new(), not_found: Vec::new(), state: None, } } } struct GetCtx<'a> { client: &'a HttpClient, api_url: &'a str, account_id: &'a str, type_name: &'a str, properties: Option<&'a [&'a str]>, limits: &'a Limits, } pub fn get_objects( client: &HttpClient, api_url: &str, account_id: &str, type_name: &str, ids: &[JmapId], properties: Option<&[&str]>, limits: &Limits, ) -> Result, JmapError> { let ctx = GetCtx { client, api_url, account_id, type_name, properties, limits, }; let mut out = GetResult::default(); let chunk = limits.max_objects_in_get.max(1) as usize; let mut start = 0; while start < ids.len() { let end = (start + chunk).min(ids.len()); get_chunk(&ctx, &ids[start..end], &mut out)?; start = end; } Ok(out) } fn get_chunk( ctx: &GetCtx<'_>, ids: &[JmapId], out: &mut GetResult, ) -> Result<(), JmapError> { let mut args = Map::new(); args.insert( "accountId".to_owned(), Value::String(ctx.account_id.to_owned()), ); args.insert("ids".to_owned(), ids_array(ids)); if let Some(props) = ctx.properties { let mut p: Vec = props .iter() .map(|s| Value::String((*s).to_owned())) .collect(); p.push(Value::String("id".to_owned())); args.insert("properties".to_owned(), Value::Array(p)); } let mut req = Request::new(); req.call(format!("{}/get", ctx.type_name), Value::Object(args), "g"); let send_result: Result<(), JmapError> = if req.fits(ctx.limits).is_err() { Err(JmapError::RequestTooLarge) } else { match req.send(ctx.client, ctx.api_url) { Ok(resp) => { let mr = resp.first()?; check_method_error(mr)?; decode_get(mr, out) } Err(e) => Err(e), } }; match send_result { Ok(()) => Ok(()), Err(JmapError::RequestTooLarge) => { if ids.len() <= 1 { return Err(JmapError::SingleObjectTooLarge(format!( "{}/get of a single id exceeds maxSizeRequest", ctx.type_name ))); } let mid = ids.len() / 2; get_chunk(ctx, &ids[..mid], out)?; get_chunk(ctx, &ids[mid..], out) } Err(e) => Err(e), } } pub fn get_all( client: &HttpClient, api_url: &str, account_id: &str, type_name: &str, ) -> Result, JmapError> { let mut args = Map::new(); args.insert("accountId".to_owned(), Value::String(account_id.to_owned())); args.insert("ids".to_owned(), Value::Null); let mut req = Request::new(); req.call(format!("{type_name}/get"), Value::Object(args), "g"); let resp = req.send(client, api_url)?; let mr = resp.first()?; check_method_error(mr)?; let mut out = GetResult::default(); decode_get(mr, &mut out)?; Ok(out) } pub fn get_state( client: &HttpClient, api_url: &str, account_id: &str, type_name: &str, ) -> Result, JmapError> { let mut args = Map::new(); args.insert("accountId".to_owned(), Value::String(account_id.to_owned())); args.insert("ids".to_owned(), Value::Array(Vec::new())); let mut req = Request::new(); req.call(format!("{type_name}/get"), Value::Object(args), "g"); let resp = req.send(client, api_url)?; let mr = resp.first()?; check_method_error(mr)?; let mut out: GetResult = GetResult::default(); decode_get(mr, &mut out)?; Ok(out.state) } #[derive(Debug, Default)] pub struct ChangesResult { pub created: Vec, pub updated: Vec, pub destroyed: Vec, pub new_state: String, } pub fn get_changes( client: &HttpClient, api_url: &str, account_id: &str, type_name: &str, since_state: &str, limits: &Limits, ) -> Result { let mut out = ChangesResult::default(); let mut since = since_state.to_owned(); loop { let mut args = Map::new(); args.insert("accountId".to_owned(), Value::String(account_id.to_owned())); args.insert("sinceState".to_owned(), Value::String(since.clone())); args.insert( "maxChanges".to_owned(), Value::from(limits.max_objects_in_get.max(1)), ); let mut req = Request::new(); req.call(format!("{type_name}/changes"), Value::Object(args), "c"); let resp = req.send(client, api_url)?; let mr = resp.first()?; check_method_error(mr)?; let new_state = mr .args .get("newState") .and_then(Value::as_str) .ok_or_else(|| JmapError::malformed("changes response has no newState"))? .to_owned(); append_id_array(&mr.args, "created", &mut out.created); append_id_array(&mr.args, "updated", &mut out.updated); append_id_array(&mr.args, "destroyed", &mut out.destroyed); let has_more = mr .args .get("hasMoreChanges") .and_then(Value::as_bool) .unwrap_or(false); out.new_state = new_state.clone(); if !has_more || since == new_state { break; } since = new_state; } dedup_ids(&mut out.created); dedup_ids(&mut out.updated); dedup_ids(&mut out.destroyed); Ok(out) } fn append_id_array(args: &Value, key: &str, out: &mut Vec) { if let Some(arr) = args.get(key).and_then(Value::as_array) { for v in arr { if let Some(s) = v.as_str() { out.push(JmapId(s.to_owned())); } } } } fn dedup_ids(ids: &mut Vec) { let mut seen = IndexSet::new(); ids.retain(|id| seen.insert(id.0.clone())); } fn decode_get( mr: &MethodCall, out: &mut GetResult, ) -> Result<(), JmapError> { let list = mr .args .get("list") .and_then(Value::as_array) .ok_or_else(|| JmapError::malformed("get response has no list array"))?; for item in list { out.list.push(serde_json::from_value(item.clone())?); } if let Some(nf) = mr.args.get("notFound").and_then(Value::as_array) { for v in nf { if let Some(s) = v.as_str() { out.not_found.push(JmapId(s.to_owned())); } } } if let Some(s) = mr.args.get("state").and_then(Value::as_str) { out.state = Some(s.to_owned()); } Ok(()) } #[derive(Debug, Default)] pub struct SetOutcome { pub created: Vec<(String, Value)>, pub updated: Vec, pub destroyed: Vec, pub not_created: Vec<(String, Value)>, pub not_updated: Vec<(String, Value)>, pub not_destroyed: Vec<(String, Value)>, } impl SetOutcome { fn absorb(&mut self, mut other: SetOutcome) { self.created.append(&mut other.created); self.updated.append(&mut other.updated); self.destroyed.append(&mut other.destroyed); self.not_created.append(&mut other.not_created); self.not_updated.append(&mut other.not_updated); self.not_destroyed.append(&mut other.not_destroyed); } } #[derive(Debug, Default)] pub struct SetRequest<'a> { pub create: Option, pub update: Option, pub destroy: Option, pub extra_args: &'a [(&'a str, Value)], } #[derive(Debug, Clone, Copy)] enum Section { Create, Update, Destroy, } fn collect_items(value: Option<&Value>, section: Section) -> Vec<(Section, String, Value)> { match (section, value) { (Section::Destroy, Some(Value::Array(ids))) => ids .iter() .filter_map(|v| v.as_str().map(|s| (section, s.to_owned(), Value::Null))) .collect(), (_, Some(Value::Object(m))) => m .iter() .map(|(k, v)| (section, k.clone(), v.clone())) .collect(), _ => Vec::new(), } } pub fn set_call( client: &HttpClient, api_url: &str, account_id: &str, type_name: &str, body: SetRequest<'_>, limits: &Limits, ) -> Result { let mut items = collect_items(body.create.as_ref(), Section::Create); items.extend(collect_items(body.update.as_ref(), Section::Update)); items.extend(collect_items(body.destroy.as_ref(), Section::Destroy)); if items.is_empty() { return set_send( client, api_url, account_id, type_name, &[], body.extra_args, limits, ); } let chunk = limits.max_objects_in_set.max(1) as usize; let mut outcome = SetOutcome::default(); let mut start = 0; while start < items.len() { let end = (start + chunk).min(items.len()); let part = set_send( client, api_url, account_id, type_name, &items[start..end], body.extra_args, limits, )?; outcome.absorb(part); start = end; } Ok(outcome) } fn set_send( client: &HttpClient, api_url: &str, account_id: &str, type_name: &str, items: &[(Section, String, Value)], extra_args: &[(&str, Value)], limits: &Limits, ) -> Result { let mut args = Map::new(); args.insert("accountId".to_owned(), Value::String(account_id.to_owned())); let mut create = Map::new(); let mut update = Map::new(); let mut destroy: Vec = Vec::new(); for (section, key, val) in items { match section { Section::Create => { create.insert(key.clone(), val.clone()); } Section::Update => { update.insert(key.clone(), val.clone()); } Section::Destroy => destroy.push(Value::String(key.clone())), } } if !create.is_empty() { args.insert("create".to_owned(), Value::Object(create)); } if !update.is_empty() { args.insert("update".to_owned(), Value::Object(update)); } if !destroy.is_empty() { args.insert("destroy".to_owned(), Value::Array(destroy)); } for (k, v) in extra_args { args.insert((*k).to_owned(), v.clone()); } let mut req = Request::new(); req.call(format!("{type_name}/set"), Value::Object(args), "s"); let oversize = req.fits(limits).is_err(); let send_result = if oversize { Err(JmapError::RequestTooLarge) } else { req.send(client, api_url).and_then(|resp| { let mr = resp.first()?; check_method_error(mr)?; Ok(decode_set(mr)) }) }; match send_result { Ok(outcome) => Ok(outcome), Err(JmapError::RequestTooLarge) => { if items.len() <= 1 { return Err(JmapError::SingleObjectTooLarge(format!( "{type_name}/set of a single object exceeds maxSizeRequest" ))); } let mid = items.len() / 2; let mut left = set_send( client, api_url, account_id, type_name, &items[..mid], extra_args, limits, )?; let right = set_send( client, api_url, account_id, type_name, &items[mid..], extra_args, limits, )?; left.absorb(right); Ok(left) } Err(e) => Err(e), } } fn pairs(args: &Value, key: &str) -> Vec<(String, Value)> { args.get(key) .and_then(Value::as_object) .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default() } fn keys(args: &Value, key: &str) -> Vec { args.get(key) .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(|v| v.as_str().map(str::to_owned)) .collect() }) .unwrap_or_default() } fn decode_set(mr: &MethodCall) -> SetOutcome { SetOutcome { created: pairs(&mr.args, "created"), updated: mr .args .get("updated") .and_then(Value::as_object) .map(|m| m.keys().cloned().collect()) .unwrap_or_default(), destroyed: keys(&mr.args, "destroyed"), not_created: pairs(&mr.args, "notCreated"), not_updated: pairs(&mr.args, "notUpdated"), not_destroyed: pairs(&mr.args, "notDestroyed"), } } #[cfg(test)] mod tests { use std::cell::Cell; use super::*; use crate::error::Error; use crate::jmap::http::{Auth, RetryPolicy}; fn test_client(max_retries: u32) -> HttpClient { HttpClient::new( Auth::Bearer { token: "t".to_owned(), }, RetryPolicy::new(max_retries), false, ) } fn method_error(error_type: &str) -> JmapError { JmapError::Method { call_id: "i".to_owned(), error_type: error_type.to_owned(), description: None, } } fn drive( max_retries: u32, outcome: impl Fn(u32) -> Result<&'static str, JmapError>, ) -> (Result<&'static str, JmapError>, u32) { let client = test_client(max_retries); let calls = Cell::new(0u32); let result = retry_method_call( &client, MethodCallKind::SingleObjectWrite, &Logger::from_flags(true, 0), || { calls.set(calls.get() + 1); outcome(calls.get()) }, ); (result, calls.get()) } #[test] fn transient_method_error_is_retried_until_it_succeeds() { let (result, calls) = drive(3, |n| { if n < 3 { Err(method_error("serverUnavailable")) } else { Ok("created") } }); assert_eq!(result.unwrap(), "created"); assert_eq!(calls, 3); } #[test] fn exhausting_the_budget_returns_the_original_method_error() { let (result, calls) = drive(2, |_| Err(method_error("serverUnavailable"))); assert_eq!(calls, 3, "one initial attempt plus max_retries"); let err = result.unwrap_err(); assert!( matches!(&err, JmapError::Method { error_type, .. } if error_type == "serverUnavailable"), "the caller must still see the method error, not RetriesExhausted: {err}" ); assert!( !Error::from(err).aborts_run(), "an exhausted method retry stays a per-unit failure" ); } #[test] fn permanent_method_error_consumes_no_attempt() { let (result, calls) = drive(5, |_| Err(method_error("invalidArguments"))); assert!(result.is_err()); assert_eq!(calls, 1); } #[test] fn partial_fail_is_not_retried_for_a_write() { let (result, calls) = drive(5, |_| Err(method_error("serverPartialFail"))); assert!(result.is_err()); assert_eq!(calls, 1, "retrying a partial write could duplicate it"); } #[test] fn non_method_errors_are_left_to_the_http_layer() { let (result, calls) = drive(5, |_| Err(JmapError::Transport("reset".to_owned()))); assert!(result.is_err()); assert_eq!(calls, 1); } #[test] fn using_union_includes_core_and_dedups() { let mut r = Request::new(); r.call("Mailbox/get", json!({}), "a"); r.call("Email/query", json!({}), "b"); let u = r.using().unwrap(); assert!(u.contains(&URN_CORE.to_owned())); assert!(u.contains(&"urn:ietf:params:jmap:mail".to_owned())); assert_eq!(u.iter().filter(|x| x.as_str() == URN_CORE).count(), 1); } #[test] fn using_maps_standard_types() { assert_eq!( using_urn("Principal/query", None).unwrap(), "urn:ietf:params:jmap:principals" ); assert_eq!( using_urn("CalendarEvent/set", None).unwrap(), "urn:ietf:params:jmap:calendars" ); assert_eq!( using_urn("Identity/get", None).unwrap(), "urn:ietf:params:jmap:submission" ); } #[test] fn registry_calls_use_the_inbuxa_capability() { let mut r = Request::new(); r.registry(Some(URN_INBUXA_REGISTRY)); r.call("x:Account/set", json!({}), "a"); r.call("x:Domain/get", json!({}), "b"); assert_eq!(r.using().unwrap(), vec![URN_CORE, URN_INBUXA_REGISTRY]); } #[test] fn registry_calls_use_the_stalwart_capability_from_a_stalwart_source() { let mut r = Request::new(); r.registry(Some(URN_STALWART_REGISTRY)); r.call("x:Account/get", json!({}), "a"); assert_eq!(r.using().unwrap(), vec![URN_CORE, URN_STALWART_REGISTRY]); } #[test] fn registry_calls_without_a_registry_capability_fail_clearly() { let mut r = Request::new(); r.call("Mailbox/get", json!({}), "a"); r.call("x:Domain/set", json!({}), "b"); let err = r.using().unwrap_err(); let msg = err.to_string(); assert!(matches!(err, JmapError::MissingCapability(_))); assert!(msg.contains("x:Domain/set"), "{msg}"); assert!( msg.contains(URN_INBUXA_REGISTRY) && msg.contains(URN_STALWART_REGISTRY), "{msg}" ); } #[test] fn requests_without_registry_calls_need_no_registry() { let mut r = Request::new(); r.call("Email/get", json!({}), "a"); assert!(r.using().is_ok()); } #[test] fn fits_rejects_too_many_calls() { let limits = Limits { max_objects_in_get: 10, max_objects_in_set: 10, max_calls_in_request: 2, max_concurrent_requests: 4, max_concurrent_upload: 4, max_size_request: 10_000_000, max_size_upload: 10_000_000, }; let mut r = Request::new(); r.call("Mailbox/get", json!({}), "a"); r.call("Mailbox/get", json!({}), "b"); r.call("Mailbox/get", json!({}), "c"); assert!(matches!(r.fits(&limits), Err(JmapError::RequestTooLarge))); } #[test] fn parse_response_and_method_error() { let v = json!({ "methodResponses": [ ["Mailbox/get", {"list": [], "notFound": []}, "g"], ["error", {"type": "invalidArguments"}, "x"] ] }); let r = Response::parse(v).unwrap(); assert!(check_method_error(r.by_call_id("g").unwrap()).is_ok()); let err = check_method_error(r.by_call_id("x").unwrap()).unwrap_err(); assert!(matches!(err, JmapError::Method { .. })); } #[test] fn anchor_not_found_is_classified() { let v = json!({ "methodResponses": [["error", {"type": "anchorNotFound"}, "q"]] }); let r = Response::parse(v).unwrap(); let err = check_method_error(r.first().unwrap()).unwrap_err(); assert!(matches!(err, JmapError::AnchorNotFound)); } }