use jmap_types::{Id, Invocation, JmapError, State};
use serde_json::{json, Value};
use crate::backend::{GetObject, JmapBackend, JmapObject, QueryObject};
use crate::helpers::{extract_account_id, not_found_json, optional_arg, serialize_value};
pub const SERVER_FAIL_INTERNAL_DESC: &str = "internal error";
pub fn server_fail_from_backend<E: std::fmt::Display + ?Sized>(_err: &E) -> JmapError {
JmapError::server_fail(SERVER_FAIL_INTERNAL_DESC)
}
pub fn server_fail_value_from_backend<E: std::fmt::Display + ?Sized>(_err: &E) -> Value {
json!({
"type": "serverFail",
"description": SERVER_FAIL_INTERNAL_DESC,
})
}
pub async fn handle_get<O: GetObject, B: JmapBackend>(
backend: &B,
caller: &B::CallerCtx,
args: Value,
) -> Result<(Value, Vec<Invocation>), JmapError> {
let (account_id, mut args) = extract_account_id(args)?;
if !backend
.account_exists(caller, &account_id)
.await
.map_err(|e| server_fail_from_backend(&e))?
{
return Err(JmapError::account_not_found());
}
let ids: Option<Vec<Id>> = optional_arg(&mut args, "ids", || {
JmapError::invalid_arguments("ids must be an Id array")
})?;
let properties: Option<Vec<String>> = optional_arg(&mut args, "properties", || {
JmapError::invalid_arguments("properties must be a string array")
})?;
let ids_slice = ids.as_deref();
let (list, not_found) = backend
.get_objects::<O>(caller, &account_id, ids_slice, properties.as_deref())
.await
.map_err(|e| server_fail_from_backend(&e))?;
let state = backend
.get_state::<O>(caller, &account_id)
.await
.map_err(|e| server_fail_from_backend(&e))?;
let list_json = serialize_value(&list)?;
Ok((
json!({
"accountId": account_id.as_ref(),
"state": state.as_ref(),
"list": list_json,
"notFound": not_found_json(¬_found),
}),
vec![],
))
}
pub async fn handle_changes<O: JmapObject, B: JmapBackend>(
backend: &B,
caller: &B::CallerCtx,
args: Value,
) -> Result<(Value, Vec<Invocation>), JmapError> {
let (account_id, args) = extract_account_id(args)?;
if !backend
.account_exists(caller, &account_id)
.await
.map_err(|e| server_fail_from_backend(&e))?
{
return Err(JmapError::account_not_found());
}
let since_state: State = match args.get("sinceState").and_then(|v| v.as_str()) {
Some(s) => State::from(s),
None => return Err(JmapError::invalid_arguments("sinceState is required")),
};
let max_changes: Option<u64> = match args.get("maxChanges") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().filter(|&n| n > 0).ok_or_else(|| {
JmapError::invalid_arguments("maxChanges must be a positive integer")
})?),
};
let result = backend
.get_changes::<O>(caller, &account_id, &since_state, max_changes)
.await
.map_err(JmapError::from)?;
Ok((
json!({
"accountId": account_id.as_ref(),
"oldState": since_state.as_ref(),
"newState": result.new_state.as_ref(),
"hasMoreChanges": result.has_more_changes,
"updatedProperties": Value::Null,
"created": result.created,
"updated": result.updated,
"destroyed": result.destroyed,
}),
vec![],
))
}
pub async fn handle_query<O: QueryObject, B: JmapBackend>(
backend: &B,
caller: &B::CallerCtx,
args: Value,
) -> Result<(Value, Vec<Invocation>), JmapError> {
let (account_id, mut args) = extract_account_id(args)?;
if !backend
.account_exists(caller, &account_id)
.await
.map_err(|e| server_fail_from_backend(&e))?
{
return Err(JmapError::account_not_found());
}
let calculate_total: bool = args
.get("calculateTotal")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let limit: Option<u64> = match args.get("limit") {
None | Some(Value::Null) => None,
Some(v) => match v.as_u64() {
Some(n) => Some(n),
None => {
return Err(JmapError::invalid_arguments(format!(
"limit: expected a non-negative integer, got {v}"
)))
}
},
};
let position: i64 = match args.get("position") {
None | Some(Value::Null) => 0,
Some(v) => v.as_i64().ok_or_else(|| {
JmapError::invalid_arguments(format!("position: expected an integer, got {v}"))
})?,
};
let filter: Option<O::Filter> =
optional_arg(&mut args, "filter", JmapError::unsupported_filter)?;
let sort: Option<Vec<O::Comparator>> = optional_arg(&mut args, "sort", || {
JmapError::invalid_arguments("sort must be an array")
})?;
let result = backend
.query_objects::<O>(
caller,
&account_id,
filter.as_ref(),
sort.as_deref(),
limit,
position,
)
.await
.map_err(|e| server_fail_from_backend(&e))?;
let mut resp = json!({
"accountId": account_id.as_ref(),
"queryState": result.query_state.as_ref(),
"canCalculateChanges": result.can_calculate_changes,
"position": result.position,
"ids": result.ids,
});
if calculate_total {
if let Some(t) = result.total {
resp["total"] = json!(t);
}
}
Ok((resp, vec![]))
}
pub async fn handle_query_changes<O: QueryObject, B: JmapBackend>(
backend: &B,
caller: &B::CallerCtx,
args: Value,
) -> Result<(Value, Vec<Invocation>), JmapError> {
let (account_id, mut args) = extract_account_id(args)?;
if !backend
.account_exists(caller, &account_id)
.await
.map_err(|e| server_fail_from_backend(&e))?
{
return Err(JmapError::account_not_found());
}
let since_query_state: State = match args.get("sinceQueryState").and_then(|v| v.as_str()) {
Some(s) => State::from(s),
None => return Err(JmapError::invalid_arguments("sinceQueryState is required")),
};
let max_changes: Option<u64> = match args.get("maxChanges") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().filter(|&n| n > 0).ok_or_else(|| {
JmapError::invalid_arguments("maxChanges must be a positive integer")
})?),
};
let up_to_id: Option<Id> = match args.get("upToId") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(Id::from(s.as_str())),
Some(_) => {
return Err(JmapError::invalid_arguments(
"upToId must be a string Id or null",
))
}
};
let calculate_total: bool = args
.get("calculateTotal")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let filter: Option<O::Filter> =
optional_arg(&mut args, "filter", JmapError::unsupported_filter)?;
let sort: Option<Vec<O::Comparator>> = optional_arg(&mut args, "sort", || {
JmapError::invalid_arguments("sort must be an array")
})?;
let result = backend
.query_changes::<O>(
caller,
&account_id,
&since_query_state,
filter.as_ref(),
sort.as_deref(),
max_changes,
up_to_id.as_ref(),
false, )
.await
.map_err(JmapError::from)?;
let added: Vec<Value> = result
.added
.iter()
.map(|item| {
json!({
"id": item.id.as_ref(),
"index": item.index,
})
})
.collect();
let mut resp = json!({
"accountId": account_id.as_ref(),
"oldQueryState": result.old_query_state.as_ref(),
"newQueryState": result.new_query_state.as_ref(),
"removed": result.removed,
"added": added,
});
if calculate_total {
if let Some(t) = result.total {
resp["total"] = json!(t);
}
}
Ok((resp, vec![]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_fail_from_backend_drops_display_text() {
#[derive(Debug)]
struct LeakyError(&'static str);
impl std::fmt::Display for LeakyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for LeakyError {}
const CANARY: &str = "TOKEN-DO-NOT-LEAK-c0ffee";
let err = LeakyError(CANARY);
let jmap_err = server_fail_from_backend(&err);
let wire = serde_json::to_value(&jmap_err).expect("JmapError must serialize");
let wire_str = wire.to_string();
assert!(
!wire_str.contains(CANARY),
"server_fail_from_backend must not echo backend error Display \
onto the wire; got {wire_str}"
);
assert_eq!(
wire["description"], SERVER_FAIL_INTERNAL_DESC,
"description must be the static 'internal error' string"
);
assert_eq!(wire["type"], "serverFail");
}
#[test]
fn server_fail_from_backend_accepts_generic_display() {
let _ = server_fail_from_backend("a string");
let _ = server_fail_from_backend(&"&str".to_owned());
let _ = server_fail_from_backend(&42_u64);
}
#[test]
fn server_fail_value_from_backend_drops_display_text() {
#[derive(Debug)]
struct LeakyError(&'static str);
impl std::fmt::Display for LeakyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for LeakyError {}
const CANARY: &str = "TOKEN-DO-NOT-LEAK-d00d";
let err = LeakyError(CANARY);
let wire = server_fail_value_from_backend(&err);
let wire_str = wire.to_string();
assert!(
!wire_str.contains(CANARY),
"server_fail_value_from_backend must not echo backend error \
Display onto the wire; got {wire_str}"
);
assert_eq!(wire["type"], "serverFail");
assert_eq!(
wire["description"], SERVER_FAIL_INTERNAL_DESC,
"description must be the static 'internal error' string"
);
}
#[test]
fn server_fail_value_from_backend_accepts_generic_display() {
let _ = server_fail_value_from_backend("a string");
let _ = server_fail_value_from_backend(&"owned-String".to_owned());
let owned: String = "owned".to_string();
let _ = server_fail_value_from_backend(&owned);
let _ = server_fail_value_from_backend(&42_u64);
}
}