Skip to main content

rust_web_server/http_client/
mod.rs

1//! Outbound HTTP/1.1 client.
2//!
3//! A minimal synchronous HTTP/1.1 + HTTPS client with no third-party HTTP
4//! dependency.  TLS is backed by `rustls` (the same crate used by the server's
5//! inbound TLS stack).
6//!
7//! # Plain HTTP (always available)
8//!
9//! ```rust,no_run
10//! use rust_web_server::http_client::Client;
11//!
12//! let resp = Client::new()
13//!     .get("http://httpbin.org/get")
14//!     .header("X-Request-Id", "abc123")
15//!     .timeout_ms(5_000)
16//!     .send()
17//!     .unwrap();
18//!
19//! assert!(resp.is_success());
20//! println!("{}", resp.text().unwrap());
21//! ```
22//!
23//! # HTTPS
24//!
25//! Requires the `http-client` feature (or `http2`/`http3`, which already pull
26//! in `rustls`):
27//!
28//! ```toml
29//! [dependencies]
30//! rust-web-server = { version = "17", features = ["http-client"] }
31//! ```
32//!
33//! Then use exactly the same API — the scheme in the URL selects the transport.
34//!
35//! # Async client
36//!
37//! Gated on the `http2` feature:
38//!
39//! ```rust,no_run
40//! # #[cfg(feature = "http2")]
41//! # async fn example() -> Result<(), rust_web_server::http_client::HttpClientError> {
42//! use rust_web_server::http_client::AsyncClient;
43//!
44//! let resp = AsyncClient::new()
45//!     .get("https://api.example.com/users")
46//!     .header("Authorization", "Bearer tok_…")
47//!     .send()
48//!     .await?;
49//!
50//! println!("{}", resp.text()?);
51//! # Ok(())
52//! # }
53//! ```
54
55#[cfg(test)]
56mod tests;
57
58use std::io::{Read, Write};
59use std::net::TcpStream;
60use std::time::Duration;
61
62#[cfg(any(feature = "http-client", feature = "http2"))]
63use std::sync::Arc;
64
65// ── Error type ────────────────────────────────────────────────────────────────
66
67/// Error returned by the HTTP client.
68#[derive(Debug)]
69pub struct HttpClientError(pub String);
70
71impl std::fmt::Display for HttpClientError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str(&self.0)
74    }
75}
76
77impl std::error::Error for HttpClientError {}
78
79// ── URL parser ────────────────────────────────────────────────────────────────
80
81struct ParsedUrl {
82    scheme: String,
83    host: String,
84    port: u16,
85    path_and_query: String,
86}
87
88impl ParsedUrl {
89    fn parse(url: &str) -> Result<Self, HttpClientError> {
90        // Expect "scheme://rest"
91        let rest = if let Some(r) = url.strip_prefix("https://") {
92            ("https", r)
93        } else if let Some(r) = url.strip_prefix("http://") {
94            ("http", r)
95        } else {
96            return Err(HttpClientError(format!(
97                "unsupported or missing URL scheme in '{url}'"
98            )));
99        };
100
101        let (scheme, authority_and_path) = rest;
102        let default_port: u16 = if scheme == "https" { 443 } else { 80 };
103
104        // Split authority from path at the first '/'
105        let (authority, path_and_query) = match authority_and_path.find('/') {
106            Some(idx) => {
107                let (a, p) = authority_and_path.split_at(idx);
108                (a, p.to_string())
109            }
110            None => (authority_and_path, "/".to_string()),
111        };
112
113        // Split host and optional port
114        let (host, port) = if let Some(bracket_end) = authority.find(']') {
115            // IPv6 literal: [::1]:port
116            let host = &authority[..=bracket_end];
117            let port_part = &authority[bracket_end + 1..];
118            let port = if let Some(p) = port_part.strip_prefix(':') {
119                p.parse::<u16>().map_err(|_| {
120                    HttpClientError(format!("invalid port in URL '{url}'"))
121                })?
122            } else {
123                default_port
124            };
125            (host.to_string(), port)
126        } else {
127            match authority.rfind(':') {
128                Some(idx) => {
129                    let port_str = &authority[idx + 1..];
130                    let port = port_str.parse::<u16>().map_err(|_| {
131                        HttpClientError(format!("invalid port in URL '{url}'"))
132                    })?;
133                    (authority[..idx].to_string(), port)
134                }
135                None => (authority.to_string(), default_port),
136            }
137        };
138
139        if host.is_empty() {
140            return Err(HttpClientError(format!("missing host in URL '{url}'")));
141        }
142
143        Ok(ParsedUrl {
144            scheme: scheme.to_string(),
145            host,
146            port,
147            path_and_query,
148        })
149    }
150}
151
152/// Resolve `location` against `base_url`.  If `location` is already absolute
153/// it is returned as-is.  A path starting with '/' is resolved against the
154/// origin of `base_url`.
155fn resolve_url(base_url: &str, location: &str) -> String {
156    if location.starts_with("http://") || location.starts_with("https://") {
157        return location.to_string();
158    }
159    // relative — reconstruct origin from base
160    if let Ok(base) = ParsedUrl::parse(base_url) {
161        let default_port = if base.scheme == "https" { 443 } else { 80 };
162        let port_str = if base.port == default_port {
163            String::new()
164        } else {
165            format!(":{}", base.port)
166        };
167        if location.starts_with('/') {
168            return format!("{}://{}{}{}", base.scheme, base.host, port_str, location);
169        }
170        // relative path — resolve against directory of current path
171        let base_path = base.path_and_query;
172        let dir = match base_path.rfind('/') {
173            Some(i) => &base_path[..=i],
174            None => "/",
175        };
176        return format!(
177            "{}://{}{}{}{}",
178            base.scheme, base.host, port_str, dir, location
179        );
180    }
181    location.to_string()
182}
183
184// ── Response ──────────────────────────────────────────────────────────────────
185
186/// HTTP response from the outbound client.
187#[derive(Debug)]
188pub struct Response {
189    status: u16,
190    headers: Vec<(String, String)>,
191    body: Vec<u8>,
192}
193
194impl Response {
195    /// HTTP status code.
196    pub fn status(&self) -> u16 {
197        self.status
198    }
199
200    /// `true` if the status code is in the 200–299 range.
201    pub fn is_success(&self) -> bool {
202        (200..300).contains(&self.status)
203    }
204
205    /// `true` if the status code is 301, 302, 303, 307, or 308.
206    pub fn is_redirect(&self) -> bool {
207        matches!(self.status, 301 | 302 | 303 | 307 | 308)
208    }
209
210    /// Look up a response header by name (case-insensitive).
211    pub fn header(&self, name: &str) -> Option<&str> {
212        let lower = name.to_lowercase();
213        self.headers
214            .iter()
215            .find(|(k, _)| k.to_lowercase() == lower)
216            .map(|(_, v)| v.as_str())
217    }
218
219    /// All response headers, in the order the server sent them. Use this
220    /// when you need to enumerate every header rather than look one up by
221    /// name — e.g. forwarding a response verbatim.
222    pub fn headers(&self) -> &[(String, String)] {
223        &self.headers
224    }
225
226    /// Raw response body bytes.
227    pub fn bytes(&self) -> &[u8] {
228        &self.body
229    }
230
231    /// Decode the body as UTF-8.
232    pub fn text(&self) -> Result<String, HttpClientError> {
233        String::from_utf8(self.body.clone())
234            .map_err(|e| HttpClientError(format!("body is not valid UTF-8: {e}")))
235    }
236
237    /// Parse the body as JSON.
238    #[cfg(feature = "serde")]
239    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, HttpClientError> {
240        serde_json::from_slice(&self.body)
241            .map_err(|e| HttpClientError(format!("JSON parse error: {e}")))
242    }
243}
244
245// ── Wire-level helpers ────────────────────────────────────────────────────────
246
247/// Build the HTTP/1.1 request bytes.
248fn build_request_bytes(
249    method: &str,
250    path_and_query: &str,
251    host: &str,
252    headers: &[(String, String)],
253    body: &Option<Vec<u8>>,
254) -> Vec<u8> {
255    let mut out: Vec<u8> = Vec::new();
256
257    // Status line
258    let _ = write!(
259        out,
260        "{method} {path_and_query} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: rust-web-server/{}\r\n",
261        env!("CARGO_PKG_VERSION"),
262    );
263
264    // Content-Length (before custom headers, so callers can override)
265    if let Some(b) = body {
266        if !b.is_empty() {
267            let _ = write!(out, "Content-Length: {}\r\n", b.len());
268        }
269    }
270
271    // Custom headers
272    for (k, v) in headers {
273        let _ = write!(out, "{k}: {v}\r\n");
274    }
275
276    out.extend_from_slice(b"\r\n");
277
278    if let Some(b) = body {
279        out.extend_from_slice(b);
280    }
281
282    out
283}
284
285/// Parse an HTTP/1.1 response from any `Read` source.
286fn read_response(stream: &mut dyn Read, is_head: bool) -> Result<Response, HttpClientError> {
287    let mut buf: Vec<u8> = Vec::with_capacity(8192);
288    let mut tmp = [0u8; 4096];
289
290    // Read until we find the end of headers (\r\n\r\n)
291    let header_end = loop {
292        let n = stream
293            .read(&mut tmp)
294            .map_err(|e| HttpClientError(format!("read error: {e}")))?;
295        if n == 0 {
296            if buf.is_empty() {
297                return Err(HttpClientError(
298                    "server closed connection without sending a response".into(),
299                ));
300            }
301            // EOF before \r\n\r\n — try to parse whatever we got
302            break buf.len();
303        }
304        buf.extend_from_slice(&tmp[..n]);
305        if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
306            break pos + 4;
307        }
308    };
309
310    // Split header block
311    let header_block = std::str::from_utf8(&buf[..header_end])
312        .map_err(|_| HttpClientError("response headers are not valid UTF-8".into()))?;
313
314    let mut lines = header_block.lines();
315
316    // Status line
317    let status_line = lines
318        .next()
319        .ok_or_else(|| HttpClientError("empty response".into()))?;
320    let status = parse_status(status_line)?;
321
322    // Headers
323    let response_headers: Vec<(String, String)> = lines
324        .filter_map(|line| {
325            let mut parts = line.splitn(2, ':');
326            let name = parts.next()?.trim().to_string();
327            let value = parts.next()?.trim().to_string();
328            if name.is_empty() {
329                None
330            } else {
331                Some((name, value))
332            }
333        })
334        .collect();
335
336    // Body — already-buffered bytes beyond the header block
337    let mut body = buf[header_end..].to_vec();
338
339    if !is_head {
340        // Determine body reading strategy from headers
341        let transfer_encoding = response_headers
342            .iter()
343            .find(|(k, _)| k.to_lowercase() == "transfer-encoding")
344            .map(|(_, v)| v.to_lowercase());
345
346        let content_length: Option<usize> = response_headers
347            .iter()
348            .find(|(k, _)| k.to_lowercase() == "content-length")
349            .and_then(|(_, v)| v.trim().parse().ok());
350
351        if transfer_encoding
352            .as_deref()
353            .map(|te| te.contains("chunked"))
354            .unwrap_or(false)
355        {
356            // Read remaining chunked data then decode
357            loop {
358                let n = stream
359                    .read(&mut tmp)
360                    .map_err(|e| HttpClientError(format!("read error: {e}")))?;
361                if n == 0 {
362                    break;
363                }
364                body.extend_from_slice(&tmp[..n]);
365            }
366            body = decode_chunked(&body)?;
367        } else if let Some(len) = content_length {
368            while body.len() < len {
369                let n = stream
370                    .read(&mut tmp)
371                    .map_err(|e| HttpClientError(format!("read error: {e}")))?;
372                if n == 0 {
373                    break;
374                }
375                body.extend_from_slice(&tmp[..n]);
376            }
377            body.truncate(len);
378        } else {
379            // Read until EOF (Connection: close)
380            loop {
381                let n = stream
382                    .read(&mut tmp)
383                    .map_err(|e| HttpClientError(format!("read error: {e}")))?;
384                if n == 0 {
385                    break;
386                }
387                body.extend_from_slice(&tmp[..n]);
388            }
389        }
390    } else {
391        body.clear();
392    }
393
394    Ok(Response {
395        status,
396        headers: response_headers,
397        body,
398    })
399}
400
401fn parse_status(line: &str) -> Result<u16, HttpClientError> {
402    // "HTTP/1.x 200 Reason ..."
403    let mut parts = line.splitn(3, ' ');
404    let _version = parts
405        .next()
406        .ok_or_else(|| HttpClientError("malformed status line".into()))?;
407    let code_str = parts
408        .next()
409        .ok_or_else(|| HttpClientError("missing status code".into()))?;
410    code_str
411        .parse::<u16>()
412        .map_err(|_| HttpClientError(format!("invalid status code '{code_str}'")))
413}
414
415/// Decode chunked transfer encoding.
416fn decode_chunked(data: &[u8]) -> Result<Vec<u8>, HttpClientError> {
417    let mut out = Vec::new();
418    let mut pos = 0;
419
420    while pos < data.len() {
421        // Find end of chunk-size line
422        let line_end = data[pos..]
423            .windows(2)
424            .position(|w| w == b"\r\n")
425            .ok_or_else(|| HttpClientError("invalid chunked encoding: missing CRLF".into()))?;
426        let size_line = std::str::from_utf8(&data[pos..pos + line_end])
427            .map_err(|_| HttpClientError("chunked size is not ASCII".into()))?
428            .trim();
429        // Strip optional chunk extensions (;ext)
430        let size_str = size_line.split(';').next().unwrap_or("").trim();
431        let chunk_size = usize::from_str_radix(size_str, 16)
432            .map_err(|_| HttpClientError(format!("invalid chunk size '{size_str}'")))?;
433        pos += line_end + 2; // skip size line + CRLF
434
435        if chunk_size == 0 {
436            break; // last chunk
437        }
438
439        let end = pos + chunk_size;
440        if end > data.len() {
441            return Err(HttpClientError("chunked body truncated".into()));
442        }
443        out.extend_from_slice(&data[pos..end]);
444        pos = end + 2; // skip trailing CRLF after chunk data
445    }
446
447    Ok(out)
448}
449
450// ── TLS connector (sync) ──────────────────────────────────────────────────────
451
452#[cfg(any(feature = "http-client", feature = "http2"))]
453fn tls_connect(
454    host: &str,
455    tcp: TcpStream,
456) -> Result<rustls::StreamOwned<rustls::ClientConnection, TcpStream>, HttpClientError> {
457    use rustls::pki_types::ServerName;
458    use rustls::ClientConfig;
459
460    let root_store =
461        rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
462    let config = Arc::new(
463        ClientConfig::builder()
464            .with_root_certificates(root_store)
465            .with_no_client_auth(),
466    );
467    let server_name = ServerName::try_from(host.to_string())
468        .map_err(|e| HttpClientError(format!("invalid hostname '{host}': {e}")))?;
469    let conn = rustls::ClientConnection::new(config, server_name)
470        .map_err(|e| HttpClientError(e.to_string()))?;
471    Ok(rustls::StreamOwned::new(conn, tcp))
472}
473
474// ── Core send (one hop, no redirect) ─────────────────────────────────────────
475
476fn send_once(
477    method: &str,
478    parsed: &ParsedUrl,
479    headers: &[(String, String)],
480    body: &Option<Vec<u8>>,
481    timeout_ms: u64,
482) -> Result<Response, HttpClientError> {
483    let addr = format!("{}:{}", parsed.host, parsed.port);
484    let timeout = Duration::from_millis(timeout_ms);
485
486    // Resolve + connect
487    let sock_addr = addr
488        .parse::<std::net::SocketAddr>()
489        .or_else(|_| {
490            use std::net::ToSocketAddrs;
491            addr.to_socket_addrs()
492                .map_err(|e| HttpClientError(format!("DNS lookup for '{addr}' failed: {e}")))?
493                .next()
494                .ok_or_else(|| HttpClientError(format!("no address for '{addr}'")))
495        })
496        .map_err(|e: HttpClientError| e)?;
497
498    let tcp = TcpStream::connect_timeout(&sock_addr, timeout)
499        .map_err(|e| HttpClientError(format!("connect to '{addr}' failed: {e}")))?;
500    tcp.set_read_timeout(Some(timeout))
501        .map_err(|e| HttpClientError(e.to_string()))?;
502    tcp.set_write_timeout(Some(timeout))
503        .map_err(|e| HttpClientError(e.to_string()))?;
504
505    let request_bytes =
506        build_request_bytes(method, &parsed.path_and_query, &parsed.host, headers, body);
507
508    let is_head = method.eq_ignore_ascii_case("HEAD");
509
510    // Dispatch on scheme
511    #[cfg(any(feature = "http-client", feature = "http2"))]
512    if parsed.scheme == "https" {
513        let mut tls_stream = tls_connect(&parsed.host, tcp)?;
514        tls_stream
515            .write_all(&request_bytes)
516            .map_err(|e| HttpClientError(format!("write error: {e}")))?;
517        return read_response(&mut tls_stream, is_head);
518    }
519
520    // Plain HTTP
521    let mut stream = tcp;
522    stream
523        .write_all(&request_bytes)
524        .map_err(|e| HttpClientError(format!("write error: {e}")))?;
525    read_response(&mut stream, is_head)
526}
527
528// ── Client ────────────────────────────────────────────────────────────────────
529
530/// Synchronous HTTP/1.1 client.
531///
532/// Construct with [`Client::new()`], then call one of the method helpers
533/// (`.get()`, `.post()`, …) to get a [`RequestBuilder`], configure it, and
534/// call `.send()`.
535pub struct Client {
536    timeout_ms: u64,
537    max_redirects: u8,
538}
539
540impl Client {
541    /// Create a client with default settings:
542    /// - `timeout_ms`: 30 000 (30 seconds)
543    /// - `max_redirects`: 10
544    pub fn new() -> Self {
545        Self {
546            timeout_ms: 30_000,
547            max_redirects: 10,
548        }
549    }
550
551    /// Override the per-request timeout (connect + read combined).
552    pub fn timeout_ms(mut self, ms: u64) -> Self {
553        self.timeout_ms = ms;
554        self
555    }
556
557    /// Maximum number of redirects to follow (default: 10).
558    pub fn max_redirects(mut self, n: u8) -> Self {
559        self.max_redirects = n;
560        self
561    }
562
563    /// Start building a GET request.
564    pub fn get(&self, url: &str) -> RequestBuilder<'_> {
565        self.request("GET", url)
566    }
567
568    /// Start building a POST request.
569    pub fn post(&self, url: &str) -> RequestBuilder<'_> {
570        self.request("POST", url)
571    }
572
573    /// Start building a PUT request.
574    pub fn put(&self, url: &str) -> RequestBuilder<'_> {
575        self.request("PUT", url)
576    }
577
578    /// Start building a PATCH request.
579    pub fn patch(&self, url: &str) -> RequestBuilder<'_> {
580        self.request("PATCH", url)
581    }
582
583    /// Start building a DELETE request.
584    pub fn delete(&self, url: &str) -> RequestBuilder<'_> {
585        self.request("DELETE", url)
586    }
587
588    /// Start building a HEAD request.
589    pub fn head(&self, url: &str) -> RequestBuilder<'_> {
590        self.request("HEAD", url)
591    }
592
593    /// Start building a request with an arbitrary HTTP method.
594    pub fn request(&self, method: &str, url: &str) -> RequestBuilder<'_> {
595        RequestBuilder {
596            client: self,
597            method: method.to_uppercase(),
598            url: url.to_string(),
599            headers: Vec::new(),
600            body: None,
601            timeout_ms: None,
602        }
603    }
604}
605
606impl Default for Client {
607    fn default() -> Self {
608        Self::new()
609    }
610}
611
612// ── RequestBuilder ────────────────────────────────────────────────────────────
613
614/// Builder for a single HTTP request.
615pub struct RequestBuilder<'a> {
616    client: &'a Client,
617    method: String,
618    url: String,
619    headers: Vec<(String, String)>,
620    body: Option<Vec<u8>>,
621    timeout_ms: Option<u64>,
622}
623
624impl<'a> RequestBuilder<'a> {
625    /// Add a request header.
626    pub fn header(mut self, name: &str, value: &str) -> Self {
627        self.headers.push((name.to_string(), value.to_string()));
628        self
629    }
630
631    /// Set a raw byte body.
632    pub fn body(mut self, bytes: Vec<u8>) -> Self {
633        self.body = Some(bytes);
634        self
635    }
636
637    /// Set a plain-text body (also sets `Content-Type: text/plain`).
638    pub fn body_text(mut self, s: &str) -> Self {
639        self.headers
640            .push(("Content-Type".to_string(), "text/plain".to_string()));
641        self.body = Some(s.as_bytes().to_vec());
642        self
643    }
644
645    /// Set a JSON body (also sets `Content-Type: application/json`).
646    pub fn body_json(mut self, s: &str) -> Self {
647        self.headers.push((
648            "Content-Type".to_string(),
649            "application/json".to_string(),
650        ));
651        self.body = Some(s.as_bytes().to_vec());
652        self
653    }
654
655    /// Override the timeout for this request.
656    pub fn timeout_ms(mut self, ms: u64) -> Self {
657        self.timeout_ms = Some(ms);
658        self
659    }
660
661    /// Send the request and return the response.
662    ///
663    /// Automatically follows redirects up to the client's `max_redirects`
664    /// limit.
665    pub fn send(self) -> Result<Response, HttpClientError> {
666        let timeout = self.timeout_ms.unwrap_or(self.client.timeout_ms);
667        let max_redirects = self.client.max_redirects;
668
669        let mut method = self.method;
670        let mut url = self.url;
671        let headers = self.headers;
672        let mut body = self.body;
673        let mut redirects = 0u8;
674
675        loop {
676            let parsed = ParsedUrl::parse(&url)?;
677            let resp = send_once(&method, &parsed, &headers, &body, timeout)?;
678
679            if resp.is_redirect() && redirects < max_redirects {
680                let location = resp
681                    .header("location")
682                    .ok_or_else(|| HttpClientError("redirect with no Location header".into()))?
683                    .to_string();
684                url = resolve_url(&url, &location);
685                redirects += 1;
686                if matches!(resp.status(), 301 | 302 | 303) {
687                    method = "GET".to_string();
688                    body = None;
689                }
690                continue;
691            }
692
693            return Ok(resp);
694        }
695    }
696}
697
698// ── Async client (`http2` feature) ───────────────────────────────────────────
699
700#[cfg(feature = "http2")]
701pub use async_impl::{AsyncClient, AsyncRequestBuilder};
702
703#[cfg(feature = "http2")]
704mod async_impl {
705    use super::{
706        build_request_bytes, decode_chunked, parse_status, resolve_url, HttpClientError,
707        ParsedUrl, Response,
708    };
709    use std::sync::Arc;
710    use tokio::io::{AsyncReadExt, AsyncWriteExt};
711
712    async fn async_tls_connect(
713        host: &str,
714        stream: tokio::net::TcpStream,
715    ) -> Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>, HttpClientError> {
716        use rustls::pki_types::ServerName;
717        use rustls::ClientConfig;
718        use tokio_rustls::TlsConnector;
719
720        let root_store = rustls::RootCertStore::from_iter(
721            webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
722        );
723        let config = Arc::new(
724            ClientConfig::builder()
725                .with_root_certificates(root_store)
726                .with_no_client_auth(),
727        );
728        let connector = TlsConnector::from(config);
729        let server_name = ServerName::try_from(host.to_string())
730            .map_err(|e| HttpClientError(format!("invalid hostname '{host}': {e}")))?;
731        connector
732            .connect(server_name, stream)
733            .await
734            .map_err(|e| HttpClientError(format!("TLS handshake failed: {e}")))
735    }
736
737    async fn async_read_response(
738        stream: &mut (impl AsyncReadExt + Unpin),
739        is_head: bool,
740    ) -> Result<Response, HttpClientError> {
741        let mut buf: Vec<u8> = Vec::with_capacity(8192);
742        let mut tmp = vec![0u8; 4096];
743
744        let header_end = loop {
745            let n = stream
746                .read(&mut tmp)
747                .await
748                .map_err(|e| HttpClientError(format!("read error: {e}")))?;
749            if n == 0 {
750                if buf.is_empty() {
751                    return Err(HttpClientError(
752                        "server closed connection without a response".into(),
753                    ));
754                }
755                break buf.len();
756            }
757            buf.extend_from_slice(&tmp[..n]);
758            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
759                break pos + 4;
760            }
761        };
762
763        let header_block = std::str::from_utf8(&buf[..header_end])
764            .map_err(|_| HttpClientError("response headers not UTF-8".into()))?;
765
766        let mut lines = header_block.lines();
767        let status_line = lines
768            .next()
769            .ok_or_else(|| HttpClientError("empty response".into()))?;
770        let status = parse_status(status_line)?;
771
772        let response_headers: Vec<(String, String)> = lines
773            .filter_map(|line| {
774                let mut parts = line.splitn(2, ':');
775                let name = parts.next()?.trim().to_string();
776                let value = parts.next()?.trim().to_string();
777                if name.is_empty() { None } else { Some((name, value)) }
778            })
779            .collect();
780
781        let mut body = buf[header_end..].to_vec();
782
783        if !is_head {
784            let transfer_encoding = response_headers
785                .iter()
786                .find(|(k, _)| k.to_lowercase() == "transfer-encoding")
787                .map(|(_, v)| v.to_lowercase());
788
789            let content_length: Option<usize> = response_headers
790                .iter()
791                .find(|(k, _)| k.to_lowercase() == "content-length")
792                .and_then(|(_, v)| v.trim().parse().ok());
793
794            if transfer_encoding
795                .as_deref()
796                .map(|te| te.contains("chunked"))
797                .unwrap_or(false)
798            {
799                loop {
800                    let n = stream.read(&mut tmp).await
801                        .map_err(|e| HttpClientError(format!("read error: {e}")))?;
802                    if n == 0 { break; }
803                    body.extend_from_slice(&tmp[..n]);
804                }
805                body = decode_chunked(&body)?;
806            } else if let Some(len) = content_length {
807                while body.len() < len {
808                    let n = stream.read(&mut tmp).await
809                        .map_err(|e| HttpClientError(format!("read error: {e}")))?;
810                    if n == 0 { break; }
811                    body.extend_from_slice(&tmp[..n]);
812                }
813                body.truncate(len);
814            } else {
815                loop {
816                    let n = stream.read(&mut tmp).await
817                        .map_err(|e| HttpClientError(format!("read error: {e}")))?;
818                    if n == 0 { break; }
819                    body.extend_from_slice(&tmp[..n]);
820                }
821            }
822        } else {
823            body.clear();
824        }
825
826        Ok(Response { status, headers: response_headers, body })
827    }
828
829    async fn async_send_once(
830        method: &str,
831        parsed: &ParsedUrl,
832        headers: &[(String, String)],
833        body: &Option<Vec<u8>>,
834        timeout_ms: u64,
835    ) -> Result<Response, HttpClientError> {
836        use std::time::Duration;
837        use tokio::net::TcpStream;
838        use tokio::time::timeout;
839
840        let addr = format!("{}:{}", parsed.host, parsed.port);
841        let dur = Duration::from_millis(timeout_ms);
842        let request_bytes =
843            build_request_bytes(method, &parsed.path_and_query, &parsed.host, headers, body);
844        let is_head = method.eq_ignore_ascii_case("HEAD");
845
846        let tcp = timeout(dur, TcpStream::connect(&addr))
847            .await
848            .map_err(|_| HttpClientError(format!("connect to '{addr}' timed out")))?
849            .map_err(|e| HttpClientError(format!("connect to '{addr}' failed: {e}")))?;
850
851        if parsed.scheme == "https" {
852            let tls_stream = timeout(dur, async_tls_connect(&parsed.host, tcp))
853                .await
854                .map_err(|_| HttpClientError("TLS handshake timed out".into()))??;
855            let mut stream = tls_stream;
856            timeout(dur, stream.write_all(&request_bytes))
857                .await
858                .map_err(|_| HttpClientError("write timed out".into()))?
859                .map_err(|e| HttpClientError(format!("write error: {e}")))?;
860            return timeout(dur, async_read_response(&mut stream, is_head))
861                .await
862                .map_err(|_| HttpClientError("read timed out".into()))?;
863        }
864
865        let mut stream = tcp;
866        timeout(dur, stream.write_all(&request_bytes))
867            .await
868            .map_err(|_| HttpClientError("write timed out".into()))?
869            .map_err(|e| HttpClientError(format!("write error: {e}")))?;
870        timeout(dur, async_read_response(&mut stream, is_head))
871            .await
872            .map_err(|_| HttpClientError("read timed out".into()))?
873    }
874
875    /// Asynchronous HTTP/1.1 client (`http2` feature required).
876    pub struct AsyncClient {
877        timeout_ms: u64,
878        max_redirects: u8,
879    }
880
881    impl AsyncClient {
882        /// Create with default settings (30 s timeout, 10 redirects).
883        pub fn new() -> Self {
884            Self {
885                timeout_ms: 30_000,
886                max_redirects: 10,
887            }
888        }
889
890        /// Override the per-request timeout.
891        pub fn timeout_ms(mut self, ms: u64) -> Self {
892            self.timeout_ms = ms;
893            self
894        }
895
896        /// Maximum redirects to follow.
897        pub fn max_redirects(mut self, n: u8) -> Self {
898            self.max_redirects = n;
899            self
900        }
901
902        /// Start a GET request.
903        pub fn get(&self, url: &str) -> AsyncRequestBuilder<'_> {
904            self.request("GET", url)
905        }
906
907        /// Start a POST request.
908        pub fn post(&self, url: &str) -> AsyncRequestBuilder<'_> {
909            self.request("POST", url)
910        }
911
912        /// Start a PUT request.
913        pub fn put(&self, url: &str) -> AsyncRequestBuilder<'_> {
914            self.request("PUT", url)
915        }
916
917        /// Start a PATCH request.
918        pub fn patch(&self, url: &str) -> AsyncRequestBuilder<'_> {
919            self.request("PATCH", url)
920        }
921
922        /// Start a DELETE request.
923        pub fn delete(&self, url: &str) -> AsyncRequestBuilder<'_> {
924            self.request("DELETE", url)
925        }
926
927        /// Start a request with an arbitrary method.
928        pub fn request(&self, method: &str, url: &str) -> AsyncRequestBuilder<'_> {
929            AsyncRequestBuilder {
930                client: self,
931                method: method.to_uppercase(),
932                url: url.to_string(),
933                headers: Vec::new(),
934                body: None,
935                timeout_ms: None,
936            }
937        }
938    }
939
940    impl Default for AsyncClient {
941        fn default() -> Self {
942            Self::new()
943        }
944    }
945
946    /// Builder for an async HTTP request.
947    pub struct AsyncRequestBuilder<'a> {
948        client: &'a AsyncClient,
949        method: String,
950        url: String,
951        headers: Vec<(String, String)>,
952        body: Option<Vec<u8>>,
953        timeout_ms: Option<u64>,
954    }
955
956    impl<'a> AsyncRequestBuilder<'a> {
957        /// Add a request header.
958        pub fn header(mut self, name: &str, value: &str) -> Self {
959            self.headers.push((name.to_string(), value.to_string()));
960            self
961        }
962
963        /// Set a raw byte body.
964        pub fn body(mut self, bytes: Vec<u8>) -> Self {
965            self.body = Some(bytes);
966            self
967        }
968
969        /// Set a plain-text body (sets `Content-Type: text/plain`).
970        pub fn body_text(mut self, s: &str) -> Self {
971            self.headers
972                .push(("Content-Type".to_string(), "text/plain".to_string()));
973            self.body = Some(s.as_bytes().to_vec());
974            self
975        }
976
977        /// Set a JSON body (sets `Content-Type: application/json`).
978        pub fn body_json(mut self, s: &str) -> Self {
979            self.headers.push((
980                "Content-Type".to_string(),
981                "application/json".to_string(),
982            ));
983            self.body = Some(s.as_bytes().to_vec());
984            self
985        }
986
987        /// Override the timeout for this request.
988        pub fn timeout_ms(mut self, ms: u64) -> Self {
989            self.timeout_ms = Some(ms);
990            self
991        }
992
993        /// Send the request asynchronously.
994        pub async fn send(self) -> Result<Response, HttpClientError> {
995            let timeout = self.timeout_ms.unwrap_or(self.client.timeout_ms);
996            let max_redirects = self.client.max_redirects;
997
998            let mut method = self.method;
999            let mut url = self.url;
1000            let headers = self.headers;
1001            let mut body = self.body;
1002            let mut redirects = 0u8;
1003
1004            loop {
1005                let parsed = ParsedUrl::parse(&url)?;
1006                let resp = async_send_once(&method, &parsed, &headers, &body, timeout).await?;
1007
1008                if resp.is_redirect() && redirects < max_redirects {
1009                    let location = resp
1010                        .header("location")
1011                        .ok_or_else(|| {
1012                            HttpClientError("redirect with no Location header".into())
1013                        })?
1014                        .to_string();
1015                    url = resolve_url(&url, &location);
1016                    redirects += 1;
1017                    if matches!(resp.status(), 301 | 302 | 303) {
1018                        method = "GET".to_string();
1019                        body = None;
1020                    }
1021                    continue;
1022                }
1023
1024                return Ok(resp);
1025            }
1026        }
1027    }
1028}