use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use crate::auth::{AuthProvider, DefaultTransport, TransportConfig};
use crate::error::ClientError;
use crate::request::Session;
use crate::sse::{SseFrame, parse_sse_block};
struct SseStreamState<S> {
stream: S,
raw_buf: Vec<u8>,
buf: String,
scan_from: usize,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig {
pub request_timeout: std::time::Duration,
pub max_session_body: u64,
pub max_call_body: u64,
pub max_download_body: u64,
pub max_upload_response_body: u64,
pub max_sse_frame: usize,
pub max_ws_message: usize,
}
impl Default for ClientConfig {
fn default() -> Self {
ClientConfig {
request_timeout: std::time::Duration::from_secs(30),
max_session_body: 1024 * 1024,
max_call_body: 8 * 1024 * 1024,
max_download_body: 64 * 1024 * 1024,
max_upload_response_body: 1024 * 1024,
max_sse_frame: 1024 * 1024,
max_ws_message: 1024 * 1024,
}
}
}
impl ClientConfig {
pub fn validate(&self) -> Result<(), ClientError> {
if self.max_session_body == 0 {
return Err(ClientError::InvalidArgument(
"ClientConfig.max_session_body must be > 0".into(),
));
}
if self.max_call_body == 0 {
return Err(ClientError::InvalidArgument(
"ClientConfig.max_call_body must be > 0".into(),
));
}
if self.max_download_body == 0 {
return Err(ClientError::InvalidArgument(
"ClientConfig.max_download_body must be > 0".into(),
));
}
if self.max_upload_response_body == 0 {
return Err(ClientError::InvalidArgument(
"ClientConfig.max_upload_response_body must be > 0".into(),
));
}
if self.request_timeout == std::time::Duration::ZERO {
return Err(ClientError::InvalidArgument(
"ClientConfig.request_timeout must be > 0; Duration::ZERO would disable the per-request timeout in reqwest, not 'fail immediately'. Use Duration::from_secs(30) or similar."
.into(),
));
}
if self.max_sse_frame == 0 {
return Err(ClientError::InvalidArgument(
"ClientConfig.max_sse_frame must be > 0".into(),
));
}
if self.max_ws_message == 0 {
return Err(ClientError::InvalidArgument(
"ClientConfig.max_ws_message must be > 0".into(),
));
}
Ok(())
}
}
#[derive(Clone)]
pub struct JmapClient {
pub(crate) base_url: url::Url,
pub(crate) auth: Arc<dyn AuthProvider>,
pub(crate) http: reqwest::Client,
pub(crate) config: ClientConfig,
}
impl std::fmt::Debug for JmapClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JmapClient")
.field("base_url", &self.base_url)
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl JmapClient {
pub fn new(
transport: impl TransportConfig,
auth: impl AuthProvider + 'static,
base_url: &str,
config: ClientConfig,
) -> Result<Self, ClientError> {
let parsed = parse_base_url(base_url)?;
config.validate()?;
let http = transport.build_client()?.into_inner();
Ok(Self {
base_url: parsed,
auth: Arc::new(auth),
http,
config,
})
}
pub fn new_plain(
auth: impl AuthProvider + 'static,
base_url: &str,
config: ClientConfig,
) -> Result<Self, ClientError> {
Self::new(DefaultTransport, auth, base_url, config)
}
pub fn new_with_shared_auth(
transport: impl TransportConfig,
auth: Arc<dyn AuthProvider>,
base_url: &str,
config: ClientConfig,
) -> Result<Self, ClientError> {
let parsed = parse_base_url(base_url)?;
config.validate()?;
let http = transport.build_client()?.into_inner();
Ok(Self {
base_url: parsed,
auth,
http,
config,
})
}
pub(crate) fn inject_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(header) = self.auth.auth_header() {
builder.header(header.name(), header.expose_value())
} else {
builder
}
}
pub(crate) fn check_auth_status(status: reqwest::StatusCode) -> Result<(), ClientError> {
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
Err(ClientError::AuthFailed(status.as_u16()))
} else {
Ok(())
}
}
pub(crate) fn check_rate_limit(resp: &reqwest::Response) -> Result<(), ClientError> {
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok());
Err(ClientError::rate_limited(
retry_after,
std::time::SystemTime::now(),
))
} else {
Ok(())
}
}
}
pub(crate) async fn read_capped_body(
resp: reqwest::Response,
limit: u64,
) -> Result<Vec<u8>, ClientError> {
if let Some(len) = resp.content_length()
&& len > limit
{
return Err(ClientError::ResponseTooLarge { actual: len, limit });
}
let mut stream = resp.bytes_stream();
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(ClientError::from_reqwest)?;
let new_len = body.len() as u64 + chunk.len() as u64;
if new_len > limit {
return Err(ClientError::ResponseTooLarge {
actual: new_len,
limit,
});
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
impl JmapClient {
pub async fn fetch_session(&self) -> Result<Session, ClientError> {
let limit = self.config.max_session_body;
let url = self.base_url.join(".well-known/jmap").map_err(|e| {
ClientError::InvalidSession(format!("cannot construct session URL: {e}"))
})?;
let req = self.inject_auth(self.http.get(url).timeout(self.config.request_timeout));
let resp = {
let raw_resp = req.send().await.map_err(ClientError::from_reqwest)?;
Self::check_auth_status(raw_resp.status())?;
Self::check_rate_limit(&raw_resp)?;
raw_resp
.error_for_status()
.map_err(ClientError::from_reqwest)?
};
let body = read_capped_body(resp, limit).await?;
let session: Session = serde_json::from_slice(&body).map_err(ClientError::from_parse)?;
validate_session_url_schemes(&session)?;
Ok(session)
}
pub async fn call(
&self,
api_url: &str,
req: &jmap_types::JmapRequest,
) -> Result<jmap_types::JmapResponse, ClientError> {
require_http_url(api_url)?;
let limit = self.config.max_call_body;
let builder = self.inject_auth(
self.http
.post(api_url)
.json(req)
.timeout(self.config.request_timeout),
);
let resp = {
let raw_resp = builder.send().await.map_err(ClientError::from_reqwest)?;
Self::check_auth_status(raw_resp.status())?;
Self::check_rate_limit(&raw_resp)?;
raw_resp
.error_for_status()
.map_err(ClientError::from_reqwest)?
};
let body = read_capped_body(resp, limit).await?;
let jmap_resp: jmap_types::JmapResponse =
serde_json::from_slice(&body).map_err(ClientError::from_parse)?;
Ok(jmap_resp)
}
pub async fn call_session(
&self,
session: &Session,
req: &jmap_types::JmapRequest,
) -> Result<jmap_types::JmapResponse, ClientError> {
self.call(session.api_url.as_str(), req).await
}
pub async fn subscribe_events(
&self,
event_source_url: &str,
last_event_id: Option<&str>,
) -> Result<futures::stream::BoxStream<'static, Result<SseFrame, ClientError>>, ClientError>
{
require_http_url(event_source_url)?;
let mut req = self
.http
.get(event_source_url)
.header("Accept", "text/event-stream");
if let Some(id) = last_event_id {
req = req.header("Last-Event-ID", id);
}
let req = self.inject_auth(req);
let resp = req.send().await.map_err(ClientError::from_reqwest)?;
Self::check_auth_status(resp.status())?;
let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
{
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
let essence = ct
.split(|c: char| c == ';' || c.is_whitespace())
.next()
.unwrap_or("");
if essence != "text/event-stream" {
return Err(ClientError::UnexpectedResponse(format!(
"subscribe_events: expected Content-Type text/event-stream, got: {ct:?}"
)));
}
}
let byte_stream = resp.bytes_stream();
let sse_frame_limit = self.config.max_sse_frame;
Ok(futures::stream::unfold(
Some(SseStreamState {
stream: byte_stream,
raw_buf: Vec::new(),
buf: String::new(),
scan_from: 0, }),
move |state| async move {
let SseStreamState {
mut stream,
mut raw_buf,
mut buf,
mut scan_from,
} = state?;
loop {
let frame_end = [
buf[scan_from..]
.find("\r\n\r\n")
.map(|p| (scan_from + p, 4usize)),
buf[scan_from..]
.find("\n\n")
.map(|p| (scan_from + p, 2usize)),
buf[scan_from..]
.find("\r\r")
.map(|p| (scan_from + p, 2usize)),
buf[scan_from..]
.find("\n\r\n")
.map(|p| (scan_from + p, 3usize)),
]
.into_iter()
.flatten()
.min_by_key(|&(pos, _)| pos);
if let Some((pos, delim_len)) = frame_end {
let frame = {
let slice = &buf[..pos];
if slice.contains('\r') {
slice.replace("\r\n", "\n").replace('\r', "\n")
} else {
slice.to_owned()
}
};
buf.drain(..pos + delim_len);
scan_from = 0; let sse_frame = parse_sse_block(&frame);
return Some((
Ok(sse_frame),
Some(SseStreamState {
stream,
raw_buf,
buf,
scan_from,
}),
));
}
match stream.next().await {
None => return None,
Some(Err(e)) => {
return Some((Err(ClientError::from_reqwest(e)), None));
}
Some(Ok(bytes)) => {
raw_buf.extend_from_slice(&bytes);
if raw_buf.len() > sse_frame_limit {
return Some((
Err(ClientError::SseFrameTooLarge {
limit: sse_frame_limit,
}),
None,
));
}
let old_len = buf.len();
decode_utf8_chunk(&mut raw_buf, &mut buf);
scan_from = old_len.saturating_sub(3);
while scan_from > 0 && !buf.is_char_boundary(scan_from) {
scan_from -= 1;
}
if buf.len() > sse_frame_limit {
return Some((
Err(ClientError::SseFrameTooLarge {
limit: sse_frame_limit,
}),
None,
));
}
}
}
}
},
)
.boxed())
}
pub async fn connect_ws_session(
&self,
ws_url: &str,
auth_header: Option<crate::auth::AuthHeader<'_>>,
) -> Result<crate::ws::WsSession, ClientError> {
crate::ws::connect_ws_with_limit(ws_url, auth_header, self.config.max_ws_message).await
}
pub async fn subscribe_events_session(
&self,
session: &Session,
params: SubscribeEventsSessionParams<'_>,
) -> Result<futures::stream::BoxStream<'static, Result<SseFrame, ClientError>>, ClientError>
{
let SubscribeEventsSessionParams {
types,
close_after,
ping,
last_event_id,
} = params;
let ping_owned = ping.map(|p| p.to_string());
let vars = [
("types", types.unwrap_or("")),
("closeafter", close_after.map_or("", CloseAfter::as_str)),
("ping", ping_owned.as_deref().unwrap_or("")),
];
let expanded = crate::blob::expand_url_template(session.event_source_url.as_str(), &vars)?;
self.subscribe_events(&expanded, last_event_id).await
}
pub fn subscribe_events_reconnecting(
&self,
event_source_url: &str,
last_event_id: Option<&str>,
policy: SseReconnectPolicy,
) -> futures::stream::BoxStream<'static, Result<SseFrame, ClientError>> {
let state = SseReconnectState {
client: self.clone(),
url: event_source_url.to_owned(),
last_event_id: last_event_id.map(str::to_owned),
policy,
inner: None,
delivered: false,
failures: 0,
};
futures::stream::unfold(Some(state), |state| async move {
let mut st = state?;
loop {
let last_err;
if let Some(inner) = st.inner.as_mut() {
match inner.next().await {
Some(Ok(frame)) => {
if let Some(id) = &frame.id {
st.last_event_id = Some(id.clone());
}
st.delivered = true;
st.failures = 0;
return Some((Ok(frame), Some(st)));
}
Some(Err(e)) if !sse_error_is_retryable(&e) => {
return Some((Err(e), None));
}
Some(Err(e)) => last_err = Some(e),
None => last_err = None,
}
st.inner = None;
if st.delivered {
continue;
}
} else {
if st.failures > 0 {
tokio::time::sleep(st.policy.delay_for(st.failures)).await;
}
match st
.client
.subscribe_events(&st.url, st.last_event_id.as_deref())
.await
{
Ok(stream) => {
st.inner = Some(stream);
st.delivered = false;
continue;
}
Err(e) if !sse_error_is_retryable(&e) => return Some((Err(e), None)),
Err(e) => last_err = Some(e),
}
}
st.failures = st.failures.saturating_add(1);
if st.policy.max_retries.is_some_and(|max| st.failures > max) {
return last_err.map(|e| (Err(e), None));
}
}
})
.boxed()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SseReconnectPolicy {
pub initial_delay: Duration,
pub max_delay: Duration,
pub max_retries: Option<u32>,
}
impl Default for SseReconnectPolicy {
fn default() -> Self {
Self {
initial_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(60),
max_retries: None,
}
}
}
impl SseReconnectPolicy {
fn delay_for(&self, failures: u32) -> Duration {
use std::hash::BuildHasher as _;
let factor = 2u32.saturating_pow(failures.saturating_sub(1));
let base = self
.initial_delay
.saturating_mul(factor)
.min(self.max_delay);
let half = base / 2;
let span = u64::try_from(half.as_nanos()).unwrap_or(u64::MAX);
let r = std::collections::hash_map::RandomState::new().hash_one(());
half + Duration::from_nanos(r % span.saturating_add(1))
}
}
struct SseReconnectState {
client: JmapClient,
url: String,
last_event_id: Option<String>,
policy: SseReconnectPolicy,
inner: Option<futures::stream::BoxStream<'static, Result<SseFrame, ClientError>>>,
delivered: bool,
failures: u32,
}
fn sse_error_is_retryable(e: &ClientError) -> bool {
match e {
ClientError::Http(h) => {
!h.is_builder() && h.status().is_none_or(|s| s == 408 || s == 429 || s >= 500)
}
_ => false,
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CloseAfter {
State,
No,
}
impl CloseAfter {
pub fn as_str(self) -> &'static str {
match self {
Self::State => "state",
Self::No => "no",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SubscribeEventsSessionParams<'a> {
pub types: Option<&'a str>,
pub close_after: Option<CloseAfter>,
pub ping: Option<u32>,
pub last_event_id: Option<&'a str>,
}
pub fn extract_response<T: serde::de::DeserializeOwned>(
resp: &jmap_types::JmapResponse,
call_id: &str,
) -> Result<T, ClientError> {
if let Some(args) = find_error_args(resp, call_id) {
let err = <jmap_types::JmapError as serde::Deserialize>::deserialize(args)
.map_err(ClientError::from_parse)?;
return Err(ClientError::MethodError(err));
}
let inv = resp
.method_responses
.iter()
.find(|inv| inv.call_id == call_id)
.ok_or_else(|| ClientError::MethodNotFound(call_id.to_owned()))?;
<T as serde::Deserialize>::deserialize(&inv.arguments).map_err(ClientError::from_parse)
}
pub fn method_error_details<'a>(
resp: &'a jmap_types::JmapResponse,
call_id: &str,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
find_error_args(resp, call_id)?.as_object()
}
fn find_error_args<'a>(
resp: &'a jmap_types::JmapResponse,
call_id: &str,
) -> Option<&'a serde_json::Value> {
resp.method_responses
.iter()
.find(|inv| inv.call_id == call_id && inv.name == "error")
.map(|inv| &inv.arguments)
}
fn decode_utf8_chunk(raw: &mut Vec<u8>, buf: &mut String) {
match std::str::from_utf8(raw) {
Ok(s) => {
buf.push_str(s);
raw.clear();
}
Err(e) => {
let valid_up_to = e.valid_up_to();
buf.push_str(
std::str::from_utf8(&raw[..valid_up_to])
.expect("valid_up_to is a valid UTF-8 boundary"),
);
match e.error_len() {
Some(n) => {
let drain_end = (valid_up_to + n).min(raw.len());
raw.drain(..drain_end);
}
None => {
raw.drain(..valid_up_to);
}
}
}
}
}
fn url_scheme(url: &str) -> Option<&str> {
url.split_once("://").map(|(scheme, _)| scheme)
}
fn is_http_or_https(url: &str) -> bool {
url_scheme(url)
.is_some_and(|s| s.eq_ignore_ascii_case("http") || s.eq_ignore_ascii_case("https"))
}
fn parse_base_url(base_url: &str) -> Result<url::Url, ClientError> {
if base_url.is_empty() {
return Err(ClientError::InvalidArgument(
"base_url may not be empty".into(),
));
}
let parsed = url::Url::parse(base_url)
.map_err(|e| ClientError::InvalidArgument(format!("base_url is not a valid URL: {e}")))?;
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ClientError::InvalidArgument(
"base_url must not contain user-info (`user:password@host`); pass credentials via \
AuthProvider instead"
.into(),
));
}
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return Err(ClientError::InvalidArgument(format!(
"base_url scheme must be http or https, got: {scheme:?}"
)));
}
let path = parsed.path();
if path != "/" {
return Err(ClientError::InvalidArgument(format!(
"base_url must not have a path component, got: {path:?}"
)));
}
if parsed.query().is_some() {
return Err(ClientError::InvalidArgument(
"base_url must not have a query string".into(),
));
}
if parsed.fragment().is_some() {
return Err(ClientError::InvalidArgument(
"base_url must not have a fragment".into(),
));
}
Ok(parsed)
}
pub(crate) fn require_http_url(url: &str) -> Result<(), ClientError> {
if !is_http_or_https(url) {
return Err(ClientError::InvalidArgument(format!(
"URL must have http or https scheme, got: {url:?}"
)));
}
Ok(())
}
fn validate_session_url_schemes(session: &Session) -> Result<(), ClientError> {
for url in [
session.api_url.as_str(),
session.upload_url.as_str(),
session.download_url.as_str(),
session.event_source_url.as_str(),
] {
if !is_http_or_https(url) {
return Err(ClientError::InvalidSession(format!(
"session URL has non-http/https scheme: {url:?}"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::SseReconnectPolicy;
use super::decode_utf8_chunk;
use std::time::Duration;
#[test]
fn reconnect_delay_doubles_caps_and_jitters() {
let policy = SseReconnectPolicy {
initial_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(1),
..SseReconnectPolicy::default()
};
for (failures, d_ms) in [
(1, 100),
(2, 200),
(3, 400),
(4, 800),
(5, 1000),
(500, 1000),
] {
let d = Duration::from_millis(d_ms);
let mut seen = std::collections::HashSet::new();
for _ in 0..64 {
let got = policy.delay_for(failures);
assert!(
got >= d / 2 && got <= d,
"retry {failures}: {got:?} outside [{:?}, {d:?}]",
d / 2
);
seen.insert(got);
}
assert!(seen.len() > 1, "retry {failures}: delay must be jittered");
}
}
#[test]
fn decode_utf8_chunk_all_ascii() {
let mut raw = b"hello".to_vec();
let mut buf = String::new();
decode_utf8_chunk(&mut raw, &mut buf);
assert_eq!(buf, "hello");
assert!(raw.is_empty());
}
#[test]
fn decode_utf8_chunk_complete_multibyte() {
let mut raw = "café".as_bytes().to_vec();
let mut buf = String::new();
decode_utf8_chunk(&mut raw, &mut buf);
assert_eq!(buf, "café");
assert!(raw.is_empty());
}
#[test]
fn decode_utf8_chunk_incomplete_head_retained() {
let mut raw = vec![0xC3u8]; let mut buf = String::new();
decode_utf8_chunk(&mut raw, &mut buf);
assert_eq!(buf, "", "no complete codepoints to push");
assert_eq!(raw, vec![0xC3u8], "incomplete head must stay in raw");
}
#[test]
fn decode_utf8_chunk_prefix_then_incomplete_head() {
let mut raw = vec![b'a', b'b', 0xC3u8];
let mut buf = String::new();
decode_utf8_chunk(&mut raw, &mut buf);
assert_eq!(buf, "ab");
assert_eq!(raw, vec![0xC3u8], "incomplete head must stay in raw");
}
#[test]
fn decode_utf8_chunk_split_sequence_completed() {
let mut raw = vec![0xC3u8];
let mut buf = String::new();
decode_utf8_chunk(&mut raw, &mut buf);
assert_eq!(raw, vec![0xC3u8], "incomplete head retained after chunk 1");
raw.push(0xA9u8);
decode_utf8_chunk(&mut raw, &mut buf);
assert_eq!(buf, "é", "character fully decoded after chunk 2");
assert!(raw.is_empty());
}
#[test]
fn decode_utf8_chunk_invalid_byte_drained() {
let mut raw = vec![0xFFu8];
let mut buf = String::new();
decode_utf8_chunk(&mut raw, &mut buf);
assert_eq!(buf, "");
assert!(raw.is_empty(), "definitively invalid byte must be drained");
}
#[test]
fn decode_utf8_chunk_prefix_then_invalid_drained() {
let mut raw = vec![b'a', b'b', 0xFFu8];
let mut buf = String::new();
decode_utf8_chunk(&mut raw, &mut buf);
assert_eq!(buf, "ab");
assert!(raw.is_empty(), "prefix and invalid byte must be drained");
}
#[test]
fn jmap_client_is_send_sync_clone() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
fn assert_clone<T: Clone>() {}
assert_send::<super::JmapClient>();
assert_sync::<super::JmapClient>();
assert_clone::<super::JmapClient>();
}
}