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/request.rs | 390 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 src/request.rs (limited to 'src/request.rs') 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())) + ); + } +} -- cgit v1.3.1