use std::fmt;
use std::time::Duration;
use efema_proto::wire::{self, Batch, Heads, Page, Problem, Watch, Written};
use efema_proto::{Cursor, MEDIA_TYPE, StreamName};
use reqwest::header::{CONTENT_TYPE, HeaderValue};
use reqwest::{Response, Url};
use crate::transport::{Limits, Transport, TransportError};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const WAIT_SLACK: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct Relay {
base: String,
http: reqwest::Client,
}
#[derive(Debug, thiserror::Error)]
#[error("`{url}` is not a relay address: {reason}")]
pub struct InvalidUrl {
url: String,
reason: String,
}
impl Relay {
pub fn new(url: &str) -> Result<Self, InvalidUrl> {
let invalid = |reason: &str| InvalidUrl { url: url.to_string(), reason: reason.to_string() };
let parsed = Url::parse(url).map_err(|e| invalid(&e.to_string()))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(invalid("the scheme must be http or https"));
}
if parsed.host().is_none() {
return Err(invalid("there is no host"));
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err(invalid("a relay address has no query and no fragment"));
}
let http = reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.user_agent(concat!("efema/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| invalid(&e.to_string()))?;
Ok(Self { base: parsed.as_str().trim_end_matches('/').to_string(), http })
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.base)
}
fn entries(&self, stream: &StreamName) -> String {
self.url(&format!("{}/streams/{stream}/entries", efema_proto::API_PREFIX))
}
fn unreachable(&self, source: reqwest::Error) -> TransportError {
TransportError::Unreachable { at: self.base.clone(), source: Box::new(source.without_url()) }
}
async fn answer<T: for<'b> minicbor::Decode<'b, ()>>(&self, response: Response) -> Result<T, TransportError> {
let status = response.status();
let cbor =
response.headers().get(CONTENT_TYPE).and_then(|value| value.to_str().ok()).is_some_and(|value| {
value.split(';').next().unwrap_or_default().trim().eq_ignore_ascii_case(MEDIA_TYPE)
});
if !cbor {
return Err(TransportError::NotProtocol {
at: self.base.clone(),
reason: format!("a {status} answer that is not {MEDIA_TYPE}"),
});
}
let body = response.bytes().await.map_err(|e| self.unreachable(e))?;
let not_protocol =
|e: wire::WireError| TransportError::NotProtocol { at: self.base.clone(), reason: e.to_string() };
if status.is_success() {
wire::decode(&body).map_err(not_protocol)
} else {
let problem: Problem = wire::decode(&body).map_err(not_protocol)?;
Err(TransportError::Refused { at: self.base.clone(), problem })
}
}
}
impl fmt::Display for Relay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.base)
}
}
impl fmt::Debug for Relay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Relay({})", self.base)
}
}
impl Transport for Relay {
fn describe(&self) -> String {
self.base.clone()
}
async fn limits(&self) -> Result<Limits, TransportError> {
Ok(Limits::V1)
}
async fn append(&self, stream: &StreamName, batch: &Batch) -> Result<Written, TransportError> {
let response = self
.http
.post(self.entries(stream))
.header(CONTENT_TYPE, HeaderValue::from_static(MEDIA_TYPE))
.timeout(REQUEST_TIMEOUT)
.body(wire::encode(batch))
.send()
.await
.map_err(|e| self.unreachable(e))?;
self.answer(response).await
}
async fn read(&self, stream: &StreamName, after: Option<&Cursor>, limit: usize) -> Result<Page, TransportError> {
let mut url = format!("{}?limit={limit}", self.entries(stream));
if let Some(cursor) = after {
url.push_str(&format!("&after={cursor}"));
}
let response = self.http.get(url).timeout(REQUEST_TIMEOUT).send().await.map_err(|e| self.unreachable(e))?;
self.answer(response).await
}
async fn wait(&self, watches: &[Watch], timeout: Duration) -> Result<Heads, TransportError> {
let mut url = self.url(&format!("{}/wait?timeout={}", efema_proto::API_PREFIX, timeout.as_secs()));
for watch in watches {
url.push_str(&format!("&watch={watch}"));
}
let response =
self.http.get(url).timeout(timeout + WAIT_SLACK).send().await.map_err(|e| self.unreachable(e))?;
self.answer(response).await
}
}