summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJakub Stachurski <jakub@wilkuu.nl>2026-09-19 23:43:04 +0200
committerJakub Stachurski <jakub@wilkuu.nl>2026-09-19 23:43:04 +0200
commit2b5caa2b306dc6a8c873f9ae3a2e0830cfbe5765 (patch)
tree246514bc1d0580e73c4ae6fd38be6db91d12de6d /src
parent0922d158f34d92929a485ba235a7e3a5e222153d (diff)
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
Diffstat (limited to '')
-rw-r--r--src/capabilities.rs62
-rw-r--r--src/lib.rs6
-rw-r--r--src/request.rs390
-rw-r--r--src/session.rs1
-rw-r--r--src/types.rs43
5 files changed, 491 insertions, 11 deletions
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::<CoreEchoArgs>();
+ 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<JMAPId>;
@@ -75,8 +94,14 @@ impl<T: Capability> 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<dyn Fn(&JObject) -> Box<dyn CapabilityDyn>>;
+
+pub struct CapabilityEntry {
+ pub(crate) parser: CapabilityParser,
+ pub(crate) methods: &'static MethodRegistry,
+}
+
/// Collection of CapabilityParser
-pub struct CapabilityRegistry(pub BTreeMap<String, CapabilityParser>);
+pub struct CapabilityRegistry(pub BTreeMap<String, CapabilityEntry>);
impl Default for CapabilityRegistry {
fn default() -> Self {
@@ -94,16 +119,21 @@ impl CapabilityRegistry {
/// Insert a parser
pub fn add_capability<T: 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<T> = Result<T, JMAPError>;
+
+#[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<Self, Self::Err> {
+ 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<dyn JMAPInvocationArgDyn> {
+ Box::new(Self::from_obj(obj))
+ }
+ fn into_obj(self) -> JObject;
+
+ fn from_dyn(d: Box<dyn JMAPInvocationArgDyn>) -> JMAPResult<Box<Self>> {
+ 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<dyn Any>;
+}
+
+impl<T: JMAPInvocationArg> 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<dyn Any> {
+ Box::new(self)
+ }
+}
+
+pub struct Invocation<T: JMAPInvocationArg> {
+ pub method: JMAPMethodName,
+ pub arguments: T,
+ pub method_call_id: String,
+}
+
+impl<T: JMAPInvocationArg> Invocation<T> {
+ 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<Self> {
+ 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<dyn JMAPInvocationArgDyn>,
+ 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<String>,
+ #[serde(rename = "methodCalls")]
+ method_calls: JArr<InvocationRaw>,
+ #[serde(rename = "createIds")]
+ create_ids: Option<JObj<JMAPId, JMAPId>>,
+}
+
+#[derive(Debug)]
+pub struct JMAPRequest {
+ pub using: JArr<String>,
+ pub method_calls: JArr<JMAPResult<InvocationDyn>>,
+ pub create_ids: Option<JObj<JMAPId, JMAPId>>,
+}
+
+pub type MethodArgsParser = Box<fn(&JObject) -> Box<dyn JMAPInvocationArgDyn>>;
+pub struct MethodRegistry(pub BTreeMap<JMAPMethodName, MethodArgsParser>);
+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<T: JMAPInvocationArg>(&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<Self> {
+ 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<JMAPResult<InvocationDyn>> = 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(), &reg, true);
+ assert_matches!(req_err, Err(JMAPError::UnknownCapability(_)));
+ let req_ok = JMAPRequest::from_raw(raw, &reg, 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<CoreEchoArgs> = 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<String, JUnknown>),
@@ -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<Self, Self::Err> {
+ 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<str> 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