From 2b5caa2b306dc6a8c873f9ae3a2e0830cfbe5765 Mon Sep 17 00:00:00 2001 From: Jakub Stachurski Date: Sat, 19 Sep 2026 23:43:04 +0200 Subject: Add parsing of a JMAP request Also adds: - MethodRegistry to Capability and CapabilityRegistry - Better parsing for JMAPId - Eq and PartialEq for dates - Tests for JMAPRequest - Core/Echo method - Support for Invocations --- src/capabilities.rs | 62 ++++++++- src/lib.rs | 6 + src/request.rs | 390 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/session.rs | 1 + src/types.rs | 43 +++++- 5 files changed, 491 insertions(+), 11 deletions(-) create mode 100644 src/request.rs (limited to 'src') diff --git a/src/capabilities.rs b/src/capabilities.rs index 63aa6e8..1a4aa35 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -1,8 +1,11 @@ use std::any::Any; use std::collections::BTreeMap; +use std::sync::Arc; +use lazy_static; use serde::{Deserialize, Serialize}; +use crate::request::{JMAPInvocationArg, MethodRegistry}; use crate::session::ISession; use crate::*; @@ -10,11 +13,27 @@ use crate::*; /// JMAP Core URN pub const URN_JMAP_CORE: &str = "urn:ietf:params:jmap:core"; +lazy_static::lazy_static! { + static ref JMAP_CORE_METHODS: MethodRegistry = { + let mut reg = MethodRegistry::new(); + reg.add_method::(); + reg + }; + + static ref DEFAULT_METHODS_REG: MethodRegistry = { + MethodRegistry::new() + }; +} // Capabilities {{{ +// pub trait Capability: 'static + Sized { const URN: &str; + fn get_methods() -> &'static MethodRegistry { + &DEFAULT_METHODS_REG + } + fn from_object(obj: &JObject) -> Self; fn account_cap(&self, account_id: JMAPId, obj: &JObject) -> Self; fn account(&self) -> Option; @@ -75,8 +94,14 @@ impl CapabilityDyn for T { /// A function that parses a JObject into a Capability Trait object. /// This capability will be able to implement functionality to the JMAPSession object. pub type CapabilityParser = Box Box>; + +pub struct CapabilityEntry { + pub(crate) parser: CapabilityParser, + pub(crate) methods: &'static MethodRegistry, +} + /// Collection of CapabilityParser -pub struct CapabilityRegistry(pub BTreeMap); +pub struct CapabilityRegistry(pub BTreeMap); impl Default for CapabilityRegistry { fn default() -> Self { @@ -94,16 +119,21 @@ impl CapabilityRegistry { /// Insert a parser pub fn add_capability(&mut self) { - self.0 - .insert(T::URN.to_string(), Box::new(T::from_object_dyn)); + self.0.insert( + T::URN.to_string(), + CapabilityEntry { + parser: Box::new(T::from_object_dyn), + methods: T::get_methods(), + }, + ); } /// Use the parsers to create a list of capabilities supported by the server in the session. pub fn server_capabilities(&self, session: &ISession) -> ServerCapabilities { let mut out = BTreeMap::new(); for (urn, cap) in session.capabilities.iter() { - if let Some(parser) = self.0.get(urn) { - out.insert(urn.clone(), parser(cap)); + if let Some(entry) = self.0.get(urn) { + out.insert(urn.clone(), (entry.parser)(cap)); }; } ServerCapabilities(out) @@ -192,6 +222,24 @@ impl CapabilityCollection for AccountCapabilities { // }}} /// JMAP Core capability as outlined in RFC 8620 +pub struct CoreEchoArgs { + pub obj: JObject, +} + +impl JMAPInvocationArg for CoreEchoArgs { + const CAP_URN: &str = URN_JMAP_CORE; + + const METHOD_HINT: &str = "Core/Echo"; + + fn from_obj(obj: &JObject) -> Self { + Self { obj: obj.clone() } + } + + fn into_obj(self) -> JObject { + self.obj + } +} + #[allow(non_snake_case)] #[derive(Serialize, Deserialize, Clone)] pub struct JMAPCoreCapability { @@ -216,6 +264,10 @@ impl Capability for JMAPCoreCapability { out } + fn get_methods() -> &'static MethodRegistry { + &JMAP_CORE_METHODS + } + fn account_cap(&self, account_id: JMAPId, _: &JObject) -> Self { let mut out = self.clone(); out.accountId = Some(account_id); diff --git a/src/lib.rs b/src/lib.rs index b877f9d..e2c1d10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,9 @@ pub use types::*; pub mod capabilities; /// 2 JMAP Session resource pub mod session; + +/// 3 Structured Exchange +// 3.1 - 3.2 +// 3.3 Request +pub mod request; +// 3.4 Response diff --git a/src/request.rs b/src/request.rs new file mode 100644 index 0000000..3a31d4a --- /dev/null +++ b/src/request.rs @@ -0,0 +1,390 @@ +use std::any::Any; +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt::{Debug, Display}; +use std::str::FromStr; + +use crate::capabilities::{CapabilityRegistry, URN_JMAP_CORE}; +use crate::types::*; +use serde::{Deserialize, Serialize}; + +// TODO: Move to an error module +pub type JMAPResult = Result; + +#[derive(Debug)] +pub enum JMAPError { + ParseError(String), + DowncastError(String), + UnknownCapability(String), + UnknownMethod(String), + InvalidObject(String), +} + +impl Display for JMAPError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) //TODO: Make this less bad + } +} + +impl Error for JMAPError {} + +#[derive(Clone, PartialEq, PartialOrd, Ord, Eq, Debug)] +pub struct JMAPMethodName { + pub resource: String, + pub method: String, +} + +impl JMAPMethodName { + pub fn new(resource: String, method: String) -> Self { + assert_ne!(resource.len(), 0); + assert_ne!(method.len(), 0); + Self { resource, method } + } +} + +impl FromStr for JMAPMethodName { + type Err = JMAPError; + + fn from_str(s: &str) -> Result { + let mut spl = s.split("/"); + + let resource = spl + .next() + .expect("Always at least one string expected from `split`") + .to_string(); + + let method = (match spl.next() { + Some(m) => { + if m.is_empty() { + Err(JMAPError::ParseError( + "Missing method name after '/'".to_string(), + )) + } else { + Ok(m) + } + } + None => Err(JMAPError::ParseError("Missing '/' delimiter".to_string())), + })? + .to_string(); + + if let Some(t) = spl.next() { + Err(JMAPError::ParseError(format!( + "Additional '/' found => '/{}'", + t + ))) + } else { + Ok(Self { resource, method }) + } + } +} + +impl Display for JMAPMethodName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.resource, self.method) + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct InvocationRaw(String, JObject, String); + +pub trait JMAPInvocationArg: 'static + Sized { + const CAP_URN: &str; + const METHOD_HINT: &'static str; + fn from_obj(obj: &JObject) -> Self; + fn from_obj_dyn(obj: &JObject) -> Box { + Box::new(Self::from_obj(obj)) + } + fn into_obj(self) -> JObject; + + fn from_dyn(d: Box) -> JMAPResult> { + let dyn_hint = d.get_method_hint(); + if dyn_hint != Self::METHOD_HINT { + return Err(JMAPError::DowncastError(format!( + "Incompatible arguments: {} vs. {} ", + Self::METHOD_HINT, + dyn_hint + ))); + } + + let rf = Box::leak(d); + if let Some(anyref) = rf.as_any_mut().downcast_mut() { + // Safe because the reference is leaked from another box + unsafe { Ok(Box::from_raw(anyref)) } + } else { + // Safe because the reference is leaked from another box + unsafe { + // Free the memory + let b = Box::from_raw(rf); + drop(b) + } + Err(JMAPError::DowncastError( + "T is not the correct type despite having same method hint".to_string(), + )) + } + } +} + +pub trait JMAPInvocationArgDyn: 'static { + fn get_urn(&self) -> &'static str; + fn get_method_hint(&self) -> &'static str; + fn into_obj(self) -> JObject; + + // Convert to any for downcasting, immutable + fn as_any(&self) -> &dyn Any; + + /// Convert to any for downcasting, mutable + fn as_any_mut(&mut self) -> &mut dyn Any; + + /// Convert to any for downcasting, consumes object + fn to_any(self) -> Box; +} + +impl JMAPInvocationArgDyn for T { + fn get_urn(&self) -> &'static str { + T::CAP_URN + } + fn get_method_hint(&self) -> &'static str { + T::METHOD_HINT + } + fn into_obj(self) -> JObject { + self.into_obj() + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn to_any(self) -> Box { + Box::new(self) + } +} + +pub struct Invocation { + pub method: JMAPMethodName, + pub arguments: T, + pub method_call_id: String, +} + +impl Invocation { + pub fn to_dyn(self) -> InvocationDyn { + InvocationDyn { + method: self.method, + arguments: Box::new(self.arguments), + method_call_id: self.method_call_id, + } + } + pub fn from_dyn(d: InvocationDyn) -> JMAPResult { + Ok(Self { + method: d.method, + arguments: *T::from_dyn(d.arguments)?, + method_call_id: d.method_call_id, + }) + } +} + +pub struct InvocationDyn { + method: JMAPMethodName, + arguments: Box, + method_call_id: String, +} + +impl Debug for InvocationDyn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "InvocationDyn({}, *, {})", + self.method, self.method_call_id + ) + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct JMAPRequestRaw { + using: JArr, + #[serde(rename = "methodCalls")] + method_calls: JArr, + #[serde(rename = "createIds")] + create_ids: Option>, +} + +#[derive(Debug)] +pub struct JMAPRequest { + pub using: JArr, + pub method_calls: JArr>, + pub create_ids: Option>, +} + +pub type MethodArgsParser = Box Box>; +pub struct MethodRegistry(pub BTreeMap); +impl Default for MethodRegistry { + fn default() -> Self { + Self::new() + } +} + +impl MethodRegistry { + /// Create a collection of parsers + pub fn new() -> Self { + Self(BTreeMap::new()) + } + + /// Insert a parser + pub fn add_method(&mut self) { + self.0.insert( + T::METHOD_HINT + .parse() + .expect("METHOD_HINT needs to be a valid Method Name"), + Box::new(T::from_obj_dyn), + ); + } +} + +impl JMAPRequest { + pub fn from_raw( + raw: JMAPRequestRaw, + registry: &CapabilityRegistry, + fail_on_unknown_capability: bool, + ) -> JMAPResult { + let using = raw.using; + let create_ids = raw.create_ids; + let mut method_registries = Vec::with_capacity(using.len()); + let mut encountered_urns = Vec::with_capacity(using.len()); + let mut has_core = false; + + for cap_urn in using.iter() { + if cap_urn == URN_JMAP_CORE { + has_core = true; + } + if encountered_urns.contains(&cap_urn) { + return Err(JMAPError::InvalidObject(format!( + "Dupplicate capability {}", + cap_urn + ))); + } + encountered_urns.push(cap_urn); + if let Some(method_reg) = registry.0.get(cap_urn) { + method_registries.push(&method_reg.methods); + } else if fail_on_unknown_capability { + return Err(JMAPError::UnknownCapability(cap_urn.clone())); + } + } + + if !has_core { + return Err(JMAPError::InvalidObject( + "Missing core capability".to_string(), + )); + } + let method_calls: Vec> = raw + .method_calls + .into_iter() + .map(|method_call| { + let mut method_parser_opt: Option<&MethodArgsParser> = None; + match method_call.0.parse() { + Ok(method_name) => { + for reg in method_registries.iter() { + if let Some(parser) = reg.0.get(&method_name) { + method_parser_opt = Some(parser); + break; + } + } + if let Some(method_parser) = method_parser_opt { + let method_args = method_parser(&method_call.1); + Ok(InvocationDyn { + method: method_name, + arguments: method_args, + method_call_id: method_call.2, + }) + } else { + Err(JMAPError::UnknownMethod(method_name.to_string())) + } + } + Err(err) => Err(err), + } + }) + .collect(); + + Ok(Self { + using, + method_calls, + create_ids, + }) + } +} + +#[cfg(test)] +mod test { + use jiff::fmt::rfc2822::parse; + + use super::{JMAPRequest, JMAPRequestRaw}; + use crate::capabilities::{CapabilityRegistry, CoreEchoArgs, URN_JMAP_CORE}; + use crate::request::{Invocation, InvocationDyn, JMAPError}; + use crate::{JMAPId, JUnknown}; + use std::assert_matches; + use std::str::FromStr; + + #[test] + fn test_jmap_request_parsing() { + let req = r#"{ + "using": ["urn:ietf:params:jmap:core", "something_unknown"], + "methodCalls": [ + [ + "Core/Echo", { + "hello": "Hello world!" + }, + "a" + ], + [ + "Unknown/Get", { + "something": "else" + }, + "b" + ] + ], + "createIds": { + "i1": "id1" + } + }"#; + + let reg = CapabilityRegistry::default(); + let raw: JMAPRequestRaw = serde_json::from_str(req).unwrap(); + let req_err = JMAPRequest::from_raw(raw.clone(), ®, true); + assert_matches!(req_err, Err(JMAPError::UnknownCapability(_))); + let req_ok = JMAPRequest::from_raw(raw, ®, false); + assert_matches!( + req_ok, + Ok(JMAPRequest { + using: _, + method_calls: _, + create_ids: _ + }) + ); + let mut req_real = req_ok.expect("We tested this before"); + + assert_eq!( + req_real + .create_ids + .expect("We specified this in the test") + .get(&("i1".parse().unwrap())) + .expect("i1 Should be included") + .clone(), + JMAPId::from_str("id1").unwrap() + ); + + assert_eq!(req_real.using, vec![URN_JMAP_CORE, "something_unknown"]); + let unknown = req_real.method_calls.get(1).unwrap(); + assert_matches!(unknown, Err(JMAPError::UnknownMethod(_))); + + let echo = req_real.method_calls.remove(0).unwrap(); + + let invocation: Invocation = Invocation::from_dyn(echo).unwrap(); + + assert_eq!( + invocation.arguments.obj.get("hello"), + Some(&JUnknown::String("Hello world!".to_string())) + ); + } +} diff --git a/src/session.rs b/src/session.rs index b9e508e..2c614ad 100644 --- a/src/session.rs +++ b/src/session.rs @@ -155,6 +155,7 @@ impl JMAPSession { // Appendix A: tests {{{ #[cfg(test)] mod test { + use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/src/types.rs b/src/types.rs index 0060e71..d2713da 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,16 +1,17 @@ use jiff::Timestamp; use jiff::civil::DateTime; -use serde::de::{DeserializeOwned, Error}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; -use std::any::Any; use std::collections::BTreeMap; -use std::collections::btree_map::{Iter, Keys}; use std::hash::Hash; +use std::str::FromStr; + +use crate::request::JMAPError; // Types // *, unknown value -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(untagged)] pub enum JUnknown { Object(BTreeMap), @@ -152,6 +153,36 @@ where /// TODO: Add the restrictions above #[derive(Serialize, Deserialize, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Debug)] pub struct JMAPId(pub String); + +impl FromStr for JMAPId { + type Err = JMAPError; + fn from_str(s: &str) -> Result { + if s.len() > 255 || s.len() < 1 { + return Err(JMAPError::ParseError("Invalid ID size".to_string())); + } + + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err(JMAPError::ParseError(format!( + "ID {} contains invalid character {}", + s, c + ))); + } + + out.push(c); + } + + Ok(Self(out)) + } +} + +impl AsRef for JMAPId { + fn as_ref(&self) -> &str { + return &self.0; + } +} + //}}} // 1.3 Int and UnsignedInt {{{ pub type JInt = i64; @@ -164,7 +195,7 @@ pub type JUint = u64; /// TODO: To ensure a normalized form, the time-setfrac MUST always be ommitted if zero, and any /// letters in the string (eg. "T" and "Z") must be uppercase. -#[derive(Deserialize, Serialize, Clone, Debug)] +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, PartialOrd)] pub struct JDate(DateTime); impl JDate { /// Convert from a JUTCDate type to system time @@ -193,7 +224,7 @@ impl JDate { } /// Where UTCDate is given as a type, it means a Date where the time-offset component MUST be Z (i.e., it must be in UTC time). For example, 2014-10-30T06:12:00Z -#[derive(Deserialize, Serialize, Clone, Debug)] +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, PartialOrd)] pub struct JUTCDate(Timestamp); impl JUTCDate { /// Convert from a JDate type -- cgit v1.3.1