// http.rs — Minimal async HTTP client // // Replaces reqwest with direct hyper + rustls. No tracing dependency. // Supports: GET/POST, JSON/form bodies, streaming responses, TLS. use anyhow::{Context, Result}; use bytes::Bytes; use http_body_util::{BodyExt, Full, Empty}; use hyper::body::Incoming; use hyper::{Request, StatusCode}; use hyper_util::rt::TokioIo; use rustls::ClientConfig; use std::sync::Arc; use std::time::Duration; use tokio::net::TcpStream; /// Lightweight async HTTP client with connection pooling via keep-alive. #[derive(Clone)] pub struct HttpClient { tls: Arc, connect_timeout: Duration, request_timeout: Duration, } /// An in-flight response — provides status, headers, and body access. pub struct HttpResponse { parts: http::response::Parts, body: Incoming, } impl HttpClient { pub fn new() -> Self { Self::builder().build() } pub fn builder() -> HttpClientBuilder { HttpClientBuilder { connect_timeout: Duration::from_secs(30), request_timeout: Duration::from_secs(600), } } /// Send a GET request. pub async fn get(&self, url: &str) -> Result { self.get_with_headers(url, &[]).await } /// Send a GET request with custom headers. pub async fn get_with_headers(&self, url: &str, headers: &[(&str, &str)]) -> Result { let mut builder = Request::get(url); for &(k, v) in headers { builder = builder.header(k, v); } let req = builder.body(Empty::::new()) .context("building GET request")?; self.send_empty(req).await } /// Send a POST request with URL-encoded form data. pub async fn post_form(&self, url: &str, params: &[(&str, &str)]) -> Result { let body = serde_urlencoded::to_string(params).context("encoding form")?; let req = Request::post(url) .header("content-type", "application/x-www-form-urlencoded") .body(Full::new(Bytes::from(body))) .context("building form POST")?; self.send_full(req).await } /// Send a request with headers pre-set. JSON body. pub async fn send_json( &self, method: &str, url: &str, headers: &[(&str, &str)], body: &impl serde::Serialize, ) -> Result { let json = serde_json::to_vec(body).context("serializing JSON body")?; let mut builder = Request::builder() .method(method) .uri(url) .header("content-type", "application/json"); for &(k, v) in headers { builder = builder.header(k, v); } let req = builder.body(Full::new(Bytes::from(json))) .context("building request")?; self.send_full(req).await } async fn connect(&self, url: &str) -> Result<(bool, TokioIo>)> { let uri: http::Uri = url.parse().context("parsing URL")?; let host = uri.host().context("URL has no host")?.to_string(); let is_https = uri.scheme_str() == Some("https"); let port = uri.port_u16().unwrap_or(if is_https { 443 } else { 80 }); let tcp = tokio::time::timeout( self.connect_timeout, TcpStream::connect(format!("{}:{}", host, port)), ).await .context("connect timeout")? .context("TCP connect")?; if is_https { let server_name = rustls::pki_types::ServerName::try_from(host.clone()) .map_err(|e| anyhow::anyhow!("invalid server name: {}", e))?; let connector = tokio_rustls::TlsConnector::from(self.tls.clone()); let tls = connector.connect(server_name.to_owned(), tcp).await .context("TLS handshake")?; Ok((is_https, TokioIo::new(Box::new(tls) as Box))) } else { Ok((is_https, TokioIo::new(Box::new(tcp) as Box))) } } async fn send_full(&self, req: Request>) -> Result { let url = req.uri().to_string(); let (_is_https, io) = self.connect(&url).await?; let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await .context("HTTP handshake")?; tokio::spawn(conn); let resp = tokio::time::timeout( self.request_timeout, sender.send_request(req), ).await .context("request timeout")? .context("sending request")?; let (parts, body) = resp.into_parts(); Ok(HttpResponse { parts, body }) } async fn send_empty(&self, req: Request>) -> Result { let url = req.uri().to_string(); let (_is_https, io) = self.connect(&url).await?; let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await .context("HTTP handshake")?; tokio::spawn(conn); let resp = tokio::time::timeout( self.request_timeout, sender.send_request(req), ).await .context("request timeout")? .context("sending request")?; let (parts, body) = resp.into_parts(); Ok(HttpResponse { parts, body }) } } impl HttpResponse { pub fn status(&self) -> StatusCode { self.parts.status } pub fn header(&self, name: &str) -> Option<&str> { self.parts.headers.get(name)?.to_str().ok() } /// Read the entire body as text. pub async fn text(self) -> Result { let bytes = self.body.collect().await .context("reading response body")? .to_bytes(); Ok(String::from_utf8_lossy(&bytes).into_owned()) } /// Read the entire body and deserialize as JSON. pub async fn json(self) -> Result { let bytes = self.body.collect().await .context("reading response body")? .to_bytes(); serde_json::from_slice(&bytes).context("deserializing JSON response") } /// Read the next chunk from the response body (for SSE streaming). /// Returns None when the body is complete. pub async fn chunk(&mut self) -> Result> { match self.body.frame().await { Some(Ok(frame)) => Ok(frame.into_data().ok()), Some(Err(e)) => Err(anyhow::anyhow!("body read error: {}", e)), None => Ok(None), } } } pub struct HttpClientBuilder { connect_timeout: Duration, request_timeout: Duration, } impl HttpClientBuilder { pub fn connect_timeout(mut self, d: Duration) -> Self { self.connect_timeout = d; self } pub fn timeout(mut self, d: Duration) -> Self { self.request_timeout = d; self } pub fn build(self) -> HttpClient { let certs = rustls_native_certs::load_native_certs() .certs.into_iter() .collect::>(); let mut root_store = rustls::RootCertStore::empty(); for cert in certs { root_store.add(cert).ok(); } let tls = Arc::new( ClientConfig::builder() .with_root_certificates(root_store) .with_no_client_auth() ); HttpClient { tls, connect_timeout: self.connect_timeout, request_timeout: self.request_timeout, } } } /// Trait alias for streams that work with hyper's IO adapter. trait IoStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static {} impl IoStream for T {}