jmap: add RequestUrlTemplate type

Add type that preserves both text (String) and parsed Url value for a
Url template.

Also add a test to catch regressions.

Closes #403 (JMAP: message body fetching broken on v0.8.5)

Fixes: 51e3f163d4 ("melib/jmap: Use Url instead of String in deserializing")
Resolves: https://git.meli-email.org/meli/meli/issues/403
Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
pull/405/head
Manos Pitsidianakis 4 months ago
parent f7838b1ddf
commit f2b59a7633
No known key found for this signature in database
GPG Key ID: 7729C7707F7E09D0

@ -774,40 +774,87 @@ impl std::fmt::Display for SetError {
}
}
#[derive(Clone, Debug)]
pub struct RequestUrlTemplate {
pub text: String,
pub url: Url,
}
impl Serialize for RequestUrlTemplate {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.text)
}
}
impl<'de> ::serde::de::Deserialize<'de> for RequestUrlTemplate {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: ::serde::de::Deserializer<'de>,
{
use serde::de::{Error, Unexpected, Visitor};
struct _Visitor;
impl<'de> Visitor<'de> for _Visitor {
type Value = RequestUrlTemplate;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string representing an URL")
}
fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
where
E: Error,
{
let url = Url::parse(s).map_err(|err| {
let err_s = format!("{}", err);
Error::invalid_value(Unexpected::Str(s), &err_s.as_str())
})?;
let text = s.to_string();
Ok(RequestUrlTemplate { text, url })
}
}
deserializer.deserialize_str(_Visitor)
}
}
pub fn download_request_format(
download_url: &Url,
download_url: &RequestUrlTemplate,
account_id: &Id<Account>,
blob_id: &Id<BlobObject>,
name: Option<String>,
) -> Result<Url> {
// https://jmap.fastmail.com/download/{accountId}/{blobId}/{name}
let mut ret = String::with_capacity(
download_url.as_str().len()
download_url.text.len()
+ blob_id.len()
+ name.as_ref().map(|n| n.len()).unwrap_or(0)
+ account_id.len(),
);
let mut prev_pos = 0;
while let Some(pos) = download_url.as_str().as_bytes()[prev_pos..].find(b"{") {
ret.push_str(&download_url.as_str()[prev_pos..prev_pos + pos]);
while let Some(pos) = download_url.text.as_bytes()[prev_pos..].find(b"{") {
ret.push_str(&download_url.text[prev_pos..prev_pos + pos]);
prev_pos += pos;
if download_url.as_str()[prev_pos..].starts_with("{accountId}") {
if download_url.text[prev_pos..].starts_with("{accountId}") {
ret.push_str(account_id.as_str());
prev_pos += "{accountId}".len();
} else if download_url.as_str()[prev_pos..].starts_with("{blobId}") {
} else if download_url.text[prev_pos..].starts_with("{blobId}") {
ret.push_str(blob_id.as_str());
prev_pos += "{blobId}".len();
} else if download_url.as_str()[prev_pos..].starts_with("{name}") {
} else if download_url.text[prev_pos..].starts_with("{name}") {
ret.push_str(name.as_deref().unwrap_or(""));
prev_pos += "{name}".len();
} else if download_url.as_str()[prev_pos..].starts_with("{type}") {
} else if download_url.text[prev_pos..].starts_with("{type}") {
ret.push_str("application/octet-stream");
prev_pos += "{name}".len();
} else {
log::error!(
"BUG: unknown parameter in download_url: {}",
&download_url.as_str()[prev_pos..]
&download_url.text[prev_pos..]
);
return Err(Error::new(
"Could not instantiate URL from JMAP server's URL template value",
@ -818,16 +865,16 @@ pub fn download_request_format(
{}\nUnknown parameter found {}\n\nIf you believe these values are correct and \
should have been accepted, please report it as a bug! Otherwise inform the \
server administrator for this protocol violation.",
download_url,
download_url.text,
account_id,
blob_id,
&download_url.as_str()[prev_pos..]
&download_url.text[prev_pos..]
))
.set_kind(ErrorKind::ProtocolError));
}
}
if prev_pos != download_url.as_str().len() {
ret.push_str(&download_url.as_str()[prev_pos..]);
if prev_pos != download_url.text.len() {
ret.push_str(&download_url.text[prev_pos..]);
}
Url::parse(&ret).map_err(|err| {
Error::new("Could not instantiate URL from JMAP server's URL template value")
@ -837,22 +884,24 @@ pub fn download_request_format(
{}\nresult: {ret}\n\nIf you believe these values are correct and should have \
been accepted, please report it as a bug! Otherwise inform the server \
administrator for this protocol violation.",
download_url, account_id, blob_id
download_url.text, account_id, blob_id
))
.set_kind(ErrorKind::ProtocolError)
.set_source(Some(Arc::new(err)))
})
}
pub fn upload_request_format(upload_url: &Url, account_id: &Id<Account>) -> Result<Url> {
//"uploadUrl": "https://jmap.fastmail.com/upload/{accountId}/",
let mut ret = String::with_capacity(upload_url.as_str().len() + account_id.len());
pub fn upload_request_format(
upload_url: &RequestUrlTemplate,
account_id: &Id<Account>,
) -> Result<Url> {
let mut ret = String::with_capacity(upload_url.text.len() + account_id.len());
let mut prev_pos = 0;
while let Some(pos) = upload_url.as_str().as_bytes()[prev_pos..].find(b"{") {
ret.push_str(&upload_url.as_str()[prev_pos..prev_pos + pos]);
while let Some(pos) = upload_url.text.as_bytes()[prev_pos..].find(b"{") {
ret.push_str(&upload_url.text[prev_pos..prev_pos + pos]);
prev_pos += pos;
if upload_url.as_str()[prev_pos..].starts_with("{accountId}") {
if upload_url.text[prev_pos..].starts_with("{accountId}") {
ret.push_str(account_id.as_str());
prev_pos += "{accountId}".len();
break;
@ -861,8 +910,8 @@ pub fn upload_request_format(upload_url: &Url, account_id: &Id<Account>) -> Resu
prev_pos += 1;
}
}
if prev_pos != upload_url.as_str().len() {
ret.push_str(&upload_url.as_str()[prev_pos..]);
if prev_pos != upload_url.text.len() {
ret.push_str(&upload_url.text[prev_pos..]);
}
Url::parse(&ret).map_err(|err| {
Error::new("Could not instantiate URL from JMAP server's URL template value")
@ -872,7 +921,7 @@ pub fn upload_request_format(upload_url: &Url, account_id: &Id<Account>) -> Resu
{ret}\n\nIf you believe these values are correct and should have been accepted, \
please report it as a bug! Otherwise inform the server administrator for this \
protocol violation.",
upload_url, account_id
upload_url.text, account_id
))
.set_kind(ErrorKind::ProtocolError)
.set_source(Some(Arc::new(err)))

@ -27,6 +27,7 @@ use url::Url;
use crate::jmap::{
identity::IdentityObject,
methods::{u64_zero, RequestUrlTemplate},
objects::{Account, Id, Object, State},
protocol::JmapMailCapability,
};
@ -41,10 +42,9 @@ pub struct Session {
pub identities: IndexMap<Id<IdentityObject>, IdentityObject>,
pub username: String,
pub api_url: Arc<Url>,
pub download_url: Arc<Url>,
pub upload_url: Arc<Url>,
pub event_source_url: Arc<Url>,
pub download_url: Arc<RequestUrlTemplate>,
pub upload_url: Arc<RequestUrlTemplate>,
pub event_source_url: Arc<RequestUrlTemplate>,
pub state: State<Session>,
#[serde(flatten)]
pub extra_properties: IndexMap<String, Value>,
@ -70,20 +70,22 @@ impl Session {
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CapabilitiesObject {
#[serde(default)]
#[serde(default, skip_serializing_if = "u64_zero")]
pub max_size_upload: u64,
#[serde(default)]
#[serde(default, skip_serializing_if = "u64_zero")]
pub max_concurrent_upload: u64,
#[serde(default)]
#[serde(default, skip_serializing_if = "u64_zero")]
pub max_size_request: u64,
#[serde(default)]
#[serde(default, skip_serializing_if = "u64_zero")]
pub max_concurrent_requests: u64,
#[serde(default)]
#[serde(default, skip_serializing_if = "u64_zero")]
pub max_calls_in_request: u64,
#[serde(default)]
#[serde(default, skip_serializing_if = "u64_zero")]
pub max_objects_in_get: u64,
#[serde(default)]
#[serde(default, skip_serializing_if = "u64_zero")]
pub max_objects_in_set: u64,
#[serde(default)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub collation_algorithms: Vec<String>,
#[serde(flatten, skip_serializing_if = "IndexMap::is_empty")]
pub extra_properties: IndexMap<String, Value>,
}

@ -431,3 +431,268 @@ fn test_jmap_argument_serde() {
}},
);
}
#[test]
fn test_jmap_request_url_template() {
use serde_json::json;
use url::Url;
use crate::jmap::{
methods::{download_request_format, upload_request_format, RequestUrlTemplate},
objects::{Account, BlobObject, Id},
};
const DOWNLOAD_TEMPLATE: &str = "https://jmap.example.com/download/{accountId}/{blobId}/{name}";
const UPLOAD_TEMPLATE: &str = "https://jmap.example.com/upload/{accountId}/";
let account_id: Id<Account> = "blahblah".into();
let blob_id: Id<BlobObject> = Id::from("683f9246-56d4-4d7d-bd0c-3d4de6db7cbf");
let download_template_url: RequestUrlTemplate =
serde_json::from_value(json!(DOWNLOAD_TEMPLATE)).unwrap();
assert_eq!(
download_request_format(
&download_template_url,
&account_id,
&blob_id,
Some("attachment.txt".into())
)
.unwrap(),
serde_json::from_str::<Url>(
&json!("https://jmap.example.com/download/blahblah/683f9246-56d4-4d7d-bd0c-3d4de6db7cbf/attachment.txt").to_string()
)
.unwrap()
);
assert_eq!(
download_request_format(
&download_template_url,
&account_id,
&blob_id,
Some("attachment filename.txt".into()),
)
.unwrap(),
serde_json::from_str::<Url>(
&json!("https://jmap.example.com/download/blahblah/683f9246-56d4-4d7d-bd0c-3d4de6db7cbf/attachment%20filename.txt").to_string()
)
.unwrap()
);
let upload_template_url: RequestUrlTemplate =
serde_json::from_value(json!(UPLOAD_TEMPLATE)).unwrap();
assert_eq!(
upload_request_format(&upload_template_url, &account_id).unwrap(),
serde_json::from_str::<Url>(
&json!("https://jmap.example.com/upload/blahblah/").to_string()
)
.unwrap()
);
}
#[test]
fn test_jmap_session_serde() {
use serde_json::json;
use crate::jmap::{
objects::{Account, Id, State},
session::{CapabilitiesObject, Session},
};
const RFC_EXAMPLE: &str = r###"{
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 50000000,
"maxConcurrentUpload": 8,
"maxSizeRequest": 10000000,
"maxConcurrentRequests": 8,
"maxCallsInRequest": 32,
"maxObjectsInGet": 256,
"maxObjectsInSet": 128,
"collationAlgorithms": [
"i;ascii-numeric",
"i;ascii-casemap",
"i;unicode-casemap"
]
},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"https://example.com/apis/foobar": {
"maxFoosFinangled": 42
}
},
"accounts": {
"A13824": {
"name": "john@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": null,
"maxMailboxDepth": 10
},
"urn:ietf:params:jmap:contacts": {
}
}
},
"A97813": {
"name": "jane@example.com",
"isPersonal": false,
"isReadOnly": true,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": 1,
"maxMailboxDepth": 10
}
}
}
},
"primaryAccounts": {
"urn:ietf:params:jmap:mail": "A13824",
"urn:ietf:params:jmap:contacts": "A13824"
},
"username": "john@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/upload/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "75128aab4b1b"
}"###;
let expected = Session {
capabilities: indexmap::indexmap! {
"urn:ietf:params:jmap:core".to_string() => CapabilitiesObject {
max_size_upload: 50000000,
max_concurrent_upload: 8,
max_size_request: 10000000,
max_concurrent_requests: 8,
max_calls_in_request: 32,
max_objects_in_get: 256,
max_objects_in_set: 128,
collation_algorithms: vec![
"i;ascii-numeric".to_string(),
"i;ascii-casemap".to_string(),
"i;unicode-casemap".to_string(),
],
extra_properties: indexmap::IndexMap::default(),
},
"urn:ietf:params:jmap:mail".to_string() => CapabilitiesObject {
max_size_upload: 0,
max_concurrent_upload: 0,
max_size_request: 0,
max_concurrent_requests: 0,
max_calls_in_request: 0,
max_objects_in_get: 0,
max_objects_in_set: 0,
collation_algorithms: vec![],
extra_properties: indexmap::IndexMap::default(),
},
"urn:ietf:params:jmap:contacts".to_string() => CapabilitiesObject {
max_size_upload: 0,
max_concurrent_upload: 0,
max_size_request: 0,
max_concurrent_requests: 0,
max_calls_in_request: 0,
max_objects_in_get: 0,
max_objects_in_set: 0,
collation_algorithms: vec![],
extra_properties: indexmap::IndexMap::default(),
},
"https://example.com/apis/foobar".to_string() => CapabilitiesObject {
max_size_upload: 0,
max_concurrent_upload: 0,
max_size_request: 0,
max_concurrent_requests: 0,
max_calls_in_request: 0,
max_objects_in_get: 0,
max_objects_in_set: 0,
collation_algorithms: vec![],
extra_properties: indexmap::indexmap! {
"maxFoosFinangled".to_string() => json!(42),
},
},
},
accounts: indexmap::indexmap! {
Id::<Account>::from(
"A13824",
) => Account {
name: "john@example.com".to_string(),
is_personal: true,
is_read_only: false,
account_capabilities: indexmap::indexmap! {
"urn:ietf:params:jmap:mail".to_string() => json!({
"maxMailboxDepth": 10,
"maxMailboxesPerEmail": null
}),
"urn:ietf:params:jmap:contacts".to_string() => json!({}),
},
extra_properties: indexmap::indexmap! {},
},
Id::<Account>::from(
"A97813",
) => Account {
name: "jane@example.com".to_string(),
is_personal: false,
is_read_only: true,
account_capabilities: indexmap::indexmap! {
"urn:ietf:params:jmap:mail".to_string() => json!({
"maxMailboxDepth": 10,
"maxMailboxesPerEmail": 1
}),
},
extra_properties: indexmap::indexmap! {},
},
},
primary_accounts: indexmap::indexmap! {
"urn:ietf:params:jmap:mail".to_string() => Id::<Account>::from(
"A13824",
),
"urn:ietf:params:jmap:contacts".to_string() => Id::<Account>::from(
"A13824",
),
},
identities: indexmap::indexmap! {},
username: "john@example.com".to_string(),
api_url: serde_json::from_value(json!("https://jmap.example.com/api/")).unwrap(),
download_url: serde_json::from_value(json!(
"https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
))
.unwrap(),
upload_url: serde_json::from_value(json!("https://jmap.example.com/upload/{accountId}/"))
.unwrap(),
event_source_url: serde_json::from_value(json!(
"https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
))
.unwrap(),
state: State {
inner: "75128aab4b1b".into(),
_ph: std::marker::PhantomData,
},
extra_properties: indexmap::indexmap! {},
};
assert_eq!(
json!(serde_json::from_str::<Session>(RFC_EXAMPLE).unwrap()),
json!(expected)
);
assert_eq!(
json!(serde_json::from_str::<serde_json::Value>(RFC_EXAMPLE).unwrap()),
json!(expected)
);
assert_eq!(
json!(serde_json::from_str::<serde_json::Value>(RFC_EXAMPLE).unwrap()),
json!(serde_json::from_str::<Session>(RFC_EXAMPLE).unwrap()),
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(
&json!(serde_json::from_str::<serde_json::Value>(RFC_EXAMPLE).unwrap()).to_string()
)
.unwrap(),
json!(serde_json::from_str::<Session>(RFC_EXAMPLE).unwrap()),
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(
&json!(serde_json::from_str::<serde_json::Value>(RFC_EXAMPLE).unwrap()).to_string()
)
.unwrap(),
serde_json::from_str::<serde_json::Value>(
&json!(serde_json::from_str::<Session>(RFC_EXAMPLE).unwrap()).to_string()
)
.unwrap(),
);
}

Loading…
Cancel
Save