use chrono::{DateTime, Utc};
use futures_util::{Stream, StreamExt as _};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::auth::Credentials;
use crate::error::{ApiError, Error, Result};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct EventStreamRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until_id: Option<String>,
}
impl EventStreamRequest {
#[must_use]
pub fn since(since: DateTime<Utc>) -> Self {
Self {
since: Some(since),
..Self::default()
}
}
#[must_use]
pub fn from_id(since_id: impl Into<String>) -> Self {
Self {
since_id: Some(since_id.into()),
..Self::default()
}
}
#[must_use]
pub(crate) fn query(&self) -> Vec<(&'static str, String)> {
let mut query = Vec::new();
if let Some(since) = self.since {
query.push(("since", since.to_rfc3339()));
}
if let Some(until) = self.until {
query.push(("until", until.to_rfc3339()));
}
if let Some(since_id) = &self.since_id {
query.push(("since_id", since_id.clone()));
}
if let Some(until_id) = &self.until_id {
query.push(("until_id", until_id.clone()));
}
query
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Event {
pub id: Option<String>,
pub name: String,
pub data: String,
}
impl Event {
pub fn json<T: DeserializeOwned>(&self) -> Result<T> {
serde_json::from_str(&self.data).map_err(|source| Error::Decode {
path: "event stream".to_owned(),
body: self.data.clone(),
source,
})
}
}
impl From<eventsource_stream::Event> for Event {
fn from(event: eventsource_stream::Event) -> Self {
Self {
id: (!event.id.is_empty()).then_some(event.id),
name: event.event,
data: event.data,
}
}
}
pub(crate) fn stream_error(error: &eventsource_stream::EventStreamError<reqwest::Error>) -> Error {
match error {
eventsource_stream::EventStreamError::Transport(source) => {
Error::Stream(format!("event stream failed: {source}"))
}
eventsource_stream::EventStreamError::Utf8(source) => {
Error::Stream(format!("event stream was not valid utf-8: {source}"))
}
eventsource_stream::EventStreamError::Parser(source) => {
Error::Stream(format!("event stream was malformed: {source}"))
}
}
}
pub(crate) fn streaming_client(credentials: &Credentials) -> Result<reqwest::Client> {
let mut headers = reqwest::header::HeaderMap::new();
credentials.apply(&mut headers)?;
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(crate::config::user_agent()),
);
reqwest::Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(Error::transport)
}
#[cfg(feature = "broker")]
pub(crate) fn download_client(
credentials: &Credentials,
timeout: Option<std::time::Duration>,
) -> Result<reqwest::Client> {
let mut headers = reqwest::header::HeaderMap::new();
credentials.apply(&mut headers)?;
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(crate::config::user_agent()),
);
let mut builder = reqwest::Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::limited(10));
if let Some(timeout) = timeout {
builder = builder.timeout(timeout);
}
builder.build().map_err(Error::transport)
}
pub(crate) async fn subscribe(
http: &reqwest::Client,
url: &str,
path: &str,
query: &[(&'static str, String)],
) -> Result<impl Stream<Item = Result<Event>> + use<>> {
use eventsource_stream::Eventsource as _;
let mut request = http
.get(url)
.header(reqwest::header::CONNECTION, "keep-alive")
.header(reqwest::header::CACHE_CONTROL, "no-cache")
.header(reqwest::header::CONTENT_TYPE, "text/event-stream")
.header(reqwest::header::ACCEPT, "text/event-stream");
if !query.is_empty() {
request = request.query(query);
}
let response = request.send().await.map_err(Error::transport)?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(Error::Api(ApiError::from_body(status.as_u16(), path, body)));
}
Ok(response
.bytes_stream()
.eventsource()
.map(|event| match event {
Ok(event) => Ok(Event::from(event)),
Err(error) => Err(stream_error(&error)),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_id_yet_is_none_rather_than_an_empty_string() {
let event = Event::from(eventsource_stream::Event {
event: "message".to_owned(),
data: "{}".to_owned(),
id: String::new(),
retry: None,
});
assert_eq!(event.id, None);
assert_eq!(event.name, "message");
}
#[test]
fn the_timestamp_window_renders_as_rfc_3339_rather_than_a_date() {
let filter = EventStreamRequest::since("2026-03-20T12:24:58Z".parse().unwrap());
let query = filter.query();
assert_eq!(query.len(), 1);
assert_eq!(query[0].0, "since");
assert!(query[0].1.starts_with("2026-03-20T12:24:58"));
}
#[test]
fn an_empty_filter_sends_nothing() {
assert!(EventStreamRequest::default().query().is_empty());
}
#[tokio::test]
async fn a_rejected_subscription_is_labeled_with_the_given_path() {
use wiremock::matchers::{method, path as path_matcher};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_matcher("/v1/events/status"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&server)
.await;
let http = reqwest::Client::new();
let url = format!("{}/v1/events/status", server.uri());
match subscribe(&http, &url, "/status", &[]).await {
Err(Error::Api(api)) => {
assert_eq!(api.status, 403);
assert_eq!(api.path, "/status");
assert_eq!(api.message, "forbidden");
}
Err(other) => panic!("expected Api, got {other:?}"),
Ok(_) => panic!("expected the rejected subscription to error"),
}
}
#[test]
fn the_payload_deserializes_into_a_caller_chosen_type() {
let event = Event {
id: Some("1".to_owned()),
name: "message".to_owned(),
data: r#"{"status_to":"ACTIVE"}"#.to_owned(),
};
let value: serde_json::Value = event.json().unwrap();
assert_eq!(value["status_to"], "ACTIVE");
let error = event.json::<Vec<u8>>().unwrap_err();
assert!(matches!(error, Error::Decode { .. }));
}
}