From ec1225168d93d6fa13161e59164e1d0e32c637bd Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Fri, 9 Jan 2026 10:14:14 -0500 Subject: [PATCH 1/8] feat!: Replace hyper-specific interfaces with generic trait (#104) --- .github/workflows/ci.yml | 8 +- .github/workflows/release-please.yml | 3 +- contract-tests/Cargo.toml | 5 +- .../src/bin/sse-test-api/stream_entity.rs | 84 +++++- eventsource-client/Cargo.toml | 13 +- eventsource-client/README.md | 122 ++++++-- eventsource-client/examples/tail.rs | 54 ---- eventsource-client/src/client.rs | 271 +++++++----------- eventsource-client/src/error.rs | 5 + eventsource-client/src/event_parser.rs | 2 +- eventsource-client/src/lib.rs | 66 +++-- eventsource-client/src/response.rs | 33 ++- eventsource-client/src/transport.rs | 124 ++++++++ 13 files changed, 497 insertions(+), 293 deletions(-) delete mode 100644 eventsource-client/examples/tail.rs create mode 100644 eventsource-client/src/transport.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 043425d..a524f84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,15 @@ name: Run CI on: push: - branches: [main] + branches: + - "main" + - "feat/**" paths-ignore: - "**.md" # Do not need to run CI for markdown changes. pull_request: - branches: [main] + branches: + - "main" + - "feat/**" paths-ignore: - "**.md" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index ffaf013..18f0e9c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -3,7 +3,8 @@ name: Run Release Please on: push: branches: - - main + - "main" + - "feat/**" jobs: release-package: diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index b34b0ce..5092214 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -11,10 +11,11 @@ eventsource-client = { path = "../eventsource-client" } serde_json = { version = "1.0.39"} actix = { version = "0.13.1"} actix-web = { version = "4"} -reqwest = { version = "0.11.6", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } env_logger = { version = "0.10.0" } -hyper = { version = "0.14.19", features = ["client", "http1", "tcp"] } log = "0.4.6" +http = "1.0" +bytes = "1.5" [[bin]] name = "sse-test-api" diff --git a/contract-tests/src/bin/sse-test-api/stream_entity.rs b/contract-tests/src/bin/sse-test-api/stream_entity.rs index a46e189..118f12a 100644 --- a/contract-tests/src/bin/sse-test-api/stream_entity.rs +++ b/contract-tests/src/bin/sse-test-api/stream_entity.rs @@ -1,5 +1,5 @@ use actix_web::rt::task::JoinHandle; -use futures::TryStreamExt; +use futures::{StreamExt, TryStreamExt}; use log::error; use std::{ sync::{Arc, Mutex}, @@ -7,9 +7,78 @@ use std::{ }; use eventsource_client as es; +use eventsource_client::{ByteStream, HttpTransport, ResponseFuture, TransportError}; use crate::{Config, EventType}; +// Simple reqwest-based transport implementation +#[derive(Clone)] +struct ReqwestTransport { + client: reqwest::Client, +} + +impl ReqwestTransport { + fn new(timeout: Option) -> Result { + let mut builder = reqwest::Client::builder(); + + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + + let client = builder.build()?; + Ok(Self { client }) + } +} + +impl HttpTransport for ReqwestTransport { + fn request(&self, request: http::Request>) -> ResponseFuture { + let (parts, body_opt) = request.into_parts(); + + let mut req_builder = self + .client + .request(parts.method.clone(), parts.uri.to_string()); + + for (name, value) in parts.headers.iter() { + req_builder = req_builder.header(name, value); + } + + if let Some(body) = body_opt { + req_builder = req_builder.body(body); + } + + let req = match req_builder.build() { + Ok(r) => r, + Err(e) => return Box::pin(async move { Err(TransportError::new(e)) }), + }; + + let client = self.client.clone(); + + Box::pin(async move { + let resp = client.execute(req).await.map_err(TransportError::new)?; + + let status = resp.status(); + let headers = resp.headers().clone(); + + let byte_stream: ByteStream = Box::pin( + resp.bytes_stream() + .map(|result| result.map_err(TransportError::new)), + ); + + let mut response_builder = http::Response::builder().status(status); + + for (name, value) in headers.iter() { + response_builder = response_builder.header(name, value); + } + + let response = response_builder + .body(byte_stream) + .map_err(TransportError::new)?; + + Ok(response) + }) + } +} + pub(crate) struct Inner { callback_counter: Mutex, callback_url: String, @@ -102,9 +171,12 @@ impl Inner { reconnect_options = reconnect_options.delay(Duration::from_millis(delay_ms)); } - if let Some(read_timeout_ms) = config.read_timeout_ms { - client_builder = client_builder.read_timeout(Duration::from_millis(read_timeout_ms)); - } + // Create transport with timeout configuration + let timeout = config.read_timeout_ms.map(Duration::from_millis); + let transport = match ReqwestTransport::new(timeout) { + Ok(t) => t, + Err(e) => return Err(format!("Failed to create transport {:?}", e)), + }; if let Some(last_event_id) = &config.last_event_id { client_builder = client_builder.last_event_id(last_event_id.clone()); @@ -128,7 +200,9 @@ impl Inner { } Ok(Box::new( - client_builder.reconnect(reconnect_options.build()).build(), + client_builder + .reconnect(reconnect_options.build()) + .build_with_transport(transport), )) } } diff --git a/eventsource-client/Cargo.toml b/eventsource-client/Cargo.toml index 09c0f84..aa5d1e5 100644 --- a/eventsource-client/Cargo.toml +++ b/eventsource-client/Cargo.toml @@ -11,13 +11,12 @@ keywords = ["launchdarkly", "feature-flags", "feature-toggles", "eventsource", " exclude = ["CHANGELOG.md"] [dependencies] +bytes = "1.5" futures = "0.3.21" -hyper = { version = "0.14.19", features = ["client", "http1", "tcp"] } -hyper-rustls = { version = "0.24.1", optional = true } +http = "1.0" log = "0.4.6" pin-project = "1.0.10" tokio = { version = "1.17.0", features = ["time"] } -hyper-timeout = "0.4.1" rand = "0.8.5" base64 = "0.22.1" @@ -31,10 +30,6 @@ test-case = "3.2.1" proptest = "1.0.0" -[features] -default = ["rustls"] -rustls = ["hyper-rustls", "hyper-rustls/http2"] -[[example]] -name = "tail" -required-features = ["rustls"] +[features] +default = [] diff --git a/eventsource-client/README.md b/eventsource-client/README.md index c1e8dd9..2d1c312 100644 --- a/eventsource-client/README.md +++ b/eventsource-client/README.md @@ -4,41 +4,125 @@ Client for the [Server-Sent Events] protocol (aka [EventSource]). +This library focuses on the SSE protocol implementation. You provide the HTTP transport layer (hyper, reqwest, etc.), giving you full control over HTTP configuration like timeouts, TLS, and connection pooling. + [Server-Sent Events]: https://html.spec.whatwg.org/multipage/server-sent-events.html [EventSource]: https://developer.mozilla.org/en-US/docs/Web/API/EventSource ## Requirements -Requires tokio. +* Tokio async runtime +* An HTTP client library (hyper, reqwest, or custom) + +## Quick Start + +### 1. Add dependencies -## Usage +```toml +[dependencies] +eventsource-client = "0.17" +reqwest = { version = "0.12", features = ["stream"] } # or hyper v1 +futures = "0.3" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +### 2. Implement HttpTransport -Example that just prints the type of each event received: +Use one of our example implementations: ```rust -use eventsource_client as es; +// See examples/reqwest_transport.rs for complete implementation +use eventsource_client::{HttpTransport, ResponseFuture}; -let mut client = es::ClientBuilder::for_url("https://example.com/stream")? - .header("Authorization", "Basic username:password")? - .build(); +struct ReqwestTransport { + client: reqwest::Client, +} -client - .stream() - .map_ok(|event| println!("got event: {:?}", event)) - .map_err(|err| eprintln!("error streaming events: {:?}", err)); +impl HttpTransport for ReqwestTransport { + fn request(&self, request: http::Request<()>) -> ResponseFuture { + // Convert request and call HTTP client + // See examples/ for full implementation + } +} ``` -(Some boilerplate omitted for clarity; see [examples directory] for complete, -working code.) +### 3. Use the client + +```rust +use eventsource_client::{ClientBuilder, SSE}; +use futures::TryStreamExt; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create HTTP transport + let transport = ReqwestTransport::new()?; + + // Build SSE client + let client = ClientBuilder::for_url("https://example.com/stream")? + .header("Authorization", "Bearer token")? + .build_with_transport(transport); + + // Stream events + let mut stream = client.stream(); + + while let Some(event) = stream.try_next().await? { + match event { + SSE::Event(evt) => println!("Event: {}", evt.event_type), + SSE::Comment(c) => println!("Comment: {}", c), + SSE::Connected(_) => println!("Connected!"), + } + } + + Ok(()) +} +``` -[examples directory]: https://github.com/launchdarkly/rust-eventsource-client/tree/main/eventsource-client/examples ## Features -* tokio-based streaming client. -* Supports setting custom headers on the HTTP request (e.g. for endpoints - requiring authorization). -* Retry for failed connections. -* Reconnection if connection is interrupted, with exponential backoff. +* **Pluggable HTTP transport** - Use any HTTP client (hyper, reqwest, or custom) +* **Tokio-based streaming** - Efficient async/await support +* **Custom headers** - Full control over HTTP requests +* **Automatic reconnection** - Configurable exponential backoff +* **Retry logic** - Handle transient failures gracefully +* **Redirect following** - Automatic handling of HTTP redirects +* **Last-Event-ID** - Resume streams from last received event + +## Migration from v0.16 + +If you're upgrading from v0.16 (which used hyper 0.14 internally), see [MIGRATION.md](MIGRATION.md) for a detailed migration guide. + +Key changes: +- You must now provide an HTTP transport implementation +- Removed `build()`, `build_http()`, and other hyper-specific methods +- Use `build_with_transport(transport)` instead +- Timeout configuration moved to your HTTP transport + +## Why Pluggable Transport? + +1. **Use latest HTTP clients** - Not locked to a specific HTTP library version +2. **Full control** - Configure timeouts, TLS, proxies, etc. exactly as needed +3. **Smaller library** - Focused on SSE protocol, not HTTP implementation +4. **Flexibility** - Swap HTTP clients without changing SSE code + +## Architecture + +``` +┌─────────────────────────────────────┐ +│ Your Application │ +└─────────────┬───────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ eventsource-client │ +│ (SSE Protocol Implementation) │ +└─────────────┬───────────────────────┘ + │ HttpTransport trait + ▼ +┌─────────────────────────────────────┐ +│ Your HTTP Client │ +│ (hyper, reqwest, custom, etc.) │ +└─────────────────────────────────────┘ +``` ## Stability diff --git a/eventsource-client/examples/tail.rs b/eventsource-client/examples/tail.rs deleted file mode 100644 index 44fd6c3..0000000 --- a/eventsource-client/examples/tail.rs +++ /dev/null @@ -1,54 +0,0 @@ -use futures::{Stream, TryStreamExt}; -use std::{env, process, time::Duration}; - -use eventsource_client as es; - -#[tokio::main] -async fn main() -> Result<(), es::Error> { - env_logger::init(); - - let args: Vec = env::args().collect(); - - if args.len() != 3 { - eprintln!("Please pass args: "); - process::exit(1); - } - - let url = &args[1]; - let auth_header = &args[2]; - - let client = es::ClientBuilder::for_url(url)? - .header("Authorization", auth_header)? - .reconnect( - es::ReconnectOptions::reconnect(true) - .retry_initial(false) - .delay(Duration::from_secs(1)) - .backoff_factor(2) - .delay_max(Duration::from_secs(60)) - .build(), - ) - .build(); - - let mut stream = tail_events(client); - - while let Ok(Some(_)) = stream.try_next().await {} - - Ok(()) -} - -fn tail_events(client: impl es::Client) -> impl Stream> { - client - .stream() - .map_ok(|event| match event { - es::SSE::Connected(connection) => { - println!("got connected: \nstatus={}", connection.response().status()) - } - es::SSE::Event(ev) => { - println!("got an event: {}\n{}", ev.event_type, ev.data) - } - es::SSE::Comment(comment) => { - println!("got a comment: \n{}", comment) - } - }) - .map_err(|err| eprintln!("error streaming events: {:?}", err)) -} diff --git a/eventsource-client/src/client.rs b/eventsource-client/src/client.rs index 565f66f..a7f3f26 100644 --- a/eventsource-client/src/client.rs +++ b/eventsource-client/src/client.rs @@ -1,16 +1,7 @@ use base64::prelude::*; use futures::{ready, Stream}; -use hyper::{ - body::HttpBody, - client::{ - connect::{Connect, Connection}, - ResponseFuture, - }, - header::{HeaderMap, HeaderName, HeaderValue}, - service::Service, - Body, Request, Uri, -}; +use http::{HeaderMap, HeaderName, HeaderValue, Request, Uri}; use log::{debug, info, trace, warn}; use pin_project::pin_project; use std::{ @@ -18,40 +9,31 @@ use std::{ fmt::{self, Debug, Formatter}, future::Future, io::ErrorKind, - pin::{pin, Pin}, + pin::Pin, str::FromStr, + sync::Arc, task::{Context, Poll}, time::{Duration, Instant}, }; -use tokio::{ - io::{AsyncRead, AsyncWrite}, - time::Sleep, -}; +use tokio::time::Sleep; use crate::{ config::ReconnectOptions, response::{ErrorBody, Response}, + {ByteStream, HttpTransport, ResponseFuture}, }; use crate::{ error::{Error, Result}, event_parser::ConnectionDetails, }; -use hyper::client::HttpConnector; -use hyper_timeout::TimeoutConnector; - use crate::event_parser::EventParser; use crate::event_parser::SSE; use crate::retry::{BackoffRetry, RetryStrategy}; use std::error::Error as StdError; -#[cfg(feature = "rustls")] -use hyper_rustls::HttpsConnectorBuilder; - -type BoxError = Box; - /// Represents a [`Pin`]'d [`Send`] + [`Sync`] stream, returned by [`Client`]'s stream method. pub type BoxStream = Pin + Send + Sync>>; @@ -75,9 +57,6 @@ pub struct ClientBuilder { url: Uri, headers: HeaderMap, reconnect_opts: ReconnectOptions, - connect_timeout: Option, - read_timeout: Option, - write_timeout: Option, last_event_id: Option, method: String, body: Option, @@ -99,9 +78,6 @@ impl ClientBuilder { url, headers: header_map, reconnect_opts: ReconnectOptions::default(), - connect_timeout: None, - read_timeout: None, - write_timeout: None, last_event_id: None, method: String::from("GET"), max_redirects: None, @@ -148,25 +124,6 @@ impl ClientBuilder { self.header("Authorization", &value) } - /// Set a connect timeout for the underlying connection. There is no connect timeout by - /// default. - pub fn connect_timeout(mut self, connect_timeout: Duration) -> ClientBuilder { - self.connect_timeout = Some(connect_timeout); - self - } - - /// Set a read timeout for the underlying connection. There is no read timeout by default. - pub fn read_timeout(mut self, read_timeout: Duration) -> ClientBuilder { - self.read_timeout = Some(read_timeout); - self - } - - /// Set a write timeout for the underlying connection. There is no write timeout by default. - pub fn write_timeout(mut self, write_timeout: Duration) -> ClientBuilder { - self.write_timeout = Some(write_timeout); - self - } - /// Configure the client's reconnect behaviour according to the supplied /// [`ReconnectOptions`]. /// @@ -184,60 +141,28 @@ impl ClientBuilder { self } - /// Build with a specific client connector. - pub fn build_with_conn(self, conn: C) -> impl Client - where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, - C::Future: Send + 'static, - C::Error: Into, - { - let mut connector = TimeoutConnector::new(conn); - connector.set_connect_timeout(self.connect_timeout); - connector.set_read_timeout(self.read_timeout); - connector.set_write_timeout(self.write_timeout); - - let client = hyper::Client::builder().build::<_, hyper::Body>(connector); - - ClientImpl { - http: client, - request_props: RequestProps { - url: self.url, - headers: self.headers, - method: self.method, - body: self.body, - reconnect_opts: self.reconnect_opts, - max_redirects: self.max_redirects.unwrap_or(DEFAULT_REDIRECT_LIMIT), - }, - last_event_id: self.last_event_id, - } - } - - /// Build with an HTTP client connector. - pub fn build_http(self) -> impl Client { - self.build_with_conn(HttpConnector::new()) - } - - #[cfg(feature = "rustls")] - /// Build with an HTTPS client connector, using the OS root certificate store. - pub fn build(self) -> impl Client { - let conn = HttpsConnectorBuilder::new() - .with_native_roots() - .https_or_http() - .enable_http1() - .enable_http2() - .build(); - - self.build_with_conn(conn) - } - - /// Build with the given [`hyper::client::Client`]. - pub fn build_with_http_client(self, http: hyper::Client) -> impl Client + /// Build a client with a custom HTTP transport implementation. + /// + /// # Arguments + /// + /// * `transport` - An implementation of the [`HttpTransport`] trait that will handle + /// HTTP requests. See the `examples/` directory for reference implementations. + /// + /// # Example + /// + /// ```ignore + /// use eventsource_client::ClientBuilder; + /// + /// let transport = MyTransport::new(); + /// let client = ClientBuilder::for_url("https://example.com/events")? + /// .build_with_transport(transport); + /// ``` + pub fn build_with_transport(self, transport: T) -> impl Client where - C: Connect + Clone + Send + Sync + 'static, + T: HttpTransport, { ClientImpl { - http, + transport: Arc::new(transport), request_props: RequestProps { url: self.url, headers: self.headers, @@ -263,17 +188,13 @@ struct RequestProps { /// A client implementation that connects to a server using the Server-Sent Events protocol /// and consumes the event stream indefinitely. -/// Can be parameterized with different hyper Connectors, such as HTTP or HTTPS. -struct ClientImpl { - http: hyper::Client, +struct ClientImpl { + transport: Arc, request_props: RequestProps, last_event_id: Option, } -impl Client for ClientImpl -where - C: Connect + Clone + Send + Sync + 'static, -{ +impl Client for ClientImpl { /// Connect to the server and begin consuming the stream. Produces a /// [`Stream`] of [`Event`](crate::Event)s wrapped in [`Result`]. /// @@ -283,7 +204,7 @@ where /// reconnect for retryable errors. fn stream(&self) -> BoxStream> { Box::pin(ReconnectingRequest::new( - self.http.clone(), + Arc::clone(&self.transport), self.request_props.clone(), self.last_event_id.clone(), )) @@ -299,7 +220,7 @@ enum State { #[pin] resp: ResponseFuture, }, - Connected(#[pin] hyper::Body), + Connected(#[pin] ByteStream), WaitingToReconnect(#[pin] Sleep), FollowingRedirect(Option), StreamClosed, @@ -327,8 +248,8 @@ impl Debug for State { #[must_use = "streams do nothing unless polled"] #[pin_project] -pub struct ReconnectingRequest { - http: hyper::Client, +pub struct ReconnectingRequest { + transport: Arc, props: RequestProps, #[pin] state: State, @@ -341,12 +262,12 @@ pub struct ReconnectingRequest { initial_connection: bool, } -impl ReconnectingRequest { +impl ReconnectingRequest { fn new( - http: hyper::Client, + transport: Arc, props: RequestProps, last_event_id: Option, - ) -> ReconnectingRequest { + ) -> ReconnectingRequest { let reconnect_delay = props.reconnect_opts.delay; let delay_max = props.reconnect_opts.delay_max; let backoff_factor = props.reconnect_opts.backoff_factor; @@ -354,7 +275,7 @@ impl ReconnectingRequest { let url = props.url.clone(); ReconnectingRequest { props, - http, + transport, state: State::New, retry_strategy: Box::new(BackoffRetry::new( reconnect_delay, @@ -370,10 +291,7 @@ impl ReconnectingRequest { } } - fn send_request(&self) -> Result - where - C: Connect + Clone + Send + Sync + 'static, - { + fn send_request(&self) -> Result { let mut request_builder = Request::builder() .method(self.props.method.as_str()) .uri(&self.current_url); @@ -391,16 +309,13 @@ impl ReconnectingRequest { } } - let body = match &self.props.body { - Some(body) => Body::from(body.to_string()), - None => Body::empty(), - }; - + // Include the request body if set. Most SSE requests use GET and will have None, + // but some implementations (e.g., using REPORT method) may include a body. let request = request_builder - .body(body) + .body(self.props.body.clone()) .map_err(|e| Error::InvalidParameter(Box::new(e)))?; - Ok(self.http.request(request)) + Ok(self.transport.request(request)) } fn reset_redirects(self: Pin<&mut Self>) { @@ -419,10 +334,7 @@ impl ReconnectingRequest { } } -impl Stream for ReconnectingRequest -where - C: Connect + Clone + Send + Sync + 'static, -{ +impl Stream for ReconnectingRequest { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -477,7 +389,11 @@ where } StateProj::Connecting { retry, resp } => match ready!(resp.poll(cx)) { Ok(resp) => { - debug!("HTTP response: {:#?}", resp); + debug!( + "HTTP response status: {}, headers: {:?}", + resp.status(), + resp.headers() + ); if resp.status().is_success() { self.as_mut().project().retry_strategy.reset(Instant::now()); @@ -504,7 +420,7 @@ where debug!("following redirect {}", self.redirect_count); self.as_mut().project().state.set(State::FollowingRedirect( - resp.headers().get(hyper::header::LOCATION).cloned(), + resp.headers().get("location").cloned(), )); continue; } else { @@ -517,9 +433,13 @@ where } } + let status = resp.status(); + let headers = resp.headers().clone(); + let body = resp.into_body(); + let error = Error::UnexpectedResponse( - Response::new(resp.status(), resp.headers().clone()), - ErrorBody::new(resp.into_body()), + Response::new(status, headers), + ErrorBody::new(body), ); if !*retry { @@ -547,7 +467,7 @@ where warn!("request returned an error: {}", e); if !*retry { self.as_mut().project().state.set(State::StreamClosed); - return Poll::Ready(Some(Err(Error::HttpStream(Box::new(e))))); + return Poll::Ready(Some(Err(Error::Transport(e)))); } let duration = self @@ -572,7 +492,7 @@ where return Poll::Ready(Some(Err(e))); } }, - StateProj::Connected(body) => match ready!(body.poll_data(cx)) { + StateProj::Connected(mut body) => match ready!(body.as_mut().poll_next(cx)) { Some(Ok(result)) => { this.event_parser.process_bytes(result)?; continue; @@ -590,15 +510,16 @@ where .set(State::WaitingToReconnect(delay(duration, "reconnecting"))); } + // Check if the underlying error is a timeout if let Some(cause) = e.source() { if let Some(downcast) = cause.downcast_ref::() { if let std::io::ErrorKind::TimedOut = downcast.kind() { return Poll::Ready(Some(Err(Error::TimedOut))); } } - } else { - return Poll::Ready(Some(Err(Error::HttpStream(Box::new(e))))); } + + return Poll::Ready(Some(Err(Error::Transport(e)))); } None => { let duration = self @@ -651,15 +572,16 @@ fn delay(dur: Duration, description: &str) -> Sleep { mod private { use crate::client::ClientImpl; + use crate::HttpTransport; pub trait Sealed {} - impl Sealed for ClientImpl {} + impl Sealed for ClientImpl {} } #[cfg(test)] mod tests { use crate::ClientBuilder; - use hyper::http::HeaderValue; + use http::HeaderValue; use test_case::test_case; #[test_case("user", "pass", "dXNlcjpwYXNz")] @@ -685,40 +607,71 @@ mod tests { assert_eq!(Some(&expected), actual); } - use std::{pin::pin, str::FromStr, time::Duration}; + use std::{pin::pin, sync::Arc, time::Duration}; - use futures::TryStreamExt; - use hyper::{client::HttpConnector, Body, HeaderMap, Request, Uri}; - use hyper_timeout::TimeoutConnector; + use bytes::Bytes; + use futures::{stream, TryStreamExt}; + use http::HeaderMap; use tokio::time::timeout; use crate::{ client::{RequestProps, State}, ReconnectOptionsBuilder, ReconnectingRequest, + {ByteStream, HttpTransport, ResponseFuture, TransportError}, }; - const INVALID_URI: &'static str = "http://mycrazyunexsistenturl.invaliddomainext"; + // Mock transport for testing + #[derive(Clone)] + struct MockTransport { + fail_request: bool, + } + + impl MockTransport { + fn new(_url: String, fail_request: bool) -> Self { + Self { fail_request } + } + } + + impl HttpTransport for MockTransport { + fn request(&self, _request: http::Request>) -> ResponseFuture { + if self.fail_request { + // Simulate a connection error + Box::pin(async { + Err(TransportError::new(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused", + ))) + }) + } else { + // Return a 404 response + Box::pin(async { + let byte_stream: ByteStream = + Box::pin(stream::iter(vec![Ok(Bytes::from("not found"))])); + let response = http::Response::builder() + .status(404) + .body(byte_stream) + .unwrap(); + Ok(response) + }) + } + } + } + + const INVALID_URI: &str = "http://mycrazyunexsistenturl.invaliddomainext"; #[test_case(INVALID_URI, false, |state| matches!(state, State::StreamClosed))] #[test_case(INVALID_URI, true, |state| matches!(state, State::WaitingToReconnect(_)))] #[tokio::test] async fn initial_connection(uri: &str, retry_initial: bool, expected: fn(&State) -> bool) { - let default_timeout = Some(Duration::from_secs(1)); - let conn = HttpConnector::new(); - let mut connector = TimeoutConnector::new(conn); - connector.set_connect_timeout(default_timeout); - connector.set_read_timeout(default_timeout); - connector.set_write_timeout(default_timeout); - let reconnect_opts = ReconnectOptionsBuilder::new(false) .backoff_factor(1) .delay(Duration::from_secs(1)) .retry_initial(retry_initial) .build(); - let http = hyper::Client::builder().build::<_, hyper::Body>(connector); + let transport = Arc::new(MockTransport::new(uri.to_string(), true)); let req_props = RequestProps { - url: Uri::from_str(uri).unwrap(), + url: uri.parse().unwrap(), headers: HeaderMap::new(), method: "GET".to_string(), body: None, @@ -726,16 +679,10 @@ mod tests { max_redirects: 10, }; - let mut reconnecting_request = ReconnectingRequest::new(http, req_props, None); + let mut reconnecting_request = ReconnectingRequest::new(transport.clone(), req_props, None); - // sets initial state - let resp = reconnecting_request.http.request( - Request::builder() - .method("GET") - .uri(uri) - .body(Body::empty()) - .unwrap(), - ); + // sets initial state with a failing request + let resp = transport.request(http::Request::builder().uri(uri).body(None).unwrap()); reconnecting_request.state = State::Connecting { retry: reconnecting_request.props.reconnect_opts.retry_initial, diff --git a/eventsource-client/src/error.rs b/eventsource-client/src/error.rs index b4d60f5..7a1e08b 100644 --- a/eventsource-client/src/error.rs +++ b/eventsource-client/src/error.rs @@ -1,4 +1,5 @@ use crate::response::{ErrorBody, Response}; +use crate::TransportError; /// Error type for invalid response headers encountered in ResponseDetails. #[derive(Debug)] @@ -36,6 +37,8 @@ pub enum Error { UnexpectedResponse(Response, ErrorBody), /// An error reading from the HTTP response body. HttpStream(Box), + /// An error from the HTTP transport layer. + Transport(TransportError), /// The HTTP response stream ended Eof, /// The HTTP response stream ended unexpectedly (e.g. in the @@ -61,6 +64,7 @@ impl std::fmt::Display for Error { write!(f, "unexpected response: {status}") } HttpStream(err) => write!(f, "http error: {err}"), + Transport(err) => write!(f, "transport error: {err}"), Eof => write!(f, "eof"), UnexpectedEof => write!(f, "unexpected eof"), InvalidLine(line) => write!(f, "invalid line: {line}"), @@ -96,6 +100,7 @@ impl Error { pub fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Error::HttpStream(err) => Some(err.as_ref()), + Error::Transport(err) => Some(err), _ => None, } } diff --git a/eventsource-client/src/event_parser.rs b/eventsource-client/src/event_parser.rs index 79a67d3..8856a49 100644 --- a/eventsource-client/src/event_parser.rs +++ b/eventsource-client/src/event_parser.rs @@ -1,6 +1,6 @@ use std::{collections::VecDeque, convert::TryFrom, str::from_utf8}; -use hyper::body::Bytes; +use bytes::Bytes; use log::{debug, log_enabled, trace}; use pin_project::pin_project; diff --git a/eventsource-client/src/lib.rs b/eventsource-client/src/lib.rs index 52e9611..e1a5111 100644 --- a/eventsource-client/src/lib.rs +++ b/eventsource-client/src/lib.rs @@ -1,31 +1,53 @@ #![warn(rust_2018_idioms)] //! Client for the [Server-Sent Events] protocol (aka [EventSource]). //! -//! ``` -//! use futures::{TryStreamExt}; -//! # use eventsource_client::Error; -//! use eventsource_client::{Client, SSE}; -//! # #[tokio::main] -//! # async fn main() -> Result<(), eventsource_client::Error> { -//! let mut client = eventsource_client::ClientBuilder::for_url("https://example.com/stream")? -//! .header("Authorization", "Basic username:password")? -//! .build(); -//! -//! let mut stream = Box::pin(client.stream()) -//! .map_ok(|event| match event { -//! SSE::Comment(comment) => println!("got a comment event: {:?}", comment), -//! SSE::Event(evt) => println!("got an event: {}", evt.event_type), -//! SSE::Connected(_) => println!("got connected") -//! }) -//! .map_err(|e| println!("error streaming events: {:?}", e)); -//! # while let Ok(Some(_)) = stream.try_next().await {} -//! # +//! This library provides SSE protocol support but requires you to bring your own +//! HTTP transport. See the `examples/` directory for reference implementations using +//! popular HTTP clients like hyper and reqwest. +//! +//! # Getting Started +//! +//! ```ignore +//! use futures::TryStreamExt; +//! use eventsource_client::{Client, ClientBuilder, SSE}; +//! +//! # async fn example() -> Result<(), Box> { +//! // You need to implement HttpTransport trait for your HTTP client +//! // See examples/hyper_transport.rs or examples/reqwest_transport.rs for reference implementations +//! # struct MyTransport; +//! # impl eventsource_client::HttpTransport for MyTransport { +//! # fn request(&self, _req: http::Request>) -> eventsource_client::ResponseFuture { +//! # unimplemented!() +//! # } +//! # } +//! let transport = MyTransport::new(); +//! +//! let client = ClientBuilder::for_url("https://example.com/stream")? +//! .header("Authorization", "Bearer token")? +//! .build_with_transport(transport); +//! +//! let mut stream = client.stream(); +//! +//! while let Some(event) = stream.try_next().await? { +//! match event { +//! SSE::Event(evt) => println!("Event: {}", evt.event_type), +//! SSE::Comment(comment) => println!("Comment: {}", comment), +//! SSE::Connected(_) => println!("Connected!"), +//! } +//! } //! # Ok(()) //! # } //! ``` //! -//![Server-Sent Events]: https://html.spec.whatwg.org/multipage/server-sent-events.html -//![EventSource]: https://developer.mozilla.org/en-US/docs/Web/API/EventSource +//! # Implementing a Transport +//! +//! See the [`transport`] module documentation for details on implementing +//! the [`HttpTransport`] trait. +//! +//! [`HttpTransport`]: HttpTransport +//! +//! [Server-Sent Events]: https://html.spec.whatwg.org/multipage/server-sent-events.html +//! [EventSource]: https://developer.mozilla.org/en-US/docs/Web/API/EventSource mod client; mod config; @@ -33,6 +55,7 @@ mod error; mod event_parser; mod response; mod retry; +mod transport; pub use client::*; pub use config::*; @@ -40,3 +63,4 @@ pub use error::*; pub use event_parser::Event; pub use event_parser::SSE; pub use response::Response; +pub use transport::*; diff --git a/eventsource-client/src/response.rs b/eventsource-client/src/response.rs index 4e2eced..07991bc 100644 --- a/eventsource-client/src/response.rs +++ b/eventsource-client/src/response.rs @@ -1,34 +1,33 @@ -use hyper::body::Buf; -use hyper::{header::HeaderValue, Body, HeaderMap, StatusCode}; +use http::{HeaderMap, HeaderValue, StatusCode}; -use crate::{Error, HeaderError}; +use crate::{ByteStream, HeaderError}; +/// Represents an error response body as a stream of bytes. +/// +/// The body is provided as a stream so that users can read error details if needed. +/// For large error responses, the stream allows processing without loading the entire +/// response into memory. pub struct ErrorBody { - body: Body, + body: ByteStream, } impl ErrorBody { - pub fn new(body: Body) -> Self { + /// Create a new ErrorBody from a ByteStream + pub fn new(body: ByteStream) -> Self { Self { body } } - /// Returns the body of the response as a vector of bytes. - /// - /// Caution: This method reads the entire body into memory. You should only use this method if - /// you know the response is of a reasonable size. - pub async fn body_bytes(self) -> Result, Error> { - let buf = match hyper::body::aggregate(self.body).await { - Ok(buf) => buf, - Err(err) => return Err(Error::HttpStream(Box::new(err))), - }; - - Ok(buf.chunk().to_vec()) + /// Consume this ErrorBody and return the underlying ByteStream + pub fn into_stream(self) -> ByteStream { + self.body } } impl std::fmt::Debug for ErrorBody { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ErrorBody").finish() + f.debug_struct("ErrorBody") + .field("body", &"") + .finish() } } diff --git a/eventsource-client/src/transport.rs b/eventsource-client/src/transport.rs new file mode 100644 index 0000000..7867a74 --- /dev/null +++ b/eventsource-client/src/transport.rs @@ -0,0 +1,124 @@ +//! HTTP transport abstraction for Server-Sent Events client. +//! +//! This module defines the [`HttpTransport`] trait which allows users to plug in +//! their own HTTP client implementation (hyper, reqwest, or custom). +//! +//! # Example +//! +//! See the `examples/` directory for reference implementations using popular HTTP clients. + +use bytes::Bytes; +use futures::Stream; +use std::error::Error as StdError; +use std::fmt; +use std::future::Future; +use std::pin::Pin; + +// Re-export http crate types for convenience +pub use http::{HeaderMap, HeaderValue, Request, Response, StatusCode, Uri}; + +/// A pinned, boxed stream of bytes returned by HTTP transports. +/// +/// This represents the streaming response body from an HTTP request. +pub type ByteStream = Pin> + Send + Sync>>; + +/// A pinned, boxed future for an HTTP response. +/// +/// This represents the future returned by [`HttpTransport::request`]. +pub type ResponseFuture = + Pin, TransportError>> + Send + Sync>>; + +/// Error type for HTTP transport operations. +/// +/// This wraps transport-specific errors (network failures, timeouts, etc.) in a +/// common error type that the SSE client can handle uniformly. +#[derive(Debug)] +pub struct TransportError { + inner: Box, +} + +impl TransportError { + /// Create a new transport error from any error type. + pub fn new(err: impl StdError + Send + Sync + 'static) -> Self { + Self { + inner: Box::new(err), + } + } + + /// Get a reference to the inner error. + pub fn inner(&self) -> &(dyn StdError + Send + Sync + 'static) { + &*self.inner + } +} + +impl fmt::Display for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "transport error: {}", self.inner) + } +} + +impl StdError for TransportError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&*self.inner) + } +} + +/// Trait for pluggable HTTP transport implementations. +/// +/// Implement this trait to provide HTTP request/response functionality for the +/// SSE client. The transport is responsible for: +/// - Establishing HTTP connections (with TLS if needed) +/// - Sending HTTP requests +/// - Returning streaming HTTP responses +/// - Handling timeouts (if desired) +/// +/// # Example +/// +/// ```ignore +/// use eventsource_client::{HttpTransport, ByteStream, TransportError}; +/// use std::pin::Pin; +/// use std::future::Future; +/// +/// struct MyTransport { +/// // Your HTTP client here +/// } +/// +/// impl HttpTransport for MyTransport { +/// fn request( +/// &self, +/// request: http::Request>, +/// ) -> Pin, TransportError>> + Send>> { +/// // Extract body from request +/// // Convert request to your HTTP client's format +/// // Make the request +/// // Return streaming response +/// todo!() +/// } +/// } +/// ``` +pub trait HttpTransport: Send + Sync + 'static { + /// Execute an HTTP request and return a streaming response. + /// + /// # Arguments + /// + /// * `request` - The HTTP request to execute. The body type is `Option` + /// to support methods like REPORT that may include a request body. Most SSE + /// requests use GET and will have `None` as the body. + /// + /// # Returns + /// + /// A future that resolves to an HTTP response with a streaming body, or a + /// transport error if the request fails. + /// + /// The response should include: + /// - Status code + /// - Response headers + /// - A stream of body bytes + /// + /// # Notes + /// + /// - The transport should NOT follow redirects - the SSE client handles this + /// - The transport should NOT retry requests - the SSE client handles this + /// - The transport MAY implement timeouts as desired + fn request(&self, request: Request>) -> ResponseFuture; +} From bf19d4372f953b648356d34eafa451b1bc6eb665 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Mon, 12 Jan 2026 12:05:31 -0500 Subject: [PATCH 2/8] feat: Implement Hyper specific HttpTransport (#105) --- .github/actions/ci/action.yml | 4 + Makefile | 2 +- contract-tests/Cargo.toml | 5 +- .../src/bin/sse-test-api/stream_entity.rs | 100 +-- eventsource-client/Cargo.toml | 16 +- eventsource-client/README.md | 116 +-- eventsource-client/examples/tail.rs | 123 ++++ eventsource-client/src/client.rs | 13 +- eventsource-client/src/event_parser.rs | 10 +- eventsource-client/src/lib.rs | 4 + eventsource-client/src/transport.rs | 2 +- eventsource-client/src/transport_hyper.rs | 684 ++++++++++++++++++ 12 files changed, 934 insertions(+), 145 deletions(-) create mode 100644 eventsource-client/examples/tail.rs create mode 100644 eventsource-client/src/transport_hyper.rs diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index e45beab..63050cc 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -12,6 +12,10 @@ runs: shell: bash run: cargo test --all-features -p eventsource-client + - name: Run slower integration tests + shell: bash + run: cargo test --all-features -p eventsource-client --lib -- --ignored + - name: Run clippy checks shell: bash run: cargo clippy --all-features -p eventsource-client -- -D warnings diff --git a/Makefile b/Makefile index 6201a3f..dc672d7 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ start-contract-test-service-bg: @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & run-contract-tests: - @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v2.0.0/downloader/run.sh \ + @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/main/downloader/run.sh \ | VERSION=v2 PARAMS="-url http://localhost:8080 -debug -stop-service-at-end $(SKIPFLAGS) $(TEST_HARNESS_PARAMS)" sh contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index 5092214..a6ae094 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -7,11 +7,12 @@ license = "Apache-2.0" [dependencies] futures = { version = "0.3.21" } serde = { version = "1.0", features = ["derive"] } -eventsource-client = { path = "../eventsource-client" } +eventsource-client = { path = "../eventsource-client", features = ["hyper", "hyper-rustls"] } serde_json = { version = "1.0.39"} actix = { version = "0.13.1"} actix-web = { version = "4"} -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +# reqwest is required for callback client (test harness communication) +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } env_logger = { version = "0.10.0" } log = "0.4.6" http = "1.0" diff --git a/contract-tests/src/bin/sse-test-api/stream_entity.rs b/contract-tests/src/bin/sse-test-api/stream_entity.rs index 118f12a..ba36a91 100644 --- a/contract-tests/src/bin/sse-test-api/stream_entity.rs +++ b/contract-tests/src/bin/sse-test-api/stream_entity.rs @@ -1,5 +1,5 @@ use actix_web::rt::task::JoinHandle; -use futures::{StreamExt, TryStreamExt}; +use futures::TryStreamExt; use log::error; use std::{ sync::{Arc, Mutex}, @@ -7,78 +7,10 @@ use std::{ }; use eventsource_client as es; -use eventsource_client::{ByteStream, HttpTransport, ResponseFuture, TransportError}; +use eventsource_client::HyperTransport; use crate::{Config, EventType}; -// Simple reqwest-based transport implementation -#[derive(Clone)] -struct ReqwestTransport { - client: reqwest::Client, -} - -impl ReqwestTransport { - fn new(timeout: Option) -> Result { - let mut builder = reqwest::Client::builder(); - - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - - let client = builder.build()?; - Ok(Self { client }) - } -} - -impl HttpTransport for ReqwestTransport { - fn request(&self, request: http::Request>) -> ResponseFuture { - let (parts, body_opt) = request.into_parts(); - - let mut req_builder = self - .client - .request(parts.method.clone(), parts.uri.to_string()); - - for (name, value) in parts.headers.iter() { - req_builder = req_builder.header(name, value); - } - - if let Some(body) = body_opt { - req_builder = req_builder.body(body); - } - - let req = match req_builder.build() { - Ok(r) => r, - Err(e) => return Box::pin(async move { Err(TransportError::new(e)) }), - }; - - let client = self.client.clone(); - - Box::pin(async move { - let resp = client.execute(req).await.map_err(TransportError::new)?; - - let status = resp.status(); - let headers = resp.headers().clone(); - - let byte_stream: ByteStream = Box::pin( - resp.bytes_stream() - .map(|result| result.map_err(TransportError::new)), - ); - - let mut response_builder = http::Response::builder().status(status); - - for (name, value) in headers.iter() { - response_builder = response_builder.header(name, value); - } - - let response = response_builder - .body(byte_stream) - .map_err(TransportError::new)?; - - Ok(response) - }) - } -} - pub(crate) struct Inner { callback_counter: Mutex, callback_url: String, @@ -116,7 +48,7 @@ impl Inner { Ok(None) => break, Err(e) => { let failure = EventType::Error { - error: format!("Error: {:?}", e), + error: format!("Error: {e:?}"), }; if !self.send_message(failure, &client).await { @@ -131,7 +63,7 @@ impl Inner { let json = match serde_json::to_string(&event_type) { Ok(s) => s, Err(e) => { - error!("Failed to json encode event type {:?}", e); + error!("Failed to json encode event type {e:?}"); return false; } }; @@ -142,7 +74,7 @@ impl Inner { match client .post(format!("{}/{}", self.callback_url, counter_val)) - .body(format!("{}\n", json)) + .body(format!("{json}\n")) .send() .await { @@ -151,7 +83,7 @@ impl Inner { *counter = counter_val + 1 } Err(e) => { - error!("Failed to send post back to test harness {:?}", e); + error!("Failed to send post back to test harness {e:?}"); return false; } }; @@ -162,7 +94,7 @@ impl Inner { fn build_client(config: &Config) -> Result, String> { let mut client_builder = match es::ClientBuilder::for_url(&config.stream_url) { Ok(cb) => cb, - Err(e) => return Err(format!("Failed to create client builder {:?}", e)), + Err(e) => return Err(format!("Failed to create client builder {e:?}")), }; let mut reconnect_options = es::ReconnectOptions::reconnect(true); @@ -171,13 +103,6 @@ impl Inner { reconnect_options = reconnect_options.delay(Duration::from_millis(delay_ms)); } - // Create transport with timeout configuration - let timeout = config.read_timeout_ms.map(Duration::from_millis); - let transport = match ReqwestTransport::new(timeout) { - Ok(t) => t, - Err(e) => return Err(format!("Failed to create transport {:?}", e)), - }; - if let Some(last_event_id) = &config.last_event_id { client_builder = client_builder.last_event_id(last_event_id.clone()); } @@ -194,11 +119,20 @@ impl Inner { for (name, value) in headers { client_builder = match client_builder.header(name, value) { Ok(cb) => cb, - Err(e) => return Err(format!("Unable to set header {:?}", e)), + Err(e) => return Err(format!("Unable to set header {e:?}")), }; } } + // Build with HyperTransport + let mut transport_builder = HyperTransport::builder(); + + if let Some(timeout_ms) = config.read_timeout_ms { + transport_builder = transport_builder.read_timeout(Duration::from_millis(timeout_ms)); + } + + let transport = transport_builder.build_https(); + Ok(Box::new( client_builder .reconnect(reconnect_options.build()) diff --git a/eventsource-client/Cargo.toml b/eventsource-client/Cargo.toml index aa5d1e5..7a32be4 100644 --- a/eventsource-client/Cargo.toml +++ b/eventsource-client/Cargo.toml @@ -20,6 +20,16 @@ tokio = { version = "1.17.0", features = ["time"] } rand = "0.8.5" base64 = "0.22.1" +# +# Dependencies for hyper transport +# +hyper = { version = "1.0", features = ["client", "http1", "http2"], optional = true } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } +http-body-util = { version = "0.1", optional = true } +hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "webpki-roots"], optional = true } +hyper-timeout = { version = "0.5", optional = true } +tower = { version = "0.4", optional = true } + [dev-dependencies] env_logger = "0.10.0" maplit = "1.0.1" @@ -29,7 +39,7 @@ tokio = { version = "1.2.0", features = ["macros", "rt-multi-thread"] } test-case = "3.2.1" proptest = "1.0.0" - - [features] -default = [] +default = ["hyper"] +hyper = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-timeout", "dep:tower"] +hyper-rustls = ["dep:hyper-rustls"] diff --git a/eventsource-client/README.md b/eventsource-client/README.md index 2d1c312..b022699 100644 --- a/eventsource-client/README.md +++ b/eventsource-client/README.md @@ -4,7 +4,7 @@ Client for the [Server-Sent Events] protocol (aka [EventSource]). -This library focuses on the SSE protocol implementation. You provide the HTTP transport layer (hyper, reqwest, etc.), giving you full control over HTTP configuration like timeouts, TLS, and connection pooling. +This library provides a complete SSE protocol implementation with a built-in HTTP transport powered by hyper v1. The pluggable transport design also allows you to use your own HTTP client (reqwest, custom, etc.) if needed. [Server-Sent Events]: https://html.spec.whatwg.org/multipage/server-sent-events.html [EventSource]: https://developer.mozilla.org/en-US/docs/Web/API/EventSource @@ -12,7 +12,8 @@ This library focuses on the SSE protocol implementation. You provide the HTTP tr ## Requirements * Tokio async runtime -* An HTTP client library (hyper, reqwest, or custom) +* Enable the `hyper` feature for the built-in HTTP transport (enabled by default) +* Optionally enable `hyper-rustls` for HTTPS support ## Quick Start @@ -20,42 +21,29 @@ This library focuses on the SSE protocol implementation. You provide the HTTP tr ```toml [dependencies] -eventsource-client = "0.17" -reqwest = { version = "0.12", features = ["stream"] } # or hyper v1 +eventsource-client = { version = "0.17", features = ["hyper", "hyper-rustls"] } futures = "0.3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` -### 2. Implement HttpTransport +**Features:** +- `hyper` - Enables the built-in `HyperTransport` for HTTP support (enabled by default) +- `hyper-rustls` - Adds HTTPS support via rustls (optional) -Use one of our example implementations: +### 2. Use the client ```rust -// See examples/reqwest_transport.rs for complete implementation -use eventsource_client::{HttpTransport, ResponseFuture}; - -struct ReqwestTransport { - client: reqwest::Client, -} - -impl HttpTransport for ReqwestTransport { - fn request(&self, request: http::Request<()>) -> ResponseFuture { - // Convert request and call HTTP client - // See examples/ for full implementation - } -} -``` - -### 3. Use the client - -```rust -use eventsource_client::{ClientBuilder, SSE}; +use eventsource_client::{ClientBuilder, HyperTransport, SSE}; use futures::TryStreamExt; +use std::time::Duration; #[tokio::main] async fn main() -> Result<(), Box> { - // Create HTTP transport - let transport = ReqwestTransport::new()?; + // Create HTTP transport with timeouts + let transport = HyperTransport::builder() + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .build_https(); // or .build_http() for plain HTTP // Build SSE client let client = ClientBuilder::for_url("https://example.com/stream")? @@ -77,9 +65,35 @@ async fn main() -> Result<(), Box> { } ``` +## Example + +The `tail` example demonstrates a complete SSE client using the built-in `HyperTransport`: + +**Run with HTTP:** +```bash +cargo run --example tail --features hyper -- http://sse.dev/test "Bearer token" +``` + +**Run with HTTPS:** +```bash +cargo run --example tail --features hyper,hyper-rustls -- https://sse.dev/test "Bearer token" +``` + +The example shows: +- Creating a `HyperTransport` with custom timeouts +- Building an SSE client with authentication headers +- Configuring automatic reconnection with exponential backoff +- Handling different SSE event types (events, comments, connection status) +- Proper error handling for HTTPS URLs without the `hyper-rustls` feature + +See [`examples/tail.rs`](https://github.com/launchdarkly/rust-eventsource-client/tree/main/eventsource-client/examples/tail.rs) for the complete implementation. + ## Features -* **Pluggable HTTP transport** - Use any HTTP client (hyper, reqwest, or custom) +* **Built-in HTTP transport** - Production-ready `HyperTransport` powered by hyper v1 +* **Configurable timeouts** - Connect, read, and write timeout support +* **HTTPS support** - Optional rustls integration via the `hyper-rustls` feature +* **Pluggable transport** - Use a custom HTTP client if needed (reqwest, etc.) * **Tokio-based streaming** - Efficient async/await support * **Custom headers** - Full control over HTTP requests * **Automatic reconnection** - Configurable exponential backoff @@ -87,22 +101,36 @@ async fn main() -> Result<(), Box> { * **Redirect following** - Automatic handling of HTTP redirects * **Last-Event-ID** - Resume streams from last received event -## Migration from v0.16 +## Custom HTTP Transport -If you're upgrading from v0.16 (which used hyper 0.14 internally), see [MIGRATION.md](MIGRATION.md) for a detailed migration guide. +While the built-in `HyperTransport` works for most use cases, you can implement the `HttpTransport` trait to use your own HTTP client: -Key changes: -- You must now provide an HTTP transport implementation -- Removed `build()`, `build_http()`, and other hyper-specific methods -- Use `build_with_transport(transport)` instead -- Timeout configuration moved to your HTTP transport +```rust +use eventsource_client::{HttpTransport, ByteStream, TransportError}; +use std::pin::Pin; +use std::future::Future; -## Why Pluggable Transport? +#[derive(Clone)] +struct MyTransport { + // Your HTTP client here +} + +impl HttpTransport for MyTransport { + fn request( + &self, + request: http::Request>, + ) -> Pin, TransportError>> + Send + Sync + 'static>> { + // Implement HTTP request handling + // See the HttpTransport trait documentation for details + todo!() + } +} +``` -1. **Use latest HTTP clients** - Not locked to a specific HTTP library version -2. **Full control** - Configure timeouts, TLS, proxies, etc. exactly as needed -3. **Smaller library** - Focused on SSE protocol, not HTTP implementation -4. **Flexibility** - Swap HTTP clients without changing SSE code +This allows you to: +- Use a different HTTP client (reqwest, custom, etc.) +- Implement custom connection pooling or proxy logic +- Add specialized middleware or observability ## Architecture @@ -119,12 +147,12 @@ Key changes: │ HttpTransport trait ▼ ┌─────────────────────────────────────┐ -│ Your HTTP Client │ -│ (hyper, reqwest, custom, etc.) │ +│ HTTP Transport Layer │ +│ • HyperTransport (built-in) │ +│ • Custom (reqwest, etc.) │ └─────────────────────────────────────┘ ``` ## Stability -Early stage release for feedback purposes. May contain bugs or performance -issues. API subject to change. +This library is actively maintained. The SSE protocol implementation is stable. Breaking changes follow semantic versioning. diff --git a/eventsource-client/examples/tail.rs b/eventsource-client/examples/tail.rs new file mode 100644 index 0000000..c2368ca --- /dev/null +++ b/eventsource-client/examples/tail.rs @@ -0,0 +1,123 @@ +//! Example SSE client that tails an event stream +//! +//! This example uses the built-in HyperTransport for HTTP/HTTPS support. +//! +//! To run this example with HTTP support: +//! ```bash +//! cargo run --example tail --features hyper -- http://example.com/events "Bearer token" +//! ``` +//! +//! To run this example with HTTPS support: +//! ```bash +//! cargo run --example tail --features hyper,hyper-rustls -- https://example.com/events "Bearer token" +//! ``` + +use futures::{Stream, TryStreamExt}; +use std::{env, process, time::Duration}; + +use eventsource_client as es; + +#[tokio::main] +async fn main() -> Result<(), Box> { + env_logger::init(); + + let args: Vec = env::args().collect(); + + if args.len() != 3 { + eprintln!("Please pass args: "); + eprintln!("Example: cargo run --example tail --features hyper https://sse.dev/test 'Bearer token'"); + process::exit(1); + } + + let url = &args[1]; + let auth_header = &args[2]; + + // Run the appropriate version based on URL scheme and features + if url.starts_with("https://") { + #[cfg(feature = "hyper-rustls")] + { + run_with_https(url, auth_header).await?; + } + #[cfg(not(feature = "hyper-rustls"))] + { + eprintln!("Error: HTTPS URL requires the 'hyper-rustls' feature"); + eprintln!( + "Run with: cargo run --example tail --features hyper,hyper-rustls -- {} '{}'", + url, auth_header + ); + process::exit(1); + } + } else { + run_with_http(url, auth_header).await?; + } + + Ok(()) +} + +async fn run_with_http(url: &str, auth_header: &str) -> Result<(), Box> { + let transport = es::HyperTransport::builder() + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .build_http(); + + let client = es::ClientBuilder::for_url(url)? + .header("Authorization", auth_header)? + .reconnect( + es::ReconnectOptions::reconnect(true) + .retry_initial(false) + .delay(Duration::from_secs(1)) + .backoff_factor(2) + .delay_max(Duration::from_secs(60)) + .build(), + ) + .build_with_transport(transport); + + let mut stream = tail_events(client); + + while let Ok(Some(_)) = stream.try_next().await {} + + Ok(()) +} + +#[cfg(feature = "hyper-rustls")] +async fn run_with_https(url: &str, auth_header: &str) -> Result<(), Box> { + let transport = es::HyperTransport::builder() + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .build_https(); + + let client = es::ClientBuilder::for_url(url)? + .header("Authorization", auth_header)? + .reconnect( + es::ReconnectOptions::reconnect(true) + .retry_initial(false) + .delay(Duration::from_secs(1)) + .backoff_factor(2) + .delay_max(Duration::from_secs(60)) + .build(), + ) + .build_with_transport(transport); + + let mut stream = tail_events(client); + + while let Ok(Some(_)) = stream.try_next().await {} + + Ok(()) +} + +fn tail_events(client: impl es::Client) -> impl Stream> { + client + .stream() + .map_ok(|event| match event { + es::SSE::Connected(connection) => { + println!("got connected: \nstatus={}", connection.response().status()) + } + es::SSE::Event(ev) => { + println!("got an event: {}\n{}", ev.event_type, ev.data) + } + es::SSE::Comment(comment) => { + println!("got a comment: \n{comment}") + } + }) + .map_err(|err| eprintln!("error streaming events: {err:?}")) +} diff --git a/eventsource-client/src/client.rs b/eventsource-client/src/client.rs index a7f3f26..b679105 100644 --- a/eventsource-client/src/client.rs +++ b/eventsource-client/src/client.rs @@ -117,9 +117,9 @@ impl ClientBuilder { /// Set the Authorization header with the calculated basic authentication value. pub fn basic_auth(self, username: &str, password: &str) -> Result { - let auth = format!("{}:{}", username, password); + let auth = format!("{username}:{password}"); let encoded = BASE64_STANDARD.encode(auth); - let value = format!("Basic {}", encoded); + let value = format!("Basic {encoded}"); self.header("Authorization", &value) } @@ -154,7 +154,8 @@ impl ClientBuilder { /// use eventsource_client::ClientBuilder; /// /// let transport = MyTransport::new(); - /// let client = ClientBuilder::for_url("https://example.com/events")? + /// let client = ClientBuilder::for_url("https://sse.dev/test") + /// .expect("failed to create client builder") /// .build_with_transport(transport); /// ``` pub fn build_with_transport(self, transport: T) -> impl Client @@ -464,7 +465,7 @@ impl Stream for ReconnectingRequest { } Err(e) => { // This happens when the server is unreachable, e.g. connection refused. - warn!("request returned an error: {}", e); + warn!("request returned an error: {e}"); if !*retry { self.as_mut().project().state.set(State::StreamClosed); return Poll::Ready(Some(Err(Error::Transport(e)))); @@ -566,7 +567,7 @@ fn uri_from_header(maybe_header: &Option) -> Result { } fn delay(dur: Duration, description: &str) -> Sleep { - info!("Waiting {:?} before {}", dur, description); + info!("Waiting {dur:?} before {description}"); tokio::time::sleep(dur) } @@ -601,7 +602,7 @@ mod tests { .expect("failed to add authentication"); let actual = builder.headers.get("Authorization"); - let expected = HeaderValue::from_str(format!("Basic {}", expected).as_str()) + let expected = HeaderValue::from_str(format!("Basic {expected}").as_str()) .expect("unable to create expected header"); assert_eq!(Some(&expected), actual); diff --git a/eventsource-client/src/event_parser.rs b/eventsource-client/src/event_parser.rs index 8856a49..8e705fb 100644 --- a/eventsource-client/src/event_parser.rs +++ b/eventsource-client/src/event_parser.rs @@ -186,7 +186,7 @@ impl EventParser { } pub fn process_bytes(&mut self, bytes: Bytes) -> Result<()> { - trace!("Parsing bytes {:?}", bytes); + trace!("Parsing bytes {bytes:?}"); // We get bytes from the underlying stream in chunks. Decoding a chunk has two phases: // decode the chunk into lines, and decode the lines into events. // @@ -255,7 +255,7 @@ impl EventParser { Ok(retry) => { event_data.retry = Some(retry); } - _ => debug!("Failed to parse {:?} into retry value", value), + _ => debug!("Failed to parse {value:?} into retry value"), }; } } @@ -424,7 +424,7 @@ mod tests { match parse_field(b"\x80: invalid UTF-8") { Err(InvalidLine(msg)) => assert!(msg.contains("Utf8Error")), - res => panic!("expected InvalidLine error, got {:?}", res), + res => panic!("expected InvalidLine error, got {res:?}"), } } @@ -719,8 +719,8 @@ mod tests { } fn read_contents_from_file(name: &str) -> Vec { - std::fs::read(format!("test-data/{}", name)) - .unwrap_or_else(|_| panic!("couldn't read {}", name)) + std::fs::read(format!("test-data/{name}")) + .unwrap_or_else(|_| panic!("couldn't read {name}")) } proptest! { diff --git a/eventsource-client/src/lib.rs b/eventsource-client/src/lib.rs index e1a5111..545a46f 100644 --- a/eventsource-client/src/lib.rs +++ b/eventsource-client/src/lib.rs @@ -56,6 +56,8 @@ mod event_parser; mod response; mod retry; mod transport; +#[cfg(feature = "hyper")] +mod transport_hyper; pub use client::*; pub use config::*; @@ -64,3 +66,5 @@ pub use event_parser::Event; pub use event_parser::SSE; pub use response::Response; pub use transport::*; +#[cfg(feature = "hyper")] +pub use transport_hyper::*; diff --git a/eventsource-client/src/transport.rs b/eventsource-client/src/transport.rs index 7867a74..afa5825 100644 --- a/eventsource-client/src/transport.rs +++ b/eventsource-client/src/transport.rs @@ -96,7 +96,7 @@ impl StdError for TransportError { /// } /// } /// ``` -pub trait HttpTransport: Send + Sync + 'static { +pub trait HttpTransport: Send + Sync + Clone + 'static { /// Execute an HTTP request and return a streaming response. /// /// # Arguments diff --git a/eventsource-client/src/transport_hyper.rs b/eventsource-client/src/transport_hyper.rs new file mode 100644 index 0000000..ef52b86 --- /dev/null +++ b/eventsource-client/src/transport_hyper.rs @@ -0,0 +1,684 @@ +//! Hyper v1 transport implementation for eventsource-client +//! +//! This crate provides a production-ready [`HyperTransport`] implementation that +//! integrates hyper v1 with the eventsource-client library. +//! +//! # Example +//! +//! ```no_run +//! use eventsource_client::{ClientBuilder, HyperTransport}; +//! +//! # async fn example() -> Result<(), Box> { +//! let transport = HyperTransport::new(); +//! let client = ClientBuilder::for_url("https://example.com/stream")? +//! .build_with_transport(transport); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Features +//! +//! - `hyper-rustls`: Enable HTTPS support using rustls (via [`HyperTransport::builder().https()`]) +//! +//! # Timeout Configuration +//! +//! ```no_run +//! use eventsource_client::{ClientBuilder, HyperTransport}; +//! use std::time::Duration; +//! +//! # async fn example() -> Result<(), Box> { +//! let transport = HyperTransport::builder() +//! .connect_timeout(Duration::from_secs(10)) +//! .read_timeout(Duration::from_secs(30)) +//! .build_http(); +//! +//! let client = ClientBuilder::for_url("https://example.com/stream")? +//! .build_with_transport(transport); +//! # Ok(()) +//! # } +//! ``` + +use crate::{ByteStream, HttpTransport, TransportError}; +use bytes::Bytes; +use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full}; +use hyper::body::Incoming; +use hyper_timeout::TimeoutConnector; +use hyper_util::client::legacy::Client as HyperClient; +use hyper_util::rt::TokioExecutor; +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; + +/// A transport implementation using hyper v1.x +/// +/// This struct wraps a hyper client and implements the [`HttpTransport`] trait +/// for use with eventsource-client. +/// +/// # Timeout Support +/// +/// All three timeout types are fully supported via `hyper-timeout`: +/// - `connect_timeout` - Timeout for establishing the TCP connection +/// - `read_timeout` - Timeout for reading data from the connection +/// - `write_timeout` - Timeout for writing data to the connection +/// +/// Timeouts are configured using the builder pattern. See [`HyperTransportBuilder`] for details. +/// +/// # Example +/// +/// ```no_run +/// use eventsource_client::{ClientBuilder, HyperTransport}; +/// +/// # async fn example() -> Result<(), Box> { +/// // Create transport with default HTTP connector +/// let transport = HyperTransport::new(); +/// +/// // Build SSE client +/// let client = ClientBuilder::for_url("https://example.com/stream")? +/// .build_with_transport(transport); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct HyperTransport> +{ + client: HyperClient>>, +} + +/// Builder for configuring a [`HyperTransport`]. +/// +/// This builder allows you to configure timeouts and choose between HTTP and HTTPS connectors. +/// +/// # Example +/// +/// ```no_run +/// use eventsource_client::HyperTransport; +/// use std::time::Duration; +/// +/// let transport = HyperTransport::builder() +/// .connect_timeout(Duration::from_secs(10)) +/// .read_timeout(Duration::from_secs(30)) +/// .build_http(); +/// ``` +#[derive(Default)] +pub struct HyperTransportBuilder { + connect_timeout: Option, + read_timeout: Option, + write_timeout: Option, +} + +impl HyperTransport { + /// Create a new HyperTransport with default HTTP connector and no timeouts + /// + /// This creates a basic HTTP-only client that supports both HTTP/1 and HTTP/2. + /// For HTTPS support or timeout configuration, use [`HyperTransport::builder()`]. + pub fn new() -> Self { + let connector = hyper_util::client::legacy::connect::HttpConnector::new(); + let timeout_connector = TimeoutConnector::new(connector); + let client = HyperClient::builder(TokioExecutor::new()).build(timeout_connector); + + Self { client } + } + + /// Create a new HyperTransport with HTTPS support using rustls + /// + /// This creates an HTTPS client that supports both HTTP/1 and HTTP/2 protocols. + /// This method is only available when the `hyper-rustls` feature is enabled. + /// For timeout configuration, use [`HyperTransport::builder().build_https()`] instead. + /// + /// # Example + /// + /// ```no_run + /// # #[cfg(feature = "hyper-rustls")] + /// # { + /// use eventsource_client::{ClientBuilder, HyperTransport}; + /// + /// # async fn example() -> Result<(), Box> { + /// let transport = HyperTransport::new_https(); + /// let client = ClientBuilder::for_url("https://example.com/stream")? + /// .build_with_transport(transport); + /// # Ok(()) + /// # } + /// # } + /// ``` + #[cfg(feature = "hyper-rustls")] + pub fn new_https() -> HyperTransport< + TimeoutConnector< + hyper_rustls::HttpsConnector, + >, + > { + use hyper_rustls::HttpsConnectorBuilder; + + let connector = HttpsConnectorBuilder::new() + .with_webpki_roots() + .https_or_http() + .enable_http1() + .enable_http2() + .build(); + + let timeout_connector = TimeoutConnector::new(connector); + let client = HyperClient::builder(TokioExecutor::new()).build(timeout_connector); + + HyperTransport { client } + } + + /// Create a new builder for configuring HyperTransport + /// + /// The builder allows you to configure timeouts and choose between HTTP and HTTPS connectors. + /// + /// # Example + /// + /// ```no_run + /// use eventsource_client::HyperTransport; + /// use std::time::Duration; + /// + /// let transport = HyperTransport::builder() + /// .connect_timeout(Duration::from_secs(10)) + /// .read_timeout(Duration::from_secs(30)) + /// .build_http(); + /// ``` + pub fn builder() -> HyperTransportBuilder { + HyperTransportBuilder::default() + } +} + +impl HyperTransportBuilder { + /// Set a connect timeout for establishing connections + /// + /// This timeout applies when establishing the TCP connection to the server. + /// There is no connect timeout by default. + pub fn connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = Some(timeout); + self + } + + /// Set a read timeout for reading from connections + /// + /// This timeout applies when reading data from the connection. + /// There is no read timeout by default. + pub fn read_timeout(mut self, timeout: Duration) -> Self { + self.read_timeout = Some(timeout); + self + } + + /// Set a write timeout for writing to connections + /// + /// This timeout applies when writing data to the connection. + /// There is no write timeout by default. + pub fn write_timeout(mut self, timeout: Duration) -> Self { + self.write_timeout = Some(timeout); + self + } + + /// Build with an HTTP connector + /// + /// Creates a transport that supports HTTP/1 and HTTP/2 over plain HTTP. + pub fn build_http(self) -> HyperTransport { + let connector = hyper_util::client::legacy::connect::HttpConnector::new(); + self.build_with_connector(connector) + } + + /// Build with an HTTPS connector using rustls + /// + /// Creates a transport that supports HTTP/1 and HTTP/2 over HTTPS using rustls. + /// This method is only available when the `hyper-rustls` feature is enabled. + /// + /// # Example + /// + /// ```no_run + /// # #[cfg(feature = "hyper-rustls")] + /// # { + /// use eventsource_client::HyperTransport; + /// use std::time::Duration; + /// + /// let transport = HyperTransport::builder() + /// .connect_timeout(Duration::from_secs(10)) + /// .build_https(); + /// # } + /// ``` + #[cfg(feature = "hyper-rustls")] + pub fn build_https( + self, + ) -> HyperTransport< + TimeoutConnector< + hyper_rustls::HttpsConnector, + >, + > { + use hyper_rustls::HttpsConnectorBuilder; + + let connector = HttpsConnectorBuilder::new() + .with_webpki_roots() + .https_or_http() + .enable_http1() + .enable_http2() + .build(); + + self.build_with_connector(connector) + } + + /// Build with a custom connector + /// + /// This allows you to provide your own connector implementation, which is useful for: + /// - Custom TLS configuration + /// - Proxy support + /// - Connection pooling customization + /// - Custom DNS resolution + /// + /// The connector will be automatically wrapped with a `TimeoutConnector` that applies + /// the configured timeout settings. + /// + /// # Example + /// + /// ```no_run + /// use eventsource_client::HyperTransport; + /// use hyper_util::client::legacy::connect::HttpConnector; + /// use std::time::Duration; + /// + /// let mut connector = HttpConnector::new(); + /// // Configure the connector as needed + /// connector.set_nodelay(true); + /// + /// let transport = HyperTransport::builder() + /// .read_timeout(Duration::from_secs(30)) + /// .build_with_connector(connector); + /// ``` + pub fn build_with_connector(self, connector: C) -> HyperTransport> + where + C: tower::Service + Clone + Send + Sync + 'static, + C::Response: hyper_util::client::legacy::connect::Connection + + hyper::rt::Read + + hyper::rt::Write + + Send + + Unpin, + C::Future: Send + 'static, + C::Error: Into>, + { + let mut timeout_connector = TimeoutConnector::new(connector); + timeout_connector.set_connect_timeout(self.connect_timeout); + timeout_connector.set_read_timeout(self.read_timeout); + timeout_connector.set_write_timeout(self.write_timeout); + + let client = HyperClient::builder(TokioExecutor::new()).build(timeout_connector); + + HyperTransport { client } + } +} + +impl Default for HyperTransport { + fn default() -> Self { + Self::new() + } +} + +impl HttpTransport for HyperTransport +where + C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, +{ + fn request( + &self, + request: http::Request>, + ) -> Pin< + Box< + dyn Future, TransportError>> + + Send + + Sync + + 'static, + >, + > { + // Convert http::Request> to hyper::Request + let (parts, body_opt) = request.into_parts(); + + // Convert Option to BoxBody + let body: BoxBody> = match body_opt { + Some(body_str) => { + // Use Full for non-empty bodies + Full::new(Bytes::from(body_str)) + .map_err(|e| Box::new(e) as Box) + .boxed() + } + None => { + // Use Empty for no body + Empty::::new() + .map_err(|e| Box::new(e) as Box) + .boxed() + } + }; + + let hyper_req = hyper::Request::from_parts(parts, body); + + let client = self.client.clone(); + + Box::pin(async move { + // Make the request - timeouts are handled by TimeoutConnector + let resp = client + .request(hyper_req) + .await + .map_err(TransportError::new)?; + + let (parts, body) = resp.into_parts(); + + // Convert hyper's Incoming body to ByteStream + let byte_stream: ByteStream = Box::pin(body_to_stream(body)); + + Ok(http::Response::from_parts(parts, byte_stream)) + }) + } +} + +/// Convert hyper's Incoming body to a Stream of Bytes +fn body_to_stream( + body: Incoming, +) -> impl futures::Stream> + Send + Sync { + futures::stream::unfold(body, |mut body| async move { + match body.frame().await { + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + Some((Ok(data), body)) + } else { + // Skip non-data frames (trailers, etc.) + Some(( + Err(TransportError::new(std::io::Error::other("non-data frame"))), + body, + )) + } + } + Some(Err(e)) => Some((Err(TransportError::new(e)), body)), + None => None, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + use http::{Method, Request}; + use std::time::Duration; + + #[test] + fn test_hyper_transport_new() { + let transport = HyperTransport::new(); + // If we can create it without panic, the test passes + // This verifies the default HTTP connector is set up correctly + drop(transport); + } + + #[test] + fn test_hyper_transport_default() { + let transport = HyperTransport::default(); + // Verify Default trait implementation + drop(transport); + } + + #[cfg(feature = "hyper-rustls")] + #[test] + fn test_hyper_transport_new_https() { + let transport = HyperTransport::new_https(); + // If we can create it without panic, the test passes + // This verifies the HTTPS connector with rustls is set up correctly + drop(transport); + } + + #[test] + fn test_builder_default() { + let builder = HyperTransport::builder(); + let transport = builder.build_http(); + // Verify we can build with default settings + drop(transport); + } + + #[test] + fn test_builder_with_connect_timeout() { + let transport = HyperTransport::builder() + .connect_timeout(Duration::from_secs(5)) + .build_http(); + // Verify we can build with connect timeout + drop(transport); + } + + #[test] + fn test_builder_with_read_timeout() { + let transport = HyperTransport::builder() + .read_timeout(Duration::from_secs(10)) + .build_http(); + // Verify we can build with read timeout + drop(transport); + } + + #[test] + fn test_builder_with_write_timeout() { + let transport = HyperTransport::builder() + .write_timeout(Duration::from_secs(10)) + .build_http(); + // Verify we can build with write timeout + drop(transport); + } + + #[test] + fn test_builder_with_all_timeouts() { + let transport = HyperTransport::builder() + .connect_timeout(Duration::from_secs(5)) + .read_timeout(Duration::from_secs(30)) + .write_timeout(Duration::from_secs(10)) + .build_http(); + // Verify we can build with all timeouts configured + drop(transport); + } + + #[cfg(feature = "hyper-rustls")] + #[test] + fn test_builder_https() { + let transport = HyperTransport::builder() + .connect_timeout(Duration::from_secs(5)) + .read_timeout(Duration::from_secs(30)) + .build_https(); + // Verify we can build HTTPS transport with timeouts + drop(transport); + } + + #[test] + fn test_builder_with_custom_connector() { + let mut connector = hyper_util::client::legacy::connect::HttpConnector::new(); + connector.set_nodelay(true); + + let transport = HyperTransport::builder() + .read_timeout(Duration::from_secs(30)) + .build_with_connector(connector); + // Verify we can build with a custom connector + drop(transport); + } + + #[test] + fn test_transport_is_clone() { + let transport = HyperTransport::new(); + let _cloned = transport.clone(); + // Verify HyperTransport implements Clone + } + + #[tokio::test] + async fn test_http_transport_trait_implemented() { + let transport = HyperTransport::new(); + + // Create a basic request + let request = Request::builder() + .method(Method::GET) + .uri("http://httpbin.org/get") + .body(None) + .expect("failed to build request"); + + // Verify the trait is implemented by attempting to call it + // We're not actually making the request here, just verifying the types work + let _future = transport.request(request); + // The future exists and has the correct type signature + } + + #[tokio::test] + async fn test_request_with_empty_body() { + // This test verifies that we can construct a request with no body + let transport = HyperTransport::new(); + + let request = Request::builder() + .method(Method::GET) + .uri("http://httpbin.org/get") + .body(None) + .expect("failed to build request"); + + // Just verify we can create the future - not actually making network call + let _future = transport.request(request); + } + + #[tokio::test] + async fn test_request_with_string_body() { + // This test verifies that we can construct a request with a string body + let transport = HyperTransport::new(); + + let request = Request::builder() + .method(Method::POST) + .uri("http://httpbin.org/post") + .body(Some(String::from("test body"))) + .expect("failed to build request"); + + // Just verify we can create the future - not actually making network call + let _future = transport.request(request); + } + + #[tokio::test] + async fn test_body_to_stream_empty() { + // Create an empty incoming body for testing + // This is a bit tricky since Incoming is not easily constructible + // We'll test the integration through the full request path instead + + // For now, this is a placeholder showing the test structure + // A full implementation would require setting up a test HTTP server + } + + // Integration tests that actually make HTTP requests + // These require a running HTTP server, so they're marked as ignored by default + + #[tokio::test] + #[ignore] // Run with: cargo test -- --ignored + async fn test_integration_http_request() { + let transport = HyperTransport::builder() + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .build_http(); + + let request = Request::builder() + .method(Method::GET) + .uri("http://httpbin.org/get") + .body(None) + .expect("failed to build request"); + + let response = transport.request(request).await; + assert!(response.is_ok(), "Request should succeed"); + + let response = response.unwrap(); + assert!(response.status().is_success(), "Status should be success"); + + // Verify we can read from the stream + let mut stream = response.into_body(); + let mut received_data = false; + while let Some(result) = stream.next().await { + assert!(result.is_ok(), "Stream chunk should not error"); + received_data = true; + } + assert!(received_data, "Should have received some data"); + } + + #[cfg(feature = "hyper-rustls")] + #[tokio::test] + #[ignore] // Run with: cargo test -- --ignored + async fn test_integration_https_request() { + let transport = HyperTransport::builder() + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .build_https(); + + // Using example.com as it's highly reliable and well-maintained + let request = Request::builder() + .method(Method::GET) + .uri("https://example.com/") + .body(None) + .expect("failed to build request"); + + let response = transport.request(request).await; + assert!( + response.is_ok(), + "HTTPS request should succeed: {:?}", + response.as_ref().err() + ); + + let response = response.unwrap(); + assert!( + response.status().is_success(), + "Status should be success: {}", + response.status() + ); + } + + #[tokio::test] + #[ignore] // Run with: cargo test -- --ignored + async fn test_integration_request_with_body() { + let transport = HyperTransport::new(); + + let body_content = r#"{"test": "data"}"#; + let request = Request::builder() + .method(Method::POST) + .uri("http://httpbin.org/post") + .header("Content-Type", "application/json") + .body(Some(body_content.to_string())) + .expect("failed to build request"); + + let response = transport.request(request).await; + assert!(response.is_ok(), "POST request should succeed"); + + let response = response.unwrap(); + assert!(response.status().is_success(), "Status should be success"); + } + + #[tokio::test] + #[ignore] // Run with: cargo test -- --ignored + async fn test_integration_streaming_response() { + let transport = HyperTransport::new(); + + let request = Request::builder() + .method(Method::GET) + .uri("http://httpbin.org/stream/10") + .body(None) + .expect("failed to build request"); + + let response = transport.request(request).await; + assert!(response.is_ok(), "Streaming request should succeed"); + + let response = response.unwrap(); + assert!(response.status().is_success(), "Status should be success"); + + // Verify we receive multiple chunks + let mut stream = response.into_body(); + let mut chunk_count = 0; + while let Some(result) = stream.next().await { + assert!(result.is_ok(), "Stream chunk should not error"); + let chunk = result.unwrap(); + assert!(!chunk.is_empty(), "Chunk should not be empty"); + chunk_count += 1; + } + assert!(chunk_count > 0, "Should have received multiple chunks"); + } + + #[tokio::test] + #[ignore] // Run with: cargo test -- --ignored + async fn test_integration_connect_timeout() { + // Use a non-routable IP to test connect timeout + let transport = HyperTransport::builder() + .connect_timeout(Duration::from_millis(100)) + .build_http(); + + let request = Request::builder() + .method(Method::GET) + .uri("http://10.255.255.1/") + .body(None) + .expect("failed to build request"); + + let response = transport.request(request).await; + assert!(response.is_err(), "Request should timeout"); + } +} From 9cc74e2c5d060f70b18919bf12b5546cc7610e1d Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 13 Jan 2026 14:05:32 -0500 Subject: [PATCH 3/8] fix: Enable tls12 in hyper-rustls crate (#106) --- eventsource-client/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eventsource-client/Cargo.toml b/eventsource-client/Cargo.toml index 7a32be4..1b790e7 100644 --- a/eventsource-client/Cargo.toml +++ b/eventsource-client/Cargo.toml @@ -26,7 +26,7 @@ base64 = "0.22.1" hyper = { version = "1.0", features = ["client", "http1", "http2"], optional = true } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } http-body-util = { version = "0.1", optional = true } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "webpki-roots"], optional = true } +hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12", "webpki-roots"], optional = true } hyper-timeout = { version = "0.5", optional = true } tower = { version = "0.4", optional = true } @@ -42,4 +42,4 @@ proptest = "1.0.0" [features] default = ["hyper"] hyper = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-timeout", "dep:tower"] -hyper-rustls = ["dep:hyper-rustls"] +hyper-rustls = ["hyper", "dep:hyper-rustls"] From 893971c14b4516b79ac4592148c162dc21456b25 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 21 Jan 2026 16:45:48 -0500 Subject: [PATCH 4/8] fix: Use native root certs, falling back to pki if they fail to load (#107) --- eventsource-client/src/transport_hyper.rs | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/eventsource-client/src/transport_hyper.rs b/eventsource-client/src/transport_hyper.rs index ef52b86..16cd6fd 100644 --- a/eventsource-client/src/transport_hyper.rs +++ b/eventsource-client/src/transport_hyper.rs @@ -146,19 +146,7 @@ impl HyperTransport { hyper_rustls::HttpsConnector, >, > { - use hyper_rustls::HttpsConnectorBuilder; - - let connector = HttpsConnectorBuilder::new() - .with_webpki_roots() - .https_or_http() - .enable_http1() - .enable_http2() - .build(); - - let timeout_connector = TimeoutConnector::new(connector); - let client = HyperClient::builder(TokioExecutor::new()).build(timeout_connector); - - HyperTransport { client } + HyperTransport::builder().build_https() } /// Create a new builder for configuring HyperTransport @@ -246,7 +234,11 @@ impl HyperTransportBuilder { use hyper_rustls::HttpsConnectorBuilder; let connector = HttpsConnectorBuilder::new() - .with_webpki_roots() + .with_native_roots() + .unwrap_or_else(|_| { + log::debug!("Falling back to webpki roots for HTTPS connector"); + HttpsConnectorBuilder::new().with_webpki_roots() + }) .https_or_http() .enable_http1() .enable_http2() From 0ea657c0a6557fb113df046618f1923fcb3a144d Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 10 Feb 2026 10:18:15 -0500 Subject: [PATCH 5/8] chore: Merging main branch (#116) --- .github/variables/rust-versions.env | 1 + .github/workflows/check-rust-versions.yml | 72 +++++++++++++++++++++++ .github/workflows/ci.yml | 6 +- .github/workflows/manual-publish.yml | 6 +- .github/workflows/release-please.yml | 7 ++- .release-please-manifest.json | 2 +- README.md | 2 +- eventsource-client/CHANGELOG.md | 8 +++ eventsource-client/Cargo.toml | 4 +- eventsource-client/examples/tail.rs | 1 + eventsource-client/src/lib.rs | 1 + 11 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 .github/variables/rust-versions.env create mode 100644 .github/workflows/check-rust-versions.yml diff --git a/.github/variables/rust-versions.env b/.github/variables/rust-versions.env new file mode 100644 index 0000000..7fff3c1 --- /dev/null +++ b/.github/variables/rust-versions.env @@ -0,0 +1 @@ +target=1.91 diff --git a/.github/workflows/check-rust-versions.yml b/.github/workflows/check-rust-versions.yml new file mode 100644 index 0000000..70863ed --- /dev/null +++ b/.github/workflows/check-rust-versions.yml @@ -0,0 +1,72 @@ +name: Check Supported Rust Versions +on: + schedule: + - cron: "0 17 * * *" + workflow_dispatch: + +jobs: + check-rust-eol: + permissions: + contents: read + runs-on: ubuntu-latest + outputs: + target: ${{ steps.parse.outputs.target }} + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + # Perform a GET request to endoflife.date for the Rust language. The response + # contains all Rust releases; we're interested in the 2nd index (antepenultimate). + - name: Fetch officially supported Rust versions + uses: JamesIves/fetch-api-data-action@396ebea7d13904824f85b892b1616985f847301c + with: + endpoint: https://endoflife.date/api/rust.json + configuration: '{ "method": "GET" }' + debug: true + # Parse the response JSON and insert into environment variables for the next step. + # We use the antepenultimate (third most recent) version as our target. + - name: Parse officially supported Rust versions + id: parse + run: | + echo "target=${{ fromJSON(env.fetch-api-data)[2].cycle }}" >> $GITHUB_OUTPUT + + create-prs: + permissions: + contents: write + pull-requests: write + needs: check-rust-eol + runs-on: ubuntu-latest + env: + officialTargetVersion: ${{ needs.check-rust-eol.outputs.target }} + steps: + - uses: actions/checkout@v4 + + - name: Get current Rust version + id: rust-versions + run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT + + - name: Update rust-versions.env and Cargo.toml + if: steps.rust-versions.outputs.target != env.officialTargetVersion + id: update-rust-versions + run: | + # Update the versions file + sed -i -e "s#target=[^ ]*#target=${{ env.officialTargetVersion }}#g" \ + ./.github/variables/rust-versions.env + + # Update Cargo.toml rust-version field + sed -i -e "s#rust-version = \"[^\"]*\"#rust-version = \"${{ env.officialTargetVersion }}.0\"#g" \ + ./eventsource-client/Cargo.toml + + - name: Create pull request + if: steps.update-rust-versions.outcome == 'success' + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + add-paths: | + .github/variables/rust-versions.env + eventsource-client/Cargo.toml + branch: "launchdarklyreleasebot/update-to-rust${{ env.officialTargetVersion }}" + author: "LaunchDarklyReleaseBot " + committer: "LaunchDarklyReleaseBot " + title: "fix: Bump MSRV from ${{ steps.rust-versions.outputs.target }} to ${{ env.officialTargetVersion }}" + body: | + - [ ] I have triggered CI on this PR (either close & reopen this PR in Github UI, or `git commit -m "run ci" --allow-empty && git push`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a524f84..323487d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,13 @@ jobs: with: fetch-depth: 0 # If you only need the current version keep this. + - name: Get Rust version + id: rust-version + run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT + - name: Setup rust tooling run: | - rustup override set 1.83 + rustup override set ${{ steps.rust-version.outputs.target }} rustup component add rustfmt clippy - uses: ./.github/actions/ci diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 9f350b0..b9d1e0b 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -17,9 +17,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Get Rust version + id: rust-version + run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT + - name: Setup rust tooling run: | - rustup override set 1.83 + rustup override set ${{ steps.rust-version.outputs.target }} rustup component add rustfmt clippy - uses: ./.github/actions/ci diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 18f0e9c..2b00136 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -22,10 +22,15 @@ jobs: with: fetch-depth: 0 # If you only need the current version keep this. + - name: Get Rust version + if: ${{ steps.release.outputs['eventsource-client--release_created'] == 'true' }} + id: rust-version + run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT + - name: Setup rust tooling if: ${{ steps.release.outputs['eventsource-client--release_created'] == 'true' }} run: | - rustup override set 1.83 + rustup override set ${{ steps.rust-version.outputs.target }} rustup component add rustfmt clippy - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4a8f2f2..b1c0350 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - "eventsource-client": "0.16.0" + "eventsource-client": "0.16.1" } diff --git a/README.md b/README.md index a086bf7..73771cb 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,6 @@ issues. API subject to change. ## Minimum Supported Rust Version -This project aims to maintain compatibility with a Rust version that is at least six months old. +This project aims to maintain compatibility with the latest stable release of Rust in addition to the two prior minor releases. Version updates may occur more frequently than the policy guideline states if external forces require it. For example, a CVE in a downstream dependency requiring an MSRV bump would be considered an acceptable reason to violate the six month guideline. diff --git a/eventsource-client/CHANGELOG.md b/eventsource-client/CHANGELOG.md index b36705b..be2ff83 100644 --- a/eventsource-client/CHANGELOG.md +++ b/eventsource-client/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [0.16.1](https://github.com/launchdarkly/rust-eventsource-client/compare/0.16.0...0.16.1) (2026-01-26) + + +### Bug Fixes + +* Bump MSRV from 1.83 to 1.88 ([#110](https://github.com/launchdarkly/rust-eventsource-client/issues/110)) ([94cdd71](https://github.com/launchdarkly/rust-eventsource-client/commit/94cdd716bbcf0fc552b7a470142ccad9149848aa)) +* Bump MSRV from 1.88 to 1.91 ([#112](https://github.com/launchdarkly/rust-eventsource-client/issues/112)) ([78423ab](https://github.com/launchdarkly/rust-eventsource-client/commit/78423abfa406550d9270fba33aebc25e257b0216)) + ## [0.16.0](https://github.com/launchdarkly/rust-eventsource-client/compare/0.15.1...0.16.0) (2025-12-17) diff --git a/eventsource-client/Cargo.toml b/eventsource-client/Cargo.toml index 1b790e7..1371989 100644 --- a/eventsource-client/Cargo.toml +++ b/eventsource-client/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "eventsource-client" -version = "0.16.0" +version = "0.16.1" description = "Client for the Server-Sent Events protocol (aka EventSource)" repository = "https://github.com/launchdarkly/rust-eventsource-client" authors = ["LaunchDarkly"] edition = "2021" -rust-version = "1.83.0" +rust-version = "1.91.0" license = "Apache-2.0" keywords = ["launchdarkly", "feature-flags", "feature-toggles", "eventsource", "server-sent-events"] exclude = ["CHANGELOG.md"] diff --git a/eventsource-client/examples/tail.rs b/eventsource-client/examples/tail.rs index c2368ca..121de84 100644 --- a/eventsource-client/examples/tail.rs +++ b/eventsource-client/examples/tail.rs @@ -18,6 +18,7 @@ use std::{env, process, time::Duration}; use eventsource_client as es; #[tokio::main] +#[allow(clippy::result_large_err)] async fn main() -> Result<(), Box> { env_logger::init(); diff --git a/eventsource-client/src/lib.rs b/eventsource-client/src/lib.rs index 545a46f..44c995e 100644 --- a/eventsource-client/src/lib.rs +++ b/eventsource-client/src/lib.rs @@ -1,4 +1,5 @@ #![warn(rust_2018_idioms)] +#![allow(clippy::result_large_err)] //! Client for the [Server-Sent Events] protocol (aka [EventSource]). //! //! This library provides SSE protocol support but requires you to bring your own From 491b5cf72d7c2583c69f5d338a9624e38a9b777b Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Fri, 13 Feb 2026 12:08:03 -0500 Subject: [PATCH 6/8] feat: Add proxy support including HTTP_PROXY, HTTPS_PROXY detection (#115) This commit introduces proxy handling functionality for the hyper transport, including automatic instrumentation through environment variables. If HTTPS_PROXY is defined, all HTTPS traffic will potentially be routed through this proxy. If HTTP_PROXY is ALSO specified, then HTTP traffic will be routed through it. However, if HTTP_PROXY is specified without HTTPS_PROXY, then all traffic (http and https) will be routed through the HTTP_PROXY. --- .../src/bin/sse-test-api/stream_entity.rs | 4 +- eventsource-client/Cargo.toml | 4 +- eventsource-client/examples/tail.rs | 4 +- eventsource-client/src/lib.rs | 1 + eventsource-client/src/transport_hyper.rs | 393 ++++++++++++------ 5 files changed, 285 insertions(+), 121 deletions(-) diff --git a/contract-tests/src/bin/sse-test-api/stream_entity.rs b/contract-tests/src/bin/sse-test-api/stream_entity.rs index ba36a91..258fb70 100644 --- a/contract-tests/src/bin/sse-test-api/stream_entity.rs +++ b/contract-tests/src/bin/sse-test-api/stream_entity.rs @@ -131,7 +131,9 @@ impl Inner { transport_builder = transport_builder.read_timeout(Duration::from_millis(timeout_ms)); } - let transport = transport_builder.build_https(); + let transport = transport_builder + .build_https() + .map_err(|e| format!("Failed to build HTTPS transport: {e:?}"))?; Ok(Box::new( client_builder diff --git a/eventsource-client/Cargo.toml b/eventsource-client/Cargo.toml index 1371989..163a785 100644 --- a/eventsource-client/Cargo.toml +++ b/eventsource-client/Cargo.toml @@ -26,9 +26,11 @@ base64 = "0.22.1" hyper = { version = "1.0", features = ["client", "http1", "http2"], optional = true } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } http-body-util = { version = "0.1", optional = true } +hyper-http-proxy = { version = "1.1.0", optional = true } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12", "webpki-roots"], optional = true } hyper-timeout = { version = "0.5", optional = true } tower = { version = "0.4", optional = true } +no-proxy = { version = "0.3.6", default-features = false } [dev-dependencies] env_logger = "0.10.0" @@ -41,5 +43,5 @@ proptest = "1.0.0" [features] default = ["hyper"] -hyper = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-timeout", "dep:tower"] +hyper = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-timeout", "dep:tower", "dep:hyper-http-proxy"] hyper-rustls = ["hyper", "dep:hyper-rustls"] diff --git a/eventsource-client/examples/tail.rs b/eventsource-client/examples/tail.rs index 121de84..7b93abe 100644 --- a/eventsource-client/examples/tail.rs +++ b/eventsource-client/examples/tail.rs @@ -59,7 +59,7 @@ async fn run_with_http(url: &str, auth_header: &str) -> Result<(), Box Result<(), Box Result<(), Box> { -//! let transport = HyperTransport::new(); +//! let transport = HyperTransport::new()?; //! let client = ClientBuilder::for_url("https://example.com/stream")? //! .build_with_transport(transport); //! # Ok(()) @@ -30,7 +30,7 @@ //! let transport = HyperTransport::builder() //! .connect_timeout(Duration::from_secs(10)) //! .read_timeout(Duration::from_secs(30)) -//! .build_http(); +//! .build_http()?; //! //! let client = ClientBuilder::for_url("https://example.com/stream")? //! .build_with_transport(transport); @@ -40,11 +40,14 @@ use crate::{ByteStream, HttpTransport, TransportError}; use bytes::Bytes; +use http::Uri; use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full}; use hyper::body::Incoming; +use hyper_http_proxy::{Intercept, Proxy, ProxyConnector}; use hyper_timeout::TimeoutConnector; use hyper_util::client::legacy::Client as HyperClient; use hyper_util::rt::TokioExecutor; +use no_proxy::NoProxy; use std::future::Future; use std::pin::Pin; use std::time::Duration; @@ -70,7 +73,7 @@ use std::time::Duration; /// /// # async fn example() -> Result<(), Box> { /// // Create transport with default HTTP connector -/// let transport = HyperTransport::new(); +/// let transport = HyperTransport::new()?; /// /// // Build SSE client /// let client = ClientBuilder::for_url("https://example.com/stream")? @@ -79,44 +82,24 @@ use std::time::Duration; /// # } /// ``` #[derive(Clone)] -pub struct HyperTransport> -{ +pub struct HyperTransport< + C = ProxyConnector>, +> { client: HyperClient>>, } -/// Builder for configuring a [`HyperTransport`]. -/// -/// This builder allows you to configure timeouts and choose between HTTP and HTTPS connectors. -/// -/// # Example -/// -/// ```no_run -/// use eventsource_client::HyperTransport; -/// use std::time::Duration; -/// -/// let transport = HyperTransport::builder() -/// .connect_timeout(Duration::from_secs(10)) -/// .read_timeout(Duration::from_secs(30)) -/// .build_http(); -/// ``` -#[derive(Default)] -pub struct HyperTransportBuilder { - connect_timeout: Option, - read_timeout: Option, - write_timeout: Option, -} - impl HyperTransport { /// Create a new HyperTransport with default HTTP connector and no timeouts /// /// This creates a basic HTTP-only client that supports both HTTP/1 and HTTP/2. /// For HTTPS support or timeout configuration, use [`HyperTransport::builder()`]. - pub fn new() -> Self { + pub fn new() -> Result { let connector = hyper_util::client::legacy::connect::HttpConnector::new(); let timeout_connector = TimeoutConnector::new(connector); - let client = HyperClient::builder(TokioExecutor::new()).build(timeout_connector); + let proxy_connector = ProxyConnector::new(timeout_connector)?; + let client = HyperClient::builder(TokioExecutor::new()).build(proxy_connector); - Self { client } + Ok(Self { client }) } /// Create a new HyperTransport with HTTPS support using rustls @@ -133,7 +116,7 @@ impl HyperTransport { /// use eventsource_client::{ClientBuilder, HyperTransport}; /// /// # async fn example() -> Result<(), Box> { - /// let transport = HyperTransport::new_https(); + /// let transport = HyperTransport::new_https()?; /// let client = ClientBuilder::for_url("https://example.com/stream")? /// .build_with_transport(transport); /// # Ok(()) @@ -141,10 +124,17 @@ impl HyperTransport { /// # } /// ``` #[cfg(feature = "hyper-rustls")] - pub fn new_https() -> HyperTransport< - TimeoutConnector< - hyper_rustls::HttpsConnector, + pub fn new_https() -> Result< + HyperTransport< + ProxyConnector< + TimeoutConnector< + hyper_rustls::HttpsConnector< + hyper_util::client::legacy::connect::HttpConnector, + >, + >, + >, >, + std::io::Error, > { HyperTransport::builder().build_https() } @@ -169,7 +159,116 @@ impl HyperTransport { } } +impl HttpTransport for HyperTransport +where + C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, +{ + fn request( + &self, + request: http::Request>, + ) -> Pin< + Box< + dyn Future, TransportError>> + + Send + + Sync + + 'static, + >, + > { + // Convert http::Request> to hyper::Request + let (parts, body_opt) = request.into_parts(); + + // Convert Option to BoxBody + let body: BoxBody> = match body_opt { + Some(body_str) => { + // Use Full for non-empty bodies + Full::new(Bytes::from(body_str)) + .map_err(|e| Box::new(e) as Box) + .boxed() + } + None => { + // Use Empty for no body + Empty::::new() + .map_err(|e| Box::new(e) as Box) + .boxed() + } + }; + + let hyper_req = hyper::Request::from_parts(parts, body); + + let client = self.client.clone(); + + Box::pin(async move { + // Make the request - timeouts are handled by TimeoutConnector + let resp = client + .request(hyper_req) + .await + .map_err(TransportError::new)?; + + let (parts, body) = resp.into_parts(); + + // Convert hyper's Incoming body to ByteStream + let byte_stream: ByteStream = Box::pin(body_to_stream(body)); + + Ok(http::Response::from_parts(parts, byte_stream)) + }) + } +} + +/// Builder for configuring a [`HyperTransport`]. +/// +/// This builder allows you to configure timeouts and choose between HTTP and HTTPS connectors. +/// +/// # Example +/// +/// ```no_run +/// use eventsource_client::HyperTransport; +/// use std::time::Duration; +/// +/// let transport = HyperTransport::builder() +/// .connect_timeout(Duration::from_secs(10)) +/// .read_timeout(Duration::from_secs(30)) +/// .build_http(); +/// ``` +#[derive(Default)] +pub struct HyperTransportBuilder { + connect_timeout: Option, + read_timeout: Option, + write_timeout: Option, + proxy_config: Option, +} + impl HyperTransportBuilder { + pub fn disable_proxy(mut self) -> Self { + self.proxy_config = Some(ProxyConfig::Disabled); + self + } + + /// Configure the transport to automatically detect proxy settings from environment variables. + /// This is the default behavior if no proxy configuration method is called. + /// + /// The transport will check `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables to determine proxy settings. + /// Lowercase variants take precedence over uppercase. + /// + /// `NO_PROXY` is respected to bypass the proxy for specified hosts. + /// + /// If both `HTTP_PROXY` and `HTTPS_PROXY` are set, the transport will route requests based on the scheme (http vs https). + /// If only `HTTP_PROXY` is set, all requests will route through that proxy regardless of scheme. + /// If neither is set, no proxy will be used. + pub fn auto_proxy(mut self) -> Self { + self.proxy_config = Some(ProxyConfig::Auto); + self + } + + /// Configure the transport to use a custom proxy URL for all requests The URL should include + /// the scheme (http:// or https://) and can optionally include authentication info. + /// + /// When this is set, the transport will route all requests through the specified proxy, + /// regardless of environment variables. + pub fn proxy_url(mut self, proxy_url: String) -> Self { + self.proxy_config = Some(ProxyConfig::Custom(proxy_url)); + self + } + /// Set a connect timeout for establishing connections /// /// This timeout applies when establishing the TCP connection to the server. @@ -200,7 +299,7 @@ impl HyperTransportBuilder { /// Build with an HTTP connector /// /// Creates a transport that supports HTTP/1 and HTTP/2 over plain HTTP. - pub fn build_http(self) -> HyperTransport { + pub fn build_http(self) -> Result { let connector = hyper_util::client::legacy::connect::HttpConnector::new(); self.build_with_connector(connector) } @@ -220,16 +319,24 @@ impl HyperTransportBuilder { /// /// let transport = HyperTransport::builder() /// .connect_timeout(Duration::from_secs(10)) - /// .build_https(); + /// .build_https() + /// .expect("failed to build HTTPS transport"); /// # } /// ``` #[cfg(feature = "hyper-rustls")] pub fn build_https( self, - ) -> HyperTransport< - TimeoutConnector< - hyper_rustls::HttpsConnector, + ) -> Result< + HyperTransport< + ProxyConnector< + TimeoutConnector< + hyper_rustls::HttpsConnector< + hyper_util::client::legacy::connect::HttpConnector, + >, + >, + >, >, + std::io::Error, > { use hyper_rustls::HttpsConnectorBuilder; @@ -273,7 +380,10 @@ impl HyperTransportBuilder { /// .read_timeout(Duration::from_secs(30)) /// .build_with_connector(connector); /// ``` - pub fn build_with_connector(self, connector: C) -> HyperTransport> + pub fn build_with_connector( + self, + connector: C, + ) -> Result>>, std::io::Error> where C: tower::Service + Clone + Send + Sync + 'static, C::Response: hyper_util::client::legacy::connect::Connection @@ -289,70 +399,118 @@ impl HyperTransportBuilder { timeout_connector.set_read_timeout(self.read_timeout); timeout_connector.set_write_timeout(self.write_timeout); - let client = HyperClient::builder(TokioExecutor::new()).build(timeout_connector); - - HyperTransport { client } - } -} - -impl Default for HyperTransport { - fn default() -> Self { - Self::new() - } -} - -impl HttpTransport for HyperTransport -where - C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, -{ - fn request( - &self, - request: http::Request>, - ) -> Pin< - Box< - dyn Future, TransportError>> - + Send - + Sync - + 'static, - >, - > { - // Convert http::Request> to hyper::Request - let (parts, body_opt) = request.into_parts(); + let mut proxy_connector = ProxyConnector::new(timeout_connector)?; + + match self.proxy_config { + Some(ProxyConfig::Auto) | None => { + let http_proxy = std::env::var("http_proxy") + .or_else(|_| std::env::var("HTTP_PROXY")) + .unwrap_or_default(); + let https_proxy = std::env::var("https_proxy") + .or_else(|_| std::env::var("HTTPS_PROXY")) + .unwrap_or_default(); + let no_proxy = std::env::var("no_proxy") + .or_else(|_| std::env::var("NO_PROXY")) + .unwrap_or_default(); + let no_proxy = NoProxy::from(no_proxy); + + if !https_proxy.is_empty() { + let https_uri = https_proxy + .parse::() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + let no_proxy = no_proxy.clone(); + let custom: Intercept = Intercept::Custom( + (move |schema: Option<&str>, + host: Option<&str>, + _port: Option| + -> bool { + // This function should only enforce validation when it matches + // the schema of the proxy. + if !matches!(schema, Some("https")) { + return false; + } + + match host { + None => false, + Some(h) => !no_proxy.matches(h), + } + }) + .into(), + ); + let proxy = Proxy::new(custom, https_uri); + proxy_connector.add_proxy(proxy); + } - // Convert Option to BoxBody - let body: BoxBody> = match body_opt { - Some(body_str) => { - // Use Full for non-empty bodies - Full::new(Bytes::from(body_str)) - .map_err(|e| Box::new(e) as Box) - .boxed() + if !http_proxy.is_empty() { + let http_uri = http_proxy + .parse::() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + // If http_proxy is set but https_proxy is not, then all hosts are eligible to + // route through the http_proxy. + let proxy_all = https_proxy.is_empty(); + let custom: Intercept = Intercept::Custom( + (move |schema: Option<&str>, + host: Option<&str>, + _port: Option| + -> bool { + if !proxy_all && matches!(schema, Some("https")) { + return false; + } + + match host { + None => false, + Some(h) => !no_proxy.matches(h), + } + }) + .into(), + ); + let proxy = Proxy::new(custom, http_uri); + proxy_connector.add_proxy(proxy); + } } - None => { - // Use Empty for no body - Empty::::new() - .map_err(|e| Box::new(e) as Box) - .boxed() + Some(ProxyConfig::Disabled) => { + // No proxies will be added, so the client will connect directly + } + Some(ProxyConfig::Custom(url)) => { + let uri = url + .parse::() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + proxy_connector.add_proxy(Proxy::new(Intercept::All, uri)); } }; - let hyper_req = hyper::Request::from_parts(parts, body); + let client = HyperClient::builder(TokioExecutor::new()).build(proxy_connector); - let client = self.client.clone(); + Ok(HyperTransport { client }) + } +} - Box::pin(async move { - // Make the request - timeouts are handled by TimeoutConnector - let resp = client - .request(hyper_req) - .await - .map_err(TransportError::new)?; +/// Proxy configuration for HyperTransport. +/// +/// This determines whether and how the transport uses an HTTP/HTTPS proxy. +#[derive(Debug, Clone)] +enum ProxyConfig { + /// Automatically detect proxy from environment variables (default). + /// + /// Checks `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables. + /// Lowercase variants take precedence over uppercase. + Auto, - let (parts, body) = resp.into_parts(); + /// Explicitly disable proxy support. + /// + /// No proxy will be used even if environment variables are set. + Disabled, - // Convert hyper's Incoming body to ByteStream - let byte_stream: ByteStream = Box::pin(body_to_stream(body)); + /// Use a custom proxy URL. + /// + /// Format: `http://[user:pass@]host:port` + Custom(String), +} - Ok(http::Response::from_parts(parts, byte_stream)) - }) +#[allow(clippy::derivable_impls)] +impl Default for ProxyConfig { + fn default() -> Self { + ProxyConfig::Auto } } @@ -394,17 +552,10 @@ mod tests { drop(transport); } - #[test] - fn test_hyper_transport_default() { - let transport = HyperTransport::default(); - // Verify Default trait implementation - drop(transport); - } - #[cfg(feature = "hyper-rustls")] #[test] fn test_hyper_transport_new_https() { - let transport = HyperTransport::new_https(); + let transport = HyperTransport::new_https().expect("transport failed to build"); // If we can create it without panic, the test passes // This verifies the HTTPS connector with rustls is set up correctly drop(transport); @@ -413,7 +564,7 @@ mod tests { #[test] fn test_builder_default() { let builder = HyperTransport::builder(); - let transport = builder.build_http(); + let transport = builder.build_http().expect("failed to build transport"); // Verify we can build with default settings drop(transport); } @@ -422,7 +573,8 @@ mod tests { fn test_builder_with_connect_timeout() { let transport = HyperTransport::builder() .connect_timeout(Duration::from_secs(5)) - .build_http(); + .build_http() + .expect("failed to build transport"); // Verify we can build with connect timeout drop(transport); } @@ -431,7 +583,8 @@ mod tests { fn test_builder_with_read_timeout() { let transport = HyperTransport::builder() .read_timeout(Duration::from_secs(10)) - .build_http(); + .build_http() + .expect("failed to build transport"); // Verify we can build with read timeout drop(transport); } @@ -440,7 +593,8 @@ mod tests { fn test_builder_with_write_timeout() { let transport = HyperTransport::builder() .write_timeout(Duration::from_secs(10)) - .build_http(); + .build_http() + .expect("failed to build transport"); // Verify we can build with write timeout drop(transport); } @@ -451,7 +605,8 @@ mod tests { .connect_timeout(Duration::from_secs(5)) .read_timeout(Duration::from_secs(30)) .write_timeout(Duration::from_secs(10)) - .build_http(); + .build_http() + .expect("failed to build transport"); // Verify we can build with all timeouts configured drop(transport); } @@ -462,7 +617,8 @@ mod tests { let transport = HyperTransport::builder() .connect_timeout(Duration::from_secs(5)) .read_timeout(Duration::from_secs(30)) - .build_https(); + .build_https() + .expect("failed to build HTTPS transport"); // Verify we can build HTTPS transport with timeouts drop(transport); } @@ -481,14 +637,14 @@ mod tests { #[test] fn test_transport_is_clone() { - let transport = HyperTransport::new(); + let transport = HyperTransport::new().expect("failed to build transport"); let _cloned = transport.clone(); // Verify HyperTransport implements Clone } #[tokio::test] async fn test_http_transport_trait_implemented() { - let transport = HyperTransport::new(); + let transport = HyperTransport::new().expect("failed to build transport"); // Create a basic request let request = Request::builder() @@ -506,7 +662,7 @@ mod tests { #[tokio::test] async fn test_request_with_empty_body() { // This test verifies that we can construct a request with no body - let transport = HyperTransport::new(); + let transport = HyperTransport::new().expect("failed to build transport"); let request = Request::builder() .method(Method::GET) @@ -521,7 +677,7 @@ mod tests { #[tokio::test] async fn test_request_with_string_body() { // This test verifies that we can construct a request with a string body - let transport = HyperTransport::new(); + let transport = HyperTransport::new().expect("failed to build transport"); let request = Request::builder() .method(Method::POST) @@ -552,7 +708,8 @@ mod tests { let transport = HyperTransport::builder() .connect_timeout(Duration::from_secs(10)) .read_timeout(Duration::from_secs(30)) - .build_http(); + .build_http() + .expect("failed to build transport"); let request = Request::builder() .method(Method::GET) @@ -583,7 +740,8 @@ mod tests { let transport = HyperTransport::builder() .connect_timeout(Duration::from_secs(10)) .read_timeout(Duration::from_secs(30)) - .build_https(); + .build_https() + .expect("failed to build HTTPS transport"); // Using example.com as it's highly reliable and well-maintained let request = Request::builder() @@ -610,7 +768,7 @@ mod tests { #[tokio::test] #[ignore] // Run with: cargo test -- --ignored async fn test_integration_request_with_body() { - let transport = HyperTransport::new(); + let transport = HyperTransport::new().expect("failed to build transport"); let body_content = r#"{"test": "data"}"#; let request = Request::builder() @@ -630,7 +788,7 @@ mod tests { #[tokio::test] #[ignore] // Run with: cargo test -- --ignored async fn test_integration_streaming_response() { - let transport = HyperTransport::new(); + let transport = HyperTransport::new().expect("failed to build transport"); let request = Request::builder() .method(Method::GET) @@ -662,7 +820,8 @@ mod tests { // Use a non-routable IP to test connect timeout let transport = HyperTransport::builder() .connect_timeout(Duration::from_millis(100)) - .build_http(); + .build_http() + .expect("failed to build transport"); let request = Request::builder() .method(Method::GET) From 0027cac3e93cca19590bbeaa50a4322eca9e8de3 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 19 Feb 2026 12:40:06 -0500 Subject: [PATCH 7/8] feat: Replace local http transport with launchdarkly-sdk-transport (#118) --- .github/actions/build-docs/action.yml | 2 +- .github/actions/ci/action.yml | 12 +- .github/workflows/ci.yml | 34 + contract-tests/Cargo.toml | 4 +- .../src/bin/sse-test-api/stream_entity.rs | 2 +- eventsource-client/Cargo.toml | 20 +- eventsource-client/examples/tail.rs | 5 +- eventsource-client/src/client.rs | 10 +- eventsource-client/src/error.rs | 2 +- eventsource-client/src/lib.rs | 10 +- eventsource-client/src/response.rs | 3 +- eventsource-client/src/transport.rs | 124 --- eventsource-client/src/transport_hyper.rs | 835 ------------------ 13 files changed, 67 insertions(+), 996 deletions(-) delete mode 100644 eventsource-client/src/transport.rs delete mode 100644 eventsource-client/src/transport_hyper.rs diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml index 9d568d4..0858db4 100644 --- a/.github/actions/build-docs/action.yml +++ b/.github/actions/build-docs/action.yml @@ -6,4 +6,4 @@ runs: steps: - name: Build Documentation shell: bash - run: cargo doc --no-deps -p eventsource-client + run: cargo doc --no-deps --all-features -p eventsource-client diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 63050cc..ee9c958 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -1,6 +1,12 @@ name: CI Workflow description: 'Shared CI workflow.' +inputs: + feature-flags: + description: 'Cargo feature flags to pass to test and clippy commands' + required: false + default: '' + runs: using: composite steps: @@ -10,15 +16,15 @@ runs: - name: Run tests shell: bash - run: cargo test --all-features -p eventsource-client + run: cargo test ${{ inputs.feature-flags }} -p eventsource-client - name: Run slower integration tests shell: bash - run: cargo test --all-features -p eventsource-client --lib -- --ignored + run: cargo test ${{ inputs.feature-flags }} -p eventsource-client --lib -- --ignored - name: Run clippy checks shell: bash - run: cargo clippy --all-features -p eventsource-client -- -D warnings + run: cargo clippy ${{ inputs.feature-flags }} -p eventsource-client -- -D warnings - name: Build contract tests shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 323487d..0efee91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,20 @@ on: jobs: ci-build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: + - name: "default" + flags: "" + - name: "no-features" + flags: "--no-default-features" + - name: "hyper" + flags: "--no-default-features --features hyper" + - name: "hyper-rustls" + flags: "--no-default-features --features hyper-rustls" + + name: CI (${{ matrix.features.name }}) steps: - uses: actions/checkout@v4 @@ -32,4 +46,24 @@ jobs: rustup component add rustfmt clippy - uses: ./.github/actions/ci + with: + feature-flags: ${{ matrix.features.flags }} + + build-docs: + runs-on: ubuntu-latest + name: Build Documentation (all features) + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get Rust version + id: rust-version + run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT + + - name: Setup rust tooling + run: | + rustup override set ${{ steps.rust-version.outputs.target }} + - uses: ./.github/actions/build-docs diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index a6ae094..ef72c84 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -7,7 +7,7 @@ license = "Apache-2.0" [dependencies] futures = { version = "0.3.21" } serde = { version = "1.0", features = ["derive"] } -eventsource-client = { path = "../eventsource-client", features = ["hyper", "hyper-rustls"] } +eventsource-client = { path = "../eventsource-client", features = ["hyper-rustls"] } serde_json = { version = "1.0.39"} actix = { version = "0.13.1"} actix-web = { version = "4"} @@ -18,5 +18,7 @@ log = "0.4.6" http = "1.0" bytes = "1.5" +launchdarkly-sdk-transport = { git = "https://github.com/launchdarkly/rust-sdk-transport.git", branch = "main" } + [[bin]] name = "sse-test-api" diff --git a/contract-tests/src/bin/sse-test-api/stream_entity.rs b/contract-tests/src/bin/sse-test-api/stream_entity.rs index 258fb70..13992af 100644 --- a/contract-tests/src/bin/sse-test-api/stream_entity.rs +++ b/contract-tests/src/bin/sse-test-api/stream_entity.rs @@ -7,7 +7,7 @@ use std::{ }; use eventsource_client as es; -use eventsource_client::HyperTransport; +use launchdarkly_sdk_transport::HyperTransport; use crate::{Config, EventType}; diff --git a/eventsource-client/Cargo.toml b/eventsource-client/Cargo.toml index 163a785..be77f4a 100644 --- a/eventsource-client/Cargo.toml +++ b/eventsource-client/Cargo.toml @@ -20,17 +20,7 @@ tokio = { version = "1.17.0", features = ["time"] } rand = "0.8.5" base64 = "0.22.1" -# -# Dependencies for hyper transport -# -hyper = { version = "1.0", features = ["client", "http1", "http2"], optional = true } -hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } -http-body-util = { version = "0.1", optional = true } -hyper-http-proxy = { version = "1.1.0", optional = true } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12", "webpki-roots"], optional = true } -hyper-timeout = { version = "0.5", optional = true } -tower = { version = "0.4", optional = true } -no-proxy = { version = "0.3.6", default-features = false } +launchdarkly-sdk-transport = { git = "https://github.com/launchdarkly/rust-sdk-transport.git", branch = "main" } [dev-dependencies] env_logger = "0.10.0" @@ -41,7 +31,11 @@ tokio = { version = "1.2.0", features = ["macros", "rt-multi-thread"] } test-case = "3.2.1" proptest = "1.0.0" +[[example]] +name = "tail" +required-features = ["hyper"] + [features] default = ["hyper"] -hyper = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-timeout", "dep:tower", "dep:hyper-http-proxy"] -hyper-rustls = ["hyper", "dep:hyper-rustls"] +hyper = ["launchdarkly-sdk-transport/hyper"] +hyper-rustls = ["hyper", "launchdarkly-sdk-transport/hyper-rustls"] diff --git a/eventsource-client/examples/tail.rs b/eventsource-client/examples/tail.rs index 7b93abe..f069dc5 100644 --- a/eventsource-client/examples/tail.rs +++ b/eventsource-client/examples/tail.rs @@ -16,6 +16,7 @@ use futures::{Stream, TryStreamExt}; use std::{env, process, time::Duration}; use eventsource_client as es; +use launchdarkly_sdk_transport::HyperTransport; #[tokio::main] #[allow(clippy::result_large_err)] @@ -56,7 +57,7 @@ async fn main() -> Result<(), Box> { } async fn run_with_http(url: &str, auth_header: &str) -> Result<(), Box> { - let transport = es::HyperTransport::builder() + let transport = HyperTransport::builder() .connect_timeout(Duration::from_secs(10)) .read_timeout(Duration::from_secs(30)) .build_http()?; @@ -82,7 +83,7 @@ async fn run_with_http(url: &str, auth_header: &str) -> Result<(), Box Result<(), Box> { - let transport = es::HyperTransport::builder() + let transport = HyperTransport::builder() .connect_timeout(Duration::from_secs(10)) .read_timeout(Duration::from_secs(30)) .build_https()?; diff --git a/eventsource-client/src/client.rs b/eventsource-client/src/client.rs index b679105..48d6330 100644 --- a/eventsource-client/src/client.rs +++ b/eventsource-client/src/client.rs @@ -21,12 +21,12 @@ use tokio::time::Sleep; use crate::{ config::ReconnectOptions, response::{ErrorBody, Response}, - {ByteStream, HttpTransport, ResponseFuture}, }; use crate::{ error::{Error, Result}, event_parser::ConnectionDetails, }; +use launchdarkly_sdk_transport::{ByteStream, HttpTransport, ResponseFuture}; use crate::event_parser::EventParser; use crate::event_parser::SSE; @@ -313,7 +313,7 @@ impl ReconnectingRequest { // Include the request body if set. Most SSE requests use GET and will have None, // but some implementations (e.g., using REPORT method) may include a body. let request = request_builder - .body(self.props.body.clone()) + .body(self.props.body.clone().map(|b| b.into())) .map_err(|e| Error::InvalidParameter(Box::new(e)))?; Ok(self.transport.request(request)) @@ -573,7 +573,7 @@ fn delay(dur: Duration, description: &str) -> Sleep { mod private { use crate::client::ClientImpl; - use crate::HttpTransport; + use launchdarkly_sdk_transport::HttpTransport; pub trait Sealed {} impl Sealed for ClientImpl {} @@ -618,8 +618,8 @@ mod tests { use crate::{ client::{RequestProps, State}, ReconnectOptionsBuilder, ReconnectingRequest, - {ByteStream, HttpTransport, ResponseFuture, TransportError}, }; + use launchdarkly_sdk_transport::{ByteStream, HttpTransport, ResponseFuture, TransportError}; // Mock transport for testing #[derive(Clone)] @@ -634,7 +634,7 @@ mod tests { } impl HttpTransport for MockTransport { - fn request(&self, _request: http::Request>) -> ResponseFuture { + fn request(&self, _request: http::Request>) -> ResponseFuture { if self.fail_request { // Simulate a connection error Box::pin(async { diff --git a/eventsource-client/src/error.rs b/eventsource-client/src/error.rs index 7a1e08b..e1c8e39 100644 --- a/eventsource-client/src/error.rs +++ b/eventsource-client/src/error.rs @@ -1,5 +1,5 @@ use crate::response::{ErrorBody, Response}; -use crate::TransportError; +use launchdarkly_sdk_transport::TransportError; /// Error type for invalid response headers encountered in ResponseDetails. #[derive(Debug)] diff --git a/eventsource-client/src/lib.rs b/eventsource-client/src/lib.rs index 509387e..ad2ac3f 100644 --- a/eventsource-client/src/lib.rs +++ b/eventsource-client/src/lib.rs @@ -14,9 +14,8 @@ //! //! # async fn example() -> Result<(), Box> { //! // You need to implement HttpTransport trait for your HTTP client -//! // See examples/hyper_transport.rs or examples/reqwest_transport.rs for reference implementations //! # struct MyTransport; -//! # impl eventsource_client::HttpTransport for MyTransport { +//! # impl launchdarkly_sdk_transport::HttpTransport for MyTransport { //! # fn request(&self, _req: http::Request>) -> eventsource_client::ResponseFuture { //! # unimplemented!() //! # } @@ -56,10 +55,6 @@ mod error; mod event_parser; mod response; mod retry; -mod transport; - -#[cfg(feature = "hyper")] -mod transport_hyper; pub use client::*; pub use config::*; @@ -67,6 +62,3 @@ pub use error::*; pub use event_parser::Event; pub use event_parser::SSE; pub use response::Response; -pub use transport::*; -#[cfg(feature = "hyper")] -pub use transport_hyper::*; diff --git a/eventsource-client/src/response.rs b/eventsource-client/src/response.rs index 07991bc..25ececc 100644 --- a/eventsource-client/src/response.rs +++ b/eventsource-client/src/response.rs @@ -1,6 +1,7 @@ use http::{HeaderMap, HeaderValue, StatusCode}; -use crate::{ByteStream, HeaderError}; +use crate::HeaderError; +use launchdarkly_sdk_transport::ByteStream; /// Represents an error response body as a stream of bytes. /// diff --git a/eventsource-client/src/transport.rs b/eventsource-client/src/transport.rs deleted file mode 100644 index afa5825..0000000 --- a/eventsource-client/src/transport.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! HTTP transport abstraction for Server-Sent Events client. -//! -//! This module defines the [`HttpTransport`] trait which allows users to plug in -//! their own HTTP client implementation (hyper, reqwest, or custom). -//! -//! # Example -//! -//! See the `examples/` directory for reference implementations using popular HTTP clients. - -use bytes::Bytes; -use futures::Stream; -use std::error::Error as StdError; -use std::fmt; -use std::future::Future; -use std::pin::Pin; - -// Re-export http crate types for convenience -pub use http::{HeaderMap, HeaderValue, Request, Response, StatusCode, Uri}; - -/// A pinned, boxed stream of bytes returned by HTTP transports. -/// -/// This represents the streaming response body from an HTTP request. -pub type ByteStream = Pin> + Send + Sync>>; - -/// A pinned, boxed future for an HTTP response. -/// -/// This represents the future returned by [`HttpTransport::request`]. -pub type ResponseFuture = - Pin, TransportError>> + Send + Sync>>; - -/// Error type for HTTP transport operations. -/// -/// This wraps transport-specific errors (network failures, timeouts, etc.) in a -/// common error type that the SSE client can handle uniformly. -#[derive(Debug)] -pub struct TransportError { - inner: Box, -} - -impl TransportError { - /// Create a new transport error from any error type. - pub fn new(err: impl StdError + Send + Sync + 'static) -> Self { - Self { - inner: Box::new(err), - } - } - - /// Get a reference to the inner error. - pub fn inner(&self) -> &(dyn StdError + Send + Sync + 'static) { - &*self.inner - } -} - -impl fmt::Display for TransportError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "transport error: {}", self.inner) - } -} - -impl StdError for TransportError { - fn source(&self) -> Option<&(dyn StdError + 'static)> { - Some(&*self.inner) - } -} - -/// Trait for pluggable HTTP transport implementations. -/// -/// Implement this trait to provide HTTP request/response functionality for the -/// SSE client. The transport is responsible for: -/// - Establishing HTTP connections (with TLS if needed) -/// - Sending HTTP requests -/// - Returning streaming HTTP responses -/// - Handling timeouts (if desired) -/// -/// # Example -/// -/// ```ignore -/// use eventsource_client::{HttpTransport, ByteStream, TransportError}; -/// use std::pin::Pin; -/// use std::future::Future; -/// -/// struct MyTransport { -/// // Your HTTP client here -/// } -/// -/// impl HttpTransport for MyTransport { -/// fn request( -/// &self, -/// request: http::Request>, -/// ) -> Pin, TransportError>> + Send>> { -/// // Extract body from request -/// // Convert request to your HTTP client's format -/// // Make the request -/// // Return streaming response -/// todo!() -/// } -/// } -/// ``` -pub trait HttpTransport: Send + Sync + Clone + 'static { - /// Execute an HTTP request and return a streaming response. - /// - /// # Arguments - /// - /// * `request` - The HTTP request to execute. The body type is `Option` - /// to support methods like REPORT that may include a request body. Most SSE - /// requests use GET and will have `None` as the body. - /// - /// # Returns - /// - /// A future that resolves to an HTTP response with a streaming body, or a - /// transport error if the request fails. - /// - /// The response should include: - /// - Status code - /// - Response headers - /// - A stream of body bytes - /// - /// # Notes - /// - /// - The transport should NOT follow redirects - the SSE client handles this - /// - The transport should NOT retry requests - the SSE client handles this - /// - The transport MAY implement timeouts as desired - fn request(&self, request: Request>) -> ResponseFuture; -} diff --git a/eventsource-client/src/transport_hyper.rs b/eventsource-client/src/transport_hyper.rs deleted file mode 100644 index d42ab18..0000000 --- a/eventsource-client/src/transport_hyper.rs +++ /dev/null @@ -1,835 +0,0 @@ -//! Hyper v1 transport implementation for eventsource-client -//! -//! This crate provides a production-ready [`HyperTransport`] implementation that -//! integrates hyper v1 with the eventsource-client library. -//! -//! # Example -//! -//! ```no_run -//! use eventsource_client::{ClientBuilder, HyperTransport}; -//! -//! # async fn example() -> Result<(), Box> { -//! let transport = HyperTransport::new()?; -//! let client = ClientBuilder::for_url("https://example.com/stream")? -//! .build_with_transport(transport); -//! # Ok(()) -//! # } -//! ``` -//! -//! # Features -//! -//! - `hyper-rustls`: Enable HTTPS support using rustls (via [`HyperTransport::builder().https()`]) -//! -//! # Timeout Configuration -//! -//! ```no_run -//! use eventsource_client::{ClientBuilder, HyperTransport}; -//! use std::time::Duration; -//! -//! # async fn example() -> Result<(), Box> { -//! let transport = HyperTransport::builder() -//! .connect_timeout(Duration::from_secs(10)) -//! .read_timeout(Duration::from_secs(30)) -//! .build_http()?; -//! -//! let client = ClientBuilder::for_url("https://example.com/stream")? -//! .build_with_transport(transport); -//! # Ok(()) -//! # } -//! ``` - -use crate::{ByteStream, HttpTransport, TransportError}; -use bytes::Bytes; -use http::Uri; -use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full}; -use hyper::body::Incoming; -use hyper_http_proxy::{Intercept, Proxy, ProxyConnector}; -use hyper_timeout::TimeoutConnector; -use hyper_util::client::legacy::Client as HyperClient; -use hyper_util::rt::TokioExecutor; -use no_proxy::NoProxy; -use std::future::Future; -use std::pin::Pin; -use std::time::Duration; - -/// A transport implementation using hyper v1.x -/// -/// This struct wraps a hyper client and implements the [`HttpTransport`] trait -/// for use with eventsource-client. -/// -/// # Timeout Support -/// -/// All three timeout types are fully supported via `hyper-timeout`: -/// - `connect_timeout` - Timeout for establishing the TCP connection -/// - `read_timeout` - Timeout for reading data from the connection -/// - `write_timeout` - Timeout for writing data to the connection -/// -/// Timeouts are configured using the builder pattern. See [`HyperTransportBuilder`] for details. -/// -/// # Example -/// -/// ```no_run -/// use eventsource_client::{ClientBuilder, HyperTransport}; -/// -/// # async fn example() -> Result<(), Box> { -/// // Create transport with default HTTP connector -/// let transport = HyperTransport::new()?; -/// -/// // Build SSE client -/// let client = ClientBuilder::for_url("https://example.com/stream")? -/// .build_with_transport(transport); -/// # Ok(()) -/// # } -/// ``` -#[derive(Clone)] -pub struct HyperTransport< - C = ProxyConnector>, -> { - client: HyperClient>>, -} - -impl HyperTransport { - /// Create a new HyperTransport with default HTTP connector and no timeouts - /// - /// This creates a basic HTTP-only client that supports both HTTP/1 and HTTP/2. - /// For HTTPS support or timeout configuration, use [`HyperTransport::builder()`]. - pub fn new() -> Result { - let connector = hyper_util::client::legacy::connect::HttpConnector::new(); - let timeout_connector = TimeoutConnector::new(connector); - let proxy_connector = ProxyConnector::new(timeout_connector)?; - let client = HyperClient::builder(TokioExecutor::new()).build(proxy_connector); - - Ok(Self { client }) - } - - /// Create a new HyperTransport with HTTPS support using rustls - /// - /// This creates an HTTPS client that supports both HTTP/1 and HTTP/2 protocols. - /// This method is only available when the `hyper-rustls` feature is enabled. - /// For timeout configuration, use [`HyperTransport::builder().build_https()`] instead. - /// - /// # Example - /// - /// ```no_run - /// # #[cfg(feature = "hyper-rustls")] - /// # { - /// use eventsource_client::{ClientBuilder, HyperTransport}; - /// - /// # async fn example() -> Result<(), Box> { - /// let transport = HyperTransport::new_https()?; - /// let client = ClientBuilder::for_url("https://example.com/stream")? - /// .build_with_transport(transport); - /// # Ok(()) - /// # } - /// # } - /// ``` - #[cfg(feature = "hyper-rustls")] - pub fn new_https() -> Result< - HyperTransport< - ProxyConnector< - TimeoutConnector< - hyper_rustls::HttpsConnector< - hyper_util::client::legacy::connect::HttpConnector, - >, - >, - >, - >, - std::io::Error, - > { - HyperTransport::builder().build_https() - } - - /// Create a new builder for configuring HyperTransport - /// - /// The builder allows you to configure timeouts and choose between HTTP and HTTPS connectors. - /// - /// # Example - /// - /// ```no_run - /// use eventsource_client::HyperTransport; - /// use std::time::Duration; - /// - /// let transport = HyperTransport::builder() - /// .connect_timeout(Duration::from_secs(10)) - /// .read_timeout(Duration::from_secs(30)) - /// .build_http(); - /// ``` - pub fn builder() -> HyperTransportBuilder { - HyperTransportBuilder::default() - } -} - -impl HttpTransport for HyperTransport -where - C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, -{ - fn request( - &self, - request: http::Request>, - ) -> Pin< - Box< - dyn Future, TransportError>> - + Send - + Sync - + 'static, - >, - > { - // Convert http::Request> to hyper::Request - let (parts, body_opt) = request.into_parts(); - - // Convert Option to BoxBody - let body: BoxBody> = match body_opt { - Some(body_str) => { - // Use Full for non-empty bodies - Full::new(Bytes::from(body_str)) - .map_err(|e| Box::new(e) as Box) - .boxed() - } - None => { - // Use Empty for no body - Empty::::new() - .map_err(|e| Box::new(e) as Box) - .boxed() - } - }; - - let hyper_req = hyper::Request::from_parts(parts, body); - - let client = self.client.clone(); - - Box::pin(async move { - // Make the request - timeouts are handled by TimeoutConnector - let resp = client - .request(hyper_req) - .await - .map_err(TransportError::new)?; - - let (parts, body) = resp.into_parts(); - - // Convert hyper's Incoming body to ByteStream - let byte_stream: ByteStream = Box::pin(body_to_stream(body)); - - Ok(http::Response::from_parts(parts, byte_stream)) - }) - } -} - -/// Builder for configuring a [`HyperTransport`]. -/// -/// This builder allows you to configure timeouts and choose between HTTP and HTTPS connectors. -/// -/// # Example -/// -/// ```no_run -/// use eventsource_client::HyperTransport; -/// use std::time::Duration; -/// -/// let transport = HyperTransport::builder() -/// .connect_timeout(Duration::from_secs(10)) -/// .read_timeout(Duration::from_secs(30)) -/// .build_http(); -/// ``` -#[derive(Default)] -pub struct HyperTransportBuilder { - connect_timeout: Option, - read_timeout: Option, - write_timeout: Option, - proxy_config: Option, -} - -impl HyperTransportBuilder { - pub fn disable_proxy(mut self) -> Self { - self.proxy_config = Some(ProxyConfig::Disabled); - self - } - - /// Configure the transport to automatically detect proxy settings from environment variables. - /// This is the default behavior if no proxy configuration method is called. - /// - /// The transport will check `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables to determine proxy settings. - /// Lowercase variants take precedence over uppercase. - /// - /// `NO_PROXY` is respected to bypass the proxy for specified hosts. - /// - /// If both `HTTP_PROXY` and `HTTPS_PROXY` are set, the transport will route requests based on the scheme (http vs https). - /// If only `HTTP_PROXY` is set, all requests will route through that proxy regardless of scheme. - /// If neither is set, no proxy will be used. - pub fn auto_proxy(mut self) -> Self { - self.proxy_config = Some(ProxyConfig::Auto); - self - } - - /// Configure the transport to use a custom proxy URL for all requests The URL should include - /// the scheme (http:// or https://) and can optionally include authentication info. - /// - /// When this is set, the transport will route all requests through the specified proxy, - /// regardless of environment variables. - pub fn proxy_url(mut self, proxy_url: String) -> Self { - self.proxy_config = Some(ProxyConfig::Custom(proxy_url)); - self - } - - /// Set a connect timeout for establishing connections - /// - /// This timeout applies when establishing the TCP connection to the server. - /// There is no connect timeout by default. - pub fn connect_timeout(mut self, timeout: Duration) -> Self { - self.connect_timeout = Some(timeout); - self - } - - /// Set a read timeout for reading from connections - /// - /// This timeout applies when reading data from the connection. - /// There is no read timeout by default. - pub fn read_timeout(mut self, timeout: Duration) -> Self { - self.read_timeout = Some(timeout); - self - } - - /// Set a write timeout for writing to connections - /// - /// This timeout applies when writing data to the connection. - /// There is no write timeout by default. - pub fn write_timeout(mut self, timeout: Duration) -> Self { - self.write_timeout = Some(timeout); - self - } - - /// Build with an HTTP connector - /// - /// Creates a transport that supports HTTP/1 and HTTP/2 over plain HTTP. - pub fn build_http(self) -> Result { - let connector = hyper_util::client::legacy::connect::HttpConnector::new(); - self.build_with_connector(connector) - } - - /// Build with an HTTPS connector using rustls - /// - /// Creates a transport that supports HTTP/1 and HTTP/2 over HTTPS using rustls. - /// This method is only available when the `hyper-rustls` feature is enabled. - /// - /// # Example - /// - /// ```no_run - /// # #[cfg(feature = "hyper-rustls")] - /// # { - /// use eventsource_client::HyperTransport; - /// use std::time::Duration; - /// - /// let transport = HyperTransport::builder() - /// .connect_timeout(Duration::from_secs(10)) - /// .build_https() - /// .expect("failed to build HTTPS transport"); - /// # } - /// ``` - #[cfg(feature = "hyper-rustls")] - pub fn build_https( - self, - ) -> Result< - HyperTransport< - ProxyConnector< - TimeoutConnector< - hyper_rustls::HttpsConnector< - hyper_util::client::legacy::connect::HttpConnector, - >, - >, - >, - >, - std::io::Error, - > { - use hyper_rustls::HttpsConnectorBuilder; - - let connector = HttpsConnectorBuilder::new() - .with_native_roots() - .unwrap_or_else(|_| { - log::debug!("Falling back to webpki roots for HTTPS connector"); - HttpsConnectorBuilder::new().with_webpki_roots() - }) - .https_or_http() - .enable_http1() - .enable_http2() - .build(); - - self.build_with_connector(connector) - } - - /// Build with a custom connector - /// - /// This allows you to provide your own connector implementation, which is useful for: - /// - Custom TLS configuration - /// - Proxy support - /// - Connection pooling customization - /// - Custom DNS resolution - /// - /// The connector will be automatically wrapped with a `TimeoutConnector` that applies - /// the configured timeout settings. - /// - /// # Example - /// - /// ```no_run - /// use eventsource_client::HyperTransport; - /// use hyper_util::client::legacy::connect::HttpConnector; - /// use std::time::Duration; - /// - /// let mut connector = HttpConnector::new(); - /// // Configure the connector as needed - /// connector.set_nodelay(true); - /// - /// let transport = HyperTransport::builder() - /// .read_timeout(Duration::from_secs(30)) - /// .build_with_connector(connector); - /// ``` - pub fn build_with_connector( - self, - connector: C, - ) -> Result>>, std::io::Error> - where - C: tower::Service + Clone + Send + Sync + 'static, - C::Response: hyper_util::client::legacy::connect::Connection - + hyper::rt::Read - + hyper::rt::Write - + Send - + Unpin, - C::Future: Send + 'static, - C::Error: Into>, - { - let mut timeout_connector = TimeoutConnector::new(connector); - timeout_connector.set_connect_timeout(self.connect_timeout); - timeout_connector.set_read_timeout(self.read_timeout); - timeout_connector.set_write_timeout(self.write_timeout); - - let mut proxy_connector = ProxyConnector::new(timeout_connector)?; - - match self.proxy_config { - Some(ProxyConfig::Auto) | None => { - let http_proxy = std::env::var("http_proxy") - .or_else(|_| std::env::var("HTTP_PROXY")) - .unwrap_or_default(); - let https_proxy = std::env::var("https_proxy") - .or_else(|_| std::env::var("HTTPS_PROXY")) - .unwrap_or_default(); - let no_proxy = std::env::var("no_proxy") - .or_else(|_| std::env::var("NO_PROXY")) - .unwrap_or_default(); - let no_proxy = NoProxy::from(no_proxy); - - if !https_proxy.is_empty() { - let https_uri = https_proxy - .parse::() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; - let no_proxy = no_proxy.clone(); - let custom: Intercept = Intercept::Custom( - (move |schema: Option<&str>, - host: Option<&str>, - _port: Option| - -> bool { - // This function should only enforce validation when it matches - // the schema of the proxy. - if !matches!(schema, Some("https")) { - return false; - } - - match host { - None => false, - Some(h) => !no_proxy.matches(h), - } - }) - .into(), - ); - let proxy = Proxy::new(custom, https_uri); - proxy_connector.add_proxy(proxy); - } - - if !http_proxy.is_empty() { - let http_uri = http_proxy - .parse::() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; - // If http_proxy is set but https_proxy is not, then all hosts are eligible to - // route through the http_proxy. - let proxy_all = https_proxy.is_empty(); - let custom: Intercept = Intercept::Custom( - (move |schema: Option<&str>, - host: Option<&str>, - _port: Option| - -> bool { - if !proxy_all && matches!(schema, Some("https")) { - return false; - } - - match host { - None => false, - Some(h) => !no_proxy.matches(h), - } - }) - .into(), - ); - let proxy = Proxy::new(custom, http_uri); - proxy_connector.add_proxy(proxy); - } - } - Some(ProxyConfig::Disabled) => { - // No proxies will be added, so the client will connect directly - } - Some(ProxyConfig::Custom(url)) => { - let uri = url - .parse::() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; - proxy_connector.add_proxy(Proxy::new(Intercept::All, uri)); - } - }; - - let client = HyperClient::builder(TokioExecutor::new()).build(proxy_connector); - - Ok(HyperTransport { client }) - } -} - -/// Proxy configuration for HyperTransport. -/// -/// This determines whether and how the transport uses an HTTP/HTTPS proxy. -#[derive(Debug, Clone)] -enum ProxyConfig { - /// Automatically detect proxy from environment variables (default). - /// - /// Checks `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables. - /// Lowercase variants take precedence over uppercase. - Auto, - - /// Explicitly disable proxy support. - /// - /// No proxy will be used even if environment variables are set. - Disabled, - - /// Use a custom proxy URL. - /// - /// Format: `http://[user:pass@]host:port` - Custom(String), -} - -#[allow(clippy::derivable_impls)] -impl Default for ProxyConfig { - fn default() -> Self { - ProxyConfig::Auto - } -} - -/// Convert hyper's Incoming body to a Stream of Bytes -fn body_to_stream( - body: Incoming, -) -> impl futures::Stream> + Send + Sync { - futures::stream::unfold(body, |mut body| async move { - match body.frame().await { - Some(Ok(frame)) => { - if let Ok(data) = frame.into_data() { - Some((Ok(data), body)) - } else { - // Skip non-data frames (trailers, etc.) - Some(( - Err(TransportError::new(std::io::Error::other("non-data frame"))), - body, - )) - } - } - Some(Err(e)) => Some((Err(TransportError::new(e)), body)), - None => None, - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use futures::StreamExt; - use http::{Method, Request}; - use std::time::Duration; - - #[test] - fn test_hyper_transport_new() { - let transport = HyperTransport::new(); - // If we can create it without panic, the test passes - // This verifies the default HTTP connector is set up correctly - drop(transport); - } - - #[cfg(feature = "hyper-rustls")] - #[test] - fn test_hyper_transport_new_https() { - let transport = HyperTransport::new_https().expect("transport failed to build"); - // If we can create it without panic, the test passes - // This verifies the HTTPS connector with rustls is set up correctly - drop(transport); - } - - #[test] - fn test_builder_default() { - let builder = HyperTransport::builder(); - let transport = builder.build_http().expect("failed to build transport"); - // Verify we can build with default settings - drop(transport); - } - - #[test] - fn test_builder_with_connect_timeout() { - let transport = HyperTransport::builder() - .connect_timeout(Duration::from_secs(5)) - .build_http() - .expect("failed to build transport"); - // Verify we can build with connect timeout - drop(transport); - } - - #[test] - fn test_builder_with_read_timeout() { - let transport = HyperTransport::builder() - .read_timeout(Duration::from_secs(10)) - .build_http() - .expect("failed to build transport"); - // Verify we can build with read timeout - drop(transport); - } - - #[test] - fn test_builder_with_write_timeout() { - let transport = HyperTransport::builder() - .write_timeout(Duration::from_secs(10)) - .build_http() - .expect("failed to build transport"); - // Verify we can build with write timeout - drop(transport); - } - - #[test] - fn test_builder_with_all_timeouts() { - let transport = HyperTransport::builder() - .connect_timeout(Duration::from_secs(5)) - .read_timeout(Duration::from_secs(30)) - .write_timeout(Duration::from_secs(10)) - .build_http() - .expect("failed to build transport"); - // Verify we can build with all timeouts configured - drop(transport); - } - - #[cfg(feature = "hyper-rustls")] - #[test] - fn test_builder_https() { - let transport = HyperTransport::builder() - .connect_timeout(Duration::from_secs(5)) - .read_timeout(Duration::from_secs(30)) - .build_https() - .expect("failed to build HTTPS transport"); - // Verify we can build HTTPS transport with timeouts - drop(transport); - } - - #[test] - fn test_builder_with_custom_connector() { - let mut connector = hyper_util::client::legacy::connect::HttpConnector::new(); - connector.set_nodelay(true); - - let transport = HyperTransport::builder() - .read_timeout(Duration::from_secs(30)) - .build_with_connector(connector); - // Verify we can build with a custom connector - drop(transport); - } - - #[test] - fn test_transport_is_clone() { - let transport = HyperTransport::new().expect("failed to build transport"); - let _cloned = transport.clone(); - // Verify HyperTransport implements Clone - } - - #[tokio::test] - async fn test_http_transport_trait_implemented() { - let transport = HyperTransport::new().expect("failed to build transport"); - - // Create a basic request - let request = Request::builder() - .method(Method::GET) - .uri("http://httpbin.org/get") - .body(None) - .expect("failed to build request"); - - // Verify the trait is implemented by attempting to call it - // We're not actually making the request here, just verifying the types work - let _future = transport.request(request); - // The future exists and has the correct type signature - } - - #[tokio::test] - async fn test_request_with_empty_body() { - // This test verifies that we can construct a request with no body - let transport = HyperTransport::new().expect("failed to build transport"); - - let request = Request::builder() - .method(Method::GET) - .uri("http://httpbin.org/get") - .body(None) - .expect("failed to build request"); - - // Just verify we can create the future - not actually making network call - let _future = transport.request(request); - } - - #[tokio::test] - async fn test_request_with_string_body() { - // This test verifies that we can construct a request with a string body - let transport = HyperTransport::new().expect("failed to build transport"); - - let request = Request::builder() - .method(Method::POST) - .uri("http://httpbin.org/post") - .body(Some(String::from("test body"))) - .expect("failed to build request"); - - // Just verify we can create the future - not actually making network call - let _future = transport.request(request); - } - - #[tokio::test] - async fn test_body_to_stream_empty() { - // Create an empty incoming body for testing - // This is a bit tricky since Incoming is not easily constructible - // We'll test the integration through the full request path instead - - // For now, this is a placeholder showing the test structure - // A full implementation would require setting up a test HTTP server - } - - // Integration tests that actually make HTTP requests - // These require a running HTTP server, so they're marked as ignored by default - - #[tokio::test] - #[ignore] // Run with: cargo test -- --ignored - async fn test_integration_http_request() { - let transport = HyperTransport::builder() - .connect_timeout(Duration::from_secs(10)) - .read_timeout(Duration::from_secs(30)) - .build_http() - .expect("failed to build transport"); - - let request = Request::builder() - .method(Method::GET) - .uri("http://httpbin.org/get") - .body(None) - .expect("failed to build request"); - - let response = transport.request(request).await; - assert!(response.is_ok(), "Request should succeed"); - - let response = response.unwrap(); - assert!(response.status().is_success(), "Status should be success"); - - // Verify we can read from the stream - let mut stream = response.into_body(); - let mut received_data = false; - while let Some(result) = stream.next().await { - assert!(result.is_ok(), "Stream chunk should not error"); - received_data = true; - } - assert!(received_data, "Should have received some data"); - } - - #[cfg(feature = "hyper-rustls")] - #[tokio::test] - #[ignore] // Run with: cargo test -- --ignored - async fn test_integration_https_request() { - let transport = HyperTransport::builder() - .connect_timeout(Duration::from_secs(10)) - .read_timeout(Duration::from_secs(30)) - .build_https() - .expect("failed to build HTTPS transport"); - - // Using example.com as it's highly reliable and well-maintained - let request = Request::builder() - .method(Method::GET) - .uri("https://example.com/") - .body(None) - .expect("failed to build request"); - - let response = transport.request(request).await; - assert!( - response.is_ok(), - "HTTPS request should succeed: {:?}", - response.as_ref().err() - ); - - let response = response.unwrap(); - assert!( - response.status().is_success(), - "Status should be success: {}", - response.status() - ); - } - - #[tokio::test] - #[ignore] // Run with: cargo test -- --ignored - async fn test_integration_request_with_body() { - let transport = HyperTransport::new().expect("failed to build transport"); - - let body_content = r#"{"test": "data"}"#; - let request = Request::builder() - .method(Method::POST) - .uri("http://httpbin.org/post") - .header("Content-Type", "application/json") - .body(Some(body_content.to_string())) - .expect("failed to build request"); - - let response = transport.request(request).await; - assert!(response.is_ok(), "POST request should succeed"); - - let response = response.unwrap(); - assert!(response.status().is_success(), "Status should be success"); - } - - #[tokio::test] - #[ignore] // Run with: cargo test -- --ignored - async fn test_integration_streaming_response() { - let transport = HyperTransport::new().expect("failed to build transport"); - - let request = Request::builder() - .method(Method::GET) - .uri("http://httpbin.org/stream/10") - .body(None) - .expect("failed to build request"); - - let response = transport.request(request).await; - assert!(response.is_ok(), "Streaming request should succeed"); - - let response = response.unwrap(); - assert!(response.status().is_success(), "Status should be success"); - - // Verify we receive multiple chunks - let mut stream = response.into_body(); - let mut chunk_count = 0; - while let Some(result) = stream.next().await { - assert!(result.is_ok(), "Stream chunk should not error"); - let chunk = result.unwrap(); - assert!(!chunk.is_empty(), "Chunk should not be empty"); - chunk_count += 1; - } - assert!(chunk_count > 0, "Should have received multiple chunks"); - } - - #[tokio::test] - #[ignore] // Run with: cargo test -- --ignored - async fn test_integration_connect_timeout() { - // Use a non-routable IP to test connect timeout - let transport = HyperTransport::builder() - .connect_timeout(Duration::from_millis(100)) - .build_http() - .expect("failed to build transport"); - - let request = Request::builder() - .method(Method::GET) - .uri("http://10.255.255.1/") - .body(None) - .expect("failed to build request"); - - let response = transport.request(request).await; - assert!(response.is_err(), "Request should timeout"); - } -} From 523aff7b890d92922477b021ad93084347155ccb Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 19 Feb 2026 13:55:45 -0500 Subject: [PATCH 8/8] feat: Add native-tls feature (#119) --- .github/actions/build-docs/action.yml | 9 ----- .github/actions/ci/action.yml | 20 +++++------ .github/workflows/ci.yml | 29 ++++++++-------- .github/workflows/manual-publish.yml | 1 - .github/workflows/release-please.yml | 3 -- Makefile | 5 +-- contract-tests/Cargo.toml | 11 +++++-- .../src/bin/sse-test-api/stream_entity.rs | 13 ++++++++ eventsource-client/Cargo.toml | 6 ++-- eventsource-client/README.md | 33 ++++++++----------- eventsource-client/examples/tail.rs | 30 ++++++++++++----- eventsource-client/src/client.rs | 2 +- 12 files changed, 89 insertions(+), 73 deletions(-) delete mode 100644 .github/actions/build-docs/action.yml diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml deleted file mode 100644 index 0858db4..0000000 --- a/.github/actions/build-docs/action.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: Build Documentation -description: 'Build Documentation.' - -runs: - using: composite - steps: - - name: Build Documentation - shell: bash - run: cargo doc --no-deps --all-features -p eventsource-client diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index ee9c958..1ef04ff 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -1,11 +1,11 @@ name: CI Workflow -description: 'Shared CI workflow.' +description: "Shared CI workflow." inputs: - feature-flags: - description: 'Cargo feature flags to pass to test and clippy commands' + cargo-flags: + description: "Flags to pass to cargo commands." required: false - default: '' + default: "" runs: using: composite @@ -16,24 +16,24 @@ runs: - name: Run tests shell: bash - run: cargo test ${{ inputs.feature-flags }} -p eventsource-client + run: cargo test ${{ inputs.cargo-flags }} -p eventsource-client - name: Run slower integration tests shell: bash - run: cargo test ${{ inputs.feature-flags }} -p eventsource-client --lib -- --ignored + run: cargo test ${{ inputs.cargo-flags }} -p eventsource-client --lib -- --ignored - name: Run clippy checks shell: bash - run: cargo clippy ${{ inputs.feature-flags }} -p eventsource-client -- -D warnings + run: cargo clippy ${{ inputs.cargo-flags }} -p eventsource-client -- -D warnings - name: Build contract tests shell: bash - run: make build-contract-tests + run: CARGO_FLAGS="${{ inputs.cargo-flags }}" make build-contract-tests - name: Start contract test service shell: bash - run: make start-contract-test-service-bg + run: CARGO_FLAGS="${{ inputs.cargo-flags }}" make start-contract-test-service-bg - name: Run contract tests shell: bash - run: make run-contract-tests + run: CARGO_FLAGS="${{ inputs.cargo-flags }}" make run-contract-tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0efee91..534b1ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,13 +21,13 @@ jobs: matrix: features: - name: "default" - flags: "" - - name: "no-features" - flags: "--no-default-features" - - name: "hyper" - flags: "--no-default-features --features hyper" - - name: "hyper-rustls" - flags: "--no-default-features --features hyper-rustls" + cargo-flags: "" + - name: "hyper-rustls-native-roots" + cargo-flags: "--no-default-features --features hyper-rustls-native-roots" + - name: "hyper-rustls-webpki-roots" + cargo-flags: "--no-default-features --features hyper-rustls-webpki-roots" + - name: "native-tls" + cargo-flags: "--no-default-features --features native-tls" name: CI (${{ matrix.features.name }}) @@ -47,7 +47,7 @@ jobs: - uses: ./.github/actions/ci with: - feature-flags: ${{ matrix.features.flags }} + cargo-flags: ${{ matrix.features.cargo-flags }} build-docs: runs-on: ubuntu-latest @@ -58,12 +58,11 @@ jobs: with: fetch-depth: 0 - - name: Get Rust version - id: rust-version - run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT - - name: Setup rust tooling - run: | - rustup override set ${{ steps.rust-version.outputs.target }} + run: rustup override set nightly + + - name: Install cargo-docs-rs + run: cargo install cargo-docs-rs - - uses: ./.github/actions/build-docs + - name: Build documentation + run: cargo docs-rs -p eventsource-client diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index b9d1e0b..76c5f30 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -27,7 +27,6 @@ jobs: rustup component add rustfmt clippy - uses: ./.github/actions/ci - - uses: ./.github/actions/build-docs - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 name: "Get crates.io token" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 2b00136..3fea8d8 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -43,9 +43,6 @@ jobs: - uses: ./.github/actions/ci if: ${{ steps.release.outputs['eventsource-client--release_created'] == 'true' }} - - uses: ./.github/actions/build-docs - if: ${{ steps.release.outputs['eventsource-client--release_created'] == 'true' }} - - uses: ./.github/actions/publish if: ${{ steps.release.outputs['eventsource-client--release_created'] == 'true' }} with: diff --git a/Makefile b/Makefile index dc672d7..d3ded8a 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,15 @@ TEMP_TEST_OUTPUT=/tmp/contract-test-service.log +CARGO_FLAGS ?= "" build-contract-tests: - @cargo build + @cargo build $(CARGO_FLAGS) start-contract-test-service: build-contract-tests @./target/debug/sse-test-api start-contract-test-service-bg: @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" - @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & + @$(MAKE) start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & run-contract-tests: @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/main/downloader/run.sh \ diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index ef72c84..954db76 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -7,7 +7,7 @@ license = "Apache-2.0" [dependencies] futures = { version = "0.3.21" } serde = { version = "1.0", features = ["derive"] } -eventsource-client = { path = "../eventsource-client", features = ["hyper-rustls"] } +eventsource-client = { path = "../eventsource-client" } serde_json = { version = "1.0.39"} actix = { version = "0.13.1"} actix-web = { version = "4"} @@ -18,7 +18,14 @@ log = "0.4.6" http = "1.0" bytes = "1.5" -launchdarkly-sdk-transport = { git = "https://github.com/launchdarkly/rust-sdk-transport.git", branch = "main" } +launchdarkly-sdk-transport = { version = "0.1.0" } [[bin]] name = "sse-test-api" + +[features] +default = ["hyper"] +hyper = ["eventsource-client/hyper", "launchdarkly-sdk-transport/hyper"] +native-tls = ["hyper", "eventsource-client/native-tls"] +hyper-rustls-native-roots = ["hyper", "eventsource-client/hyper-rustls-native-roots"] +hyper-rustls-webpki-roots = ["hyper", "eventsource-client/hyper-rustls-webpki-roots"] diff --git a/contract-tests/src/bin/sse-test-api/stream_entity.rs b/contract-tests/src/bin/sse-test-api/stream_entity.rs index 13992af..5c202f4 100644 --- a/contract-tests/src/bin/sse-test-api/stream_entity.rs +++ b/contract-tests/src/bin/sse-test-api/stream_entity.rs @@ -131,9 +131,22 @@ impl Inner { transport_builder = transport_builder.read_timeout(Duration::from_millis(timeout_ms)); } + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] let transport = transport_builder .build_https() .map_err(|e| format!("Failed to build HTTPS transport: {e:?}"))?; + #[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + )))] + let transport = transport_builder + .build_http() + .map_err(|e| format!("Failed to build HTTP transport: {e:?}"))?; Ok(Box::new( client_builder diff --git a/eventsource-client/Cargo.toml b/eventsource-client/Cargo.toml index be77f4a..939ae62 100644 --- a/eventsource-client/Cargo.toml +++ b/eventsource-client/Cargo.toml @@ -20,7 +20,7 @@ tokio = { version = "1.17.0", features = ["time"] } rand = "0.8.5" base64 = "0.22.1" -launchdarkly-sdk-transport = { git = "https://github.com/launchdarkly/rust-sdk-transport.git", branch = "main" } +launchdarkly-sdk-transport = { version = "0.1.0" } [dev-dependencies] env_logger = "0.10.0" @@ -38,4 +38,6 @@ required-features = ["hyper"] [features] default = ["hyper"] hyper = ["launchdarkly-sdk-transport/hyper"] -hyper-rustls = ["hyper", "launchdarkly-sdk-transport/hyper-rustls"] +native-tls = ["hyper", "launchdarkly-sdk-transport/native-tls"] +hyper-rustls-native-roots = ["hyper", "launchdarkly-sdk-transport/hyper-rustls-native-roots"] +hyper-rustls-webpki-roots = ["hyper", "launchdarkly-sdk-transport/hyper-rustls-webpki-roots"] diff --git a/eventsource-client/README.md b/eventsource-client/README.md index b022699..99cbc90 100644 --- a/eventsource-client/README.md +++ b/eventsource-client/README.md @@ -9,31 +9,26 @@ This library provides a complete SSE protocol implementation with a built-in HTT [Server-Sent Events]: https://html.spec.whatwg.org/multipage/server-sent-events.html [EventSource]: https://developer.mozilla.org/en-US/docs/Web/API/EventSource -## Requirements - -* Tokio async runtime -* Enable the `hyper` feature for the built-in HTTP transport (enabled by default) -* Optionally enable `hyper-rustls` for HTTPS support - ## Quick Start ### 1. Add dependencies ```toml [dependencies] -eventsource-client = { version = "0.17", features = ["hyper", "hyper-rustls"] } +eventsource-client = { version = "0.17", features = ["hyper-rustls-native-roots"] } futures = "0.3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` **Features:** - `hyper` - Enables the built-in `HyperTransport` for HTTP support (enabled by default) -- `hyper-rustls` - Adds HTTPS support via rustls (optional) +- `hyper-rustls-native-roots`, `hyper-rustls-webpki-roots`, or `native-tls` - Adds HTTPS support via rustls (optional) ### 2. Use the client ```rust -use eventsource_client::{ClientBuilder, HyperTransport, SSE}; +use eventsource_client::{ClientBuilder, SSE}; +use launchdarkly_sdk_transport::HyperTransport; use futures::TryStreamExt; use std::time::Duration; @@ -71,12 +66,14 @@ The `tail` example demonstrates a complete SSE client using the built-in `HyperT **Run with HTTP:** ```bash -cargo run --example tail --features hyper -- http://sse.dev/test "Bearer token" +cargo run --example tail --features hyper -- http://live-test-scores.herokuapp.com/scores "Bearer token" ``` **Run with HTTPS:** ```bash -cargo run --example tail --features hyper,hyper-rustls -- https://sse.dev/test "Bearer token" +cargo run --example tail --features hyper-rustls-native-roots -- https://live-test-scores.herokuapp.com/scores "Bearer token" +cargo run --example tail --features hyper-rustls-webpki-roots -- https://live-test-scores.herokuapp.com/scores "Bearer token" +cargo run --example tail --features native-tls -- https://live-test-scores.herokuapp.com/scores "Bearer token" ``` The example shows: @@ -84,7 +81,7 @@ The example shows: - Building an SSE client with authentication headers - Configuring automatic reconnection with exponential backoff - Handling different SSE event types (events, comments, connection status) -- Proper error handling for HTTPS URLs without the `hyper-rustls` feature +- Proper error handling for HTTPS URLs without the `hyper-rustls-native-roots` feature See [`examples/tail.rs`](https://github.com/launchdarkly/rust-eventsource-client/tree/main/eventsource-client/examples/tail.rs) for the complete implementation. @@ -92,7 +89,7 @@ See [`examples/tail.rs`](https://github.com/launchdarkly/rust-eventsource-client * **Built-in HTTP transport** - Production-ready `HyperTransport` powered by hyper v1 * **Configurable timeouts** - Connect, read, and write timeout support -* **HTTPS support** - Optional rustls integration via the `hyper-rustls` feature +* **HTTPS support** - Optional rustls integration via the `hyper-rustls-*` or `native-tls` features * **Pluggable transport** - Use a custom HTTP client if needed (reqwest, etc.) * **Tokio-based streaming** - Efficient async/await support * **Custom headers** - Full control over HTTP requests @@ -106,9 +103,8 @@ See [`examples/tail.rs`](https://github.com/launchdarkly/rust-eventsource-client While the built-in `HyperTransport` works for most use cases, you can implement the `HttpTransport` trait to use your own HTTP client: ```rust -use eventsource_client::{HttpTransport, ByteStream, TransportError}; -use std::pin::Pin; -use std::future::Future; +use launchdarkly_sdk_transport::{HttpTransport, Request, ResponseFuture}; +use bytes::Bytes; #[derive(Clone)] struct MyTransport { @@ -116,10 +112,7 @@ struct MyTransport { } impl HttpTransport for MyTransport { - fn request( - &self, - request: http::Request>, - ) -> Pin, TransportError>> + Send + Sync + 'static>> { + fn request(&self, request: Request>) -> ResponseFuture { // Implement HTTP request handling // See the HttpTransport trait documentation for details todo!() diff --git a/eventsource-client/examples/tail.rs b/eventsource-client/examples/tail.rs index f069dc5..992031a 100644 --- a/eventsource-client/examples/tail.rs +++ b/eventsource-client/examples/tail.rs @@ -4,12 +4,14 @@ //! //! To run this example with HTTP support: //! ```bash -//! cargo run --example tail --features hyper -- http://example.com/events "Bearer token" +//! cargo run --example tail --features hyper -- http://live-test-scores.herokuapp.com/scores "Bearer token" //! ``` //! //! To run this example with HTTPS support: //! ```bash -//! cargo run --example tail --features hyper,hyper-rustls -- https://example.com/events "Bearer token" +//! cargo run --example tail --features hyper-rustls-native-roots -- https://live-test-scores.herokuapp.com/scores "Bearer token" +//! cargo run --example tail --features hyper-rustls-webpki-roots -- https://live-test-scores.herokuapp.com/scores "Bearer token" +//! cargo run --example tail --features native-tls -- https://live-test-scores.herokuapp.com/scores "Bearer token" //! ``` use futures::{Stream, TryStreamExt}; @@ -27,7 +29,7 @@ async fn main() -> Result<(), Box> { if args.len() != 3 { eprintln!("Please pass args: "); - eprintln!("Example: cargo run --example tail --features hyper https://sse.dev/test 'Bearer token'"); + eprintln!("Example: cargo run --example tail --features hyper https://live-test-scores.herokuapp.com/scores 'Bearer token'"); process::exit(1); } @@ -36,15 +38,23 @@ async fn main() -> Result<(), Box> { // Run the appropriate version based on URL scheme and features if url.starts_with("https://") { - #[cfg(feature = "hyper-rustls")] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] { run_with_https(url, auth_header).await?; } - #[cfg(not(feature = "hyper-rustls"))] + #[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + )))] { - eprintln!("Error: HTTPS URL requires the 'hyper-rustls' feature"); + eprintln!("Error: HTTPS URL requires the 'hyper-rustls-native-roots', 'hyper-rustls-webpki-roots', or 'native-tls' features"); eprintln!( - "Run with: cargo run --example tail --features hyper,hyper-rustls -- {} '{}'", + "Run with: cargo run --example tail --features hyper-rustls-native-roots -- {} '{}'", url, auth_header ); process::exit(1); @@ -81,7 +91,11 @@ async fn run_with_http(url: &str, auth_header: &str) -> Result<(), Box Result<(), Box> { let transport = HyperTransport::builder() .connect_timeout(Duration::from_secs(10)) diff --git a/eventsource-client/src/client.rs b/eventsource-client/src/client.rs index 48d6330..2db6b31 100644 --- a/eventsource-client/src/client.rs +++ b/eventsource-client/src/client.rs @@ -154,7 +154,7 @@ impl ClientBuilder { /// use eventsource_client::ClientBuilder; /// /// let transport = MyTransport::new(); - /// let client = ClientBuilder::for_url("https://sse.dev/test") + /// let client = ClientBuilder::for_url("https://live-test-scores.herokuapp.com/scores") /// .expect("failed to create client builder") /// .build_with_transport(transport); /// ```