use core::future::Future;
use core::pin::Pin;
use core::str::FromStr;
use core::sync::atomic::{AtomicI64, Ordering};
use core::time::Duration;
use tokio::sync::Mutex;
use serde_json::Value;
use crate::adapters::primary::transport::http::SseEvent;
use crate::adapters::secondary::transport::http::FrameReader;
use crate::application::jsonrpc::document::{
notification_document, reply_result, request_document,
};
use crate::domain::ir::JsonObject;
use crate::domain::{MessageEndpoint, RequestId, names};
use crate::application::client::{ClientTransport, TransportError};
use crate::application::jsonrpc::document::RequestError;
use crate::domain::ir::MethodName;
use super::error::{self, LegacySseError};
const EVENT_STREAM: &str = "text/event-stream";
const JSON: &str = "application/json";
const GET: &str = "GET";
const POST: &str = "POST";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LegacySseConfig {
url: String,
connect_timeout: Option<Duration>,
}
impl LegacySseConfig {
pub fn new(url: &str) -> Result<Self, LegacySseError> {
reqwest::Url::parse(url).map_err(|error| LegacySseError::Url {
got: url.to_owned(),
reason: error.to_string(),
})?;
Ok(Self {
url: url.to_owned(),
connect_timeout: None,
})
}
#[must_use]
pub const fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = Some(timeout);
self
}
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientFrame {
Message(Value),
Endpoint(MessageEndpoint),
}
impl ClientFrame {
fn classify(event: &SseEvent) -> Result<Self, LegacySseError> {
let data = event.data().to_owned();
if event.is_named(&names::ENDPOINT) {
return MessageEndpoint::from_str(&data)
.map(Self::Endpoint)
.map_err(LegacySseError::endpoint);
}
serde_json::from_str::<Value>(&data)
.map(Self::Message)
.map_err(|error| LegacySseError::Unreadable {
got: truncate(&data),
reason: error.to_string(),
})
}
fn describe(&self) -> String {
match self {
Self::Endpoint(endpoint) => format!("an `endpoint` event naming {endpoint}"),
Self::Message(value) => format!("a message frame: {value}"),
}
}
}
#[derive(Debug)]
struct Stream {
response: reqwest::Response,
reader: FrameReader,
ready: std::collections::VecDeque<SseEvent>,
}
impl Stream {
async fn next(&mut self) -> Result<Option<SseEvent>, LegacySseError> {
loop {
if let Some(event) = self.ready.pop_front() {
return Ok(Some(event));
}
let chunk = self
.response
.chunk()
.await
.map_err(|error| LegacySseError::Io { method: GET, error })?;
let Some(chunk) = chunk else {
return Ok(None);
};
self.ready
.extend(
self.reader
.push(&chunk)
.map_err(|source| LegacySseError::Unreadable {
got: String::from("a terminated SSE block"),
reason: source.to_string(),
})?,
);
}
}
}
#[derive(Debug)]
pub struct LegacySseClient {
inner: reqwest::Client,
config: LegacySseConfig,
post_to: reqwest::Url,
stream: Mutex<Box<Stream>>,
next_id: AtomicI64,
}
impl LegacySseClient {
pub async fn connect(config: LegacySseConfig) -> Result<Self, LegacySseError> {
let inner = build_client(&config)?;
Self::connect_with(inner, config).await
}
pub async fn connect_with(
inner: reqwest::Client,
config: LegacySseConfig,
) -> Result<Self, LegacySseError> {
let mut stream = open(&inner, &config.url).await?;
let first = match stream.next().await? {
Some(event) => ClientFrame::classify(&event)?,
None => {
return Err(LegacySseError::FirstEventNotEndpoint {
got: String::from("an empty stream"),
});
}
};
let ClientFrame::Endpoint(advertised) = first else {
return Err(LegacySseError::FirstEventNotEndpoint {
got: first.describe(),
});
};
let post_to = resolve(&config.url, &advertised)?;
Ok(Self {
inner,
config,
post_to,
stream: Mutex::new(Box::new(stream)),
next_id: AtomicI64::new(1),
})
}
#[must_use]
pub const fn message_endpoint(&self) -> &reqwest::Url {
&self.post_to
}
#[must_use]
pub const fn config(&self) -> &LegacySseConfig {
&self.config
}
pub async fn send(&self, frame: &[u8]) -> Result<(), LegacySseError> {
let response = self
.inner
.post(self.post_to.clone())
.header(reqwest::header::CONTENT_TYPE, JSON)
.body(frame.to_vec())
.send()
.await
.map_err(|error| LegacySseError::Io {
method: POST,
error,
})?;
if response.status().is_success() {
Ok(())
} else {
Err(refusal(POST, response).await)
}
}
pub async fn next(&self) -> Result<Option<ClientFrame>, LegacySseError> {
let mut stream = self.stream.lock().await;
stream
.next()
.await?
.map(|event| ClientFrame::classify(&event))
.transpose()
}
pub(crate) fn next_request_id(&self) -> RequestId {
RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed))
}
}
fn resolve(base: &str, advertised: &MessageEndpoint) -> Result<reqwest::Url, LegacySseError> {
let base = reqwest::Url::parse(base).map_err(|error| LegacySseError::Url {
got: base.to_owned(),
reason: error.to_string(),
})?;
let resolved = base
.join(advertised.as_str())
.map_err(|error| LegacySseError::Url {
got: advertised.as_str().to_owned(),
reason: error.to_string(),
})?;
if resolved.origin() != base.origin() {
return Err(LegacySseError::Url {
got: advertised.as_str().to_owned(),
reason: format!(
"it resolves to {resolved}, which is not on the origin this client was given ({base})"
),
});
}
Ok(resolved)
}
async fn open(inner: &reqwest::Client, url: &str) -> Result<Stream, LegacySseError> {
let response = inner
.get(url)
.header(reqwest::header::ACCEPT, EVENT_STREAM)
.send()
.await
.map_err(|error| LegacySseError::Io { method: GET, error })?;
if !response.status().is_success() {
return Err(refusal(GET, response).await);
}
if !is_event_stream(&response) {
return Err(LegacySseError::NotAStream {
got: response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned(),
});
}
Ok(Stream {
response,
reader: FrameReader::new(),
ready: std::collections::VecDeque::new(),
})
}
fn is_event_stream(response: &reqwest::Response) -> bool {
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(';')
.next()
.is_some_and(|media| media.trim().eq_ignore_ascii_case(EVENT_STREAM))
})
}
fn build_client(config: &LegacySseConfig) -> Result<reqwest::Client, LegacySseError> {
let mut builder = reqwest::Client::builder()
.user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")));
if let Some(limit) = config.connect_timeout {
builder = builder.connect_timeout(limit);
}
builder.build().map_err(|error| LegacySseError::Io {
method: "CLIENT",
error,
})
}
async fn refusal(method: &'static str, response: reqwest::Response) -> LegacySseError {
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let preview = response
.bytes()
.await
.map(|body| {
String::from_utf8_lossy(&body)
.chars()
.take(error::PREVIEW)
.collect()
})
.unwrap_or_default();
LegacySseError::Refused {
method,
status,
content_type,
preview,
}
}
fn truncate(value: &str) -> String {
value.chars().take(error::PREVIEW).collect()
}
#[cfg(test)]
mod tests;
impl ClientTransport for LegacySseClient {
fn send(
&self,
method: &str,
params: &JsonObject,
) -> Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>> {
let prepared = (|| {
let id = self.next_request_id();
let name = method_name(method)?;
let document = request_document(&id, &name, params)
.map_err(unavailable)?
.to_string();
Ok::<_, TransportError>((id, document))
})();
Box::pin(async move {
let (id, document) = prepared?;
self.send(document.as_bytes()).await.map_err(unavailable)?;
self.await_reply(&id).await
})
}
fn notify(
&self,
method: &str,
params: &JsonObject,
) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>> {
let prepared = (|| {
let name = method_name(method)?;
let document = notification_document(&name, params)
.map_err(unavailable)?
.to_string();
Ok::<_, TransportError>(document)
})();
Box::pin(async move {
let document = prepared?;
self.send(document.as_bytes()).await.map_err(unavailable)
})
}
}
impl LegacySseClient {
async fn await_reply(&self, id: &RequestId) -> Result<JsonObject, TransportError> {
loop {
let Some(frame) = self.next().await.map_err(unavailable)? else {
return Err(TransportError::Unavailable(format!(
"the stream closed before answering {:?}",
id.redacted()
)));
};
match frame {
ClientFrame::Message(payload) => {
if let Some(answer) = reply_for(&payload, id) {
return answer.map_err(unavailable);
}
}
ClientFrame::Endpoint(_) => {}
}
}
}
}
fn reply_for(payload: &Value, id: &RequestId) -> Option<Result<JsonObject, RequestError>> {
let members = payload.as_object()?;
let wanted: Option<Value> = serde_json::from_str(&id.to_json()).ok();
if wanted.as_ref() != members.get("id") {
return None;
}
Some(reply_result(&payload.to_string(), id))
}
fn method_name(method: &str) -> Result<MethodName, TransportError> {
MethodName::new(method).map_err(unavailable)
}
fn unavailable(error: impl core::fmt::Display) -> TransportError {
TransportError::Unavailable(error.to_string())
}