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    /// Raw response body bytes.
220    pub fn bytes(&self) -> &[u8] {
221        &self.body
222    }
223
224    /// Decode the body as UTF-8.
225    pub fn text(&self) -> Result<String, HttpClientError> {
226        String::from_utf8(self.body.clone())
227            .map_err(|e| HttpClientError(format!("body is not valid UTF-8: {e}")))
228    }
229
230    /// Parse the body as JSON.
231    #[cfg(feature = "serde")]
232    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, HttpClientError> {
233        serde_json::from_slice(&self.body)
234            .map_err(|e| HttpClientError(format!("JSON parse error: {e}")))
235    }
236}
237
238// ── Wire-level helpers ────────────────────────────────────────────────────────
239
240/// Build the HTTP/1.1 request bytes.
241fn build_request_bytes(
242    method: &str,
243    path_and_query: &str,
244    host: &str,
245    headers: &[(String, String)],
246    body: &Option<Vec<u8>>,
247) -> Vec<u8> {
248    let mut out: Vec<u8> = Vec::new();
249
250    // Status line
251    let _ = write!(
252        out,
253        "{method} {path_and_query} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: rust-web-server/{}\r\n",
254        env!("CARGO_PKG_VERSION"),
255    );
256
257    // Content-Length (before custom headers, so callers can override)
258    if let Some(b) = body {
259        if !b.is_empty() {
260            let _ = write!(out, "Content-Length: {}\r\n", b.len());
261        }
262    }
263
264    // Custom headers
265    for (k, v) in headers {
266        let _ = write!(out, "{k}: {v}\r\n");
267    }
268
269    out.extend_from_slice(b"\r\n");
270
271    if let Some(b) = body {
272        out.extend_from_slice(b);
273    }
274
275    out
276}
277
278/// Parse an HTTP/1.1 response from any `Read` source.
279fn read_response(stream: &mut dyn Read, is_head: bool) -> Result<Response, HttpClientError> {
280    let mut buf: Vec<u8> = Vec::with_capacity(8192);
281    let mut tmp = [0u8; 4096];
282
283    // Read until we find the end of headers (\r\n\r\n)
284    let header_end = loop {
285        let n = stream
286            .read(&mut tmp)
287            .map_err(|e| HttpClientError(format!("read error: {e}")))?;
288        if n == 0 {
289            if buf.is_empty() {
290                return Err(HttpClientError(
291                    "server closed connection without sending a response".into(),
292                ));
293            }
294            // EOF before \r\n\r\n — try to parse whatever we got
295            break buf.len();
296        }
297        buf.extend_from_slice(&tmp[..n]);
298        if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
299            break pos + 4;
300        }
301    };
302
303    // Split header block
304    let header_block = std::str::from_utf8(&buf[..header_end])
305        .map_err(|_| HttpClientError("response headers are not valid UTF-8".into()))?;
306
307    let mut lines = header_block.lines();
308
309    // Status line
310    let status_line = lines
311        .next()
312        .ok_or_else(|| HttpClientError("empty response".into()))?;
313    let status = parse_status(status_line)?;
314
315    // Headers
316    let response_headers: Vec<(String, String)> = lines
317        .filter_map(|line| {
318            let mut parts = line.splitn(2, ':');
319            let name = parts.next()?.trim().to_string();
320            let value = parts.next()?.trim().to_string();
321            if name.is_empty() {
322                None
323            } else {
324                Some((name, value))
325            }
326        })
327        .collect();
328
329    // Body — already-buffered bytes beyond the header block
330    let mut body = buf[header_end..].to_vec();
331
332    if !is_head {
333        // Determine body reading strategy from headers
334        let transfer_encoding = response_headers
335            .iter()
336            .find(|(k, _)| k.to_lowercase() == "transfer-encoding")
337            .map(|(_, v)| v.to_lowercase());
338
339        let content_length: Option<usize> = response_headers
340            .iter()
341            .find(|(k, _)| k.to_lowercase() == "content-length")
342            .and_then(|(_, v)| v.trim().parse().ok());
343
344        if transfer_encoding
345            .as_deref()
346            .map(|te| te.contains("chunked"))
347            .unwrap_or(false)
348        {
349            // Read remaining chunked data then decode
350            loop {
351                let n = stream
352                    .read(&mut tmp)
353                    .map_err(|e| HttpClientError(format!("read error: {e}")))?;
354                if n == 0 {
355                    break;
356                }
357                body.extend_from_slice(&tmp[..n]);
358            }
359            body = decode_chunked(&body)?;
360        } else if let Some(len) = content_length {
361            while body.len() < len {
362                let n = stream
363                    .read(&mut tmp)
364                    .map_err(|e| HttpClientError(format!("read error: {e}")))?;
365                if n == 0 {
366                    break;
367                }
368                body.extend_from_slice(&tmp[..n]);
369            }
370            body.truncate(len);
371        } else {
372            // Read until EOF (Connection: close)
373            loop {
374                let n = stream
375                    .read(&mut tmp)
376                    .map_err(|e| HttpClientError(format!("read error: {e}")))?;
377                if n == 0 {
378                    break;
379                }
380                body.extend_from_slice(&tmp[..n]);
381            }
382        }
383    } else {
384        body.clear();
385    }
386
387    Ok(Response {
388        status,
389        headers: response_headers,
390        body,
391    })
392}
393
394fn parse_status(line: &str) -> Result<u16, HttpClientError> {
395    // "HTTP/1.x 200 Reason ..."
396    let mut parts = line.splitn(3, ' ');
397    let _version = parts
398        .next()
399        .ok_or_else(|| HttpClientError("malformed status line".into()))?;
400    let code_str = parts
401        .next()
402        .ok_or_else(|| HttpClientError("missing status code".into()))?;
403    code_str
404        .parse::<u16>()
405        .map_err(|_| HttpClientError(format!("invalid status code '{code_str}'")))
406}
407
408/// Decode chunked transfer encoding.
409fn decode_chunked(data: &[u8]) -> Result<Vec<u8>, HttpClientError> {
410    let mut out = Vec::new();
411    let mut pos = 0;
412
413    while pos < data.len() {
414        // Find end of chunk-size line
415        let line_end = data[pos..]
416            .windows(2)
417            .position(|w| w == b"\r\n")
418            .ok_or_else(|| HttpClientError("invalid chunked encoding: missing CRLF".into()))?;
419        let size_line = std::str::from_utf8(&data[pos..pos + line_end])
420            .map_err(|_| HttpClientError("chunked size is not ASCII".into()))?
421            .trim();
422        // Strip optional chunk extensions (;ext)
423        let size_str = size_line.split(';').next().unwrap_or("").trim();
424        let chunk_size = usize::from_str_radix(size_str, 16)
425            .map_err(|_| HttpClientError(format!("invalid chunk size '{size_str}'")))?;
426        pos += line_end + 2; // skip size line + CRLF
427
428        if chunk_size == 0 {
429            break; // last chunk
430        }
431
432        let end = pos + chunk_size;
433        if end > data.len() {
434            return Err(HttpClientError("chunked body truncated".into()));
435        }
436        out.extend_from_slice(&data[pos..end]);
437        pos = end + 2; // skip trailing CRLF after chunk data
438    }
439
440    Ok(out)
441}
442
443// ── TLS connector (sync) ──────────────────────────────────────────────────────
444
445#[cfg(any(feature = "http-client", feature = "http2"))]
446fn tls_connect(
447    host: &str,
448    tcp: TcpStream,
449) -> Result<rustls::StreamOwned<rustls::ClientConnection, TcpStream>, HttpClientError> {
450    use rustls::pki_types::ServerName;
451    use rustls::ClientConfig;
452
453    let root_store =
454        rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
455    let config = Arc::new(
456        ClientConfig::builder()
457            .with_root_certificates(root_store)
458            .with_no_client_auth(),
459    );
460    let server_name = ServerName::try_from(host.to_string())
461        .map_err(|e| HttpClientError(format!("invalid hostname '{host}': {e}")))?;
462    let conn = rustls::ClientConnection::new(config, server_name)
463        .map_err(|e| HttpClientError(e.to_string()))?;
464    Ok(rustls::StreamOwned::new(conn, tcp))
465}
466
467// ── Core send (one hop, no redirect) ─────────────────────────────────────────
468
469fn send_once(
470    method: &str,
471    parsed: &ParsedUrl,
472    headers: &[(String, String)],
473    body: &Option<Vec<u8>>,
474    timeout_ms: u64,
475) -> Result<Response, HttpClientError> {
476    let addr = format!("{}:{}", parsed.host, parsed.port);
477    let timeout = Duration::from_millis(timeout_ms);
478
479    // Resolve + connect
480    let sock_addr = addr
481        .parse::<std::net::SocketAddr>()
482        .or_else(|_| {
483            use std::net::ToSocketAddrs;
484            addr.to_socket_addrs()
485                .map_err(|e| HttpClientError(format!("DNS lookup for '{addr}' failed: {e}")))?
486                .next()
487                .ok_or_else(|| HttpClientError(format!("no address for '{addr}'")))
488        })
489        .map_err(|e: HttpClientError| e)?;
490
491    let tcp = TcpStream::connect_timeout(&sock_addr, timeout)
492        .map_err(|e| HttpClientError(format!("connect to '{addr}' failed: {e}")))?;
493    tcp.set_read_timeout(Some(timeout))
494        .map_err(|e| HttpClientError(e.to_string()))?;
495    tcp.set_write_timeout(Some(timeout))
496        .map_err(|e| HttpClientError(e.to_string()))?;
497
498    let request_bytes =
499        build_request_bytes(method, &parsed.path_and_query, &parsed.host, headers, body);
500
501    let is_head = method.eq_ignore_ascii_case("HEAD");
502
503    // Dispatch on scheme
504    #[cfg(any(feature = "http-client", feature = "http2"))]
505    if parsed.scheme == "https" {
506        let mut tls_stream = tls_connect(&parsed.host, tcp)?;
507        tls_stream
508            .write_all(&request_bytes)
509            .map_err(|e| HttpClientError(format!("write error: {e}")))?;
510        return read_response(&mut tls_stream, is_head);
511    }
512
513    // Plain HTTP
514    let mut stream = tcp;
515    stream
516        .write_all(&request_bytes)
517        .map_err(|e| HttpClientError(format!("write error: {e}")))?;
518    read_response(&mut stream, is_head)
519}
520
521// ── Client ────────────────────────────────────────────────────────────────────
522
523/// Synchronous HTTP/1.1 client.
524///
525/// Construct with [`Client::new()`], then call one of the method helpers
526/// (`.get()`, `.post()`, …) to get a [`RequestBuilder`], configure it, and
527/// call `.send()`.
528pub struct Client {
529    timeout_ms: u64,
530    max_redirects: u8,
531}
532
533impl Client {
534    /// Create a client with default settings:
535    /// - `timeout_ms`: 30 000 (30 seconds)
536    /// - `max_redirects`: 10
537    pub fn new() -> Self {
538        Self {
539            timeout_ms: 30_000,
540            max_redirects: 10,
541        }
542    }
543
544    /// Override the per-request timeout (connect + read combined).
545    pub fn timeout_ms(mut self, ms: u64) -> Self {
546        self.timeout_ms = ms;
547        self
548    }
549
550    /// Maximum number of redirects to follow (default: 10).
551    pub fn max_redirects(mut self, n: u8) -> Self {
552        self.max_redirects = n;
553        self
554    }
555
556    /// Start building a GET request.
557    pub fn get(&self, url: &str) -> RequestBuilder<'_> {
558        self.request("GET", url)
559    }
560
561    /// Start building a POST request.
562    pub fn post(&self, url: &str) -> RequestBuilder<'_> {
563        self.request("POST", url)
564    }
565
566    /// Start building a PUT request.
567    pub fn put(&self, url: &str) -> RequestBuilder<'_> {
568        self.request("PUT", url)
569    }
570
571    /// Start building a PATCH request.
572    pub fn patch(&self, url: &str) -> RequestBuilder<'_> {
573        self.request("PATCH", url)
574    }
575
576    /// Start building a DELETE request.
577    pub fn delete(&self, url: &str) -> RequestBuilder<'_> {
578        self.request("DELETE", url)
579    }
580
581    /// Start building a HEAD request.
582    pub fn head(&self, url: &str) -> RequestBuilder<'_> {
583        self.request("HEAD", url)
584    }
585
586    /// Start building a request with an arbitrary HTTP method.
587    pub fn request(&self, method: &str, url: &str) -> RequestBuilder<'_> {
588        RequestBuilder {
589            client: self,
590            method: method.to_uppercase(),
591            url: url.to_string(),
592            headers: Vec::new(),
593            body: None,
594            timeout_ms: None,
595        }
596    }
597}
598
599impl Default for Client {
600    fn default() -> Self {
601        Self::new()
602    }
603}
604
605// ── RequestBuilder ────────────────────────────────────────────────────────────
606
607/// Builder for a single HTTP request.
608pub struct RequestBuilder<'a> {
609    client: &'a Client,
610    method: String,
611    url: String,
612    headers: Vec<(String, String)>,
613    body: Option<Vec<u8>>,
614    timeout_ms: Option<u64>,
615}
616
617impl<'a> RequestBuilder<'a> {
618    /// Add a request header.
619    pub fn header(mut self, name: &str, value: &str) -> Self {
620        self.headers.push((name.to_string(), value.to_string()));
621        self
622    }
623
624    /// Set a raw byte body.
625    pub fn body(mut self, bytes: Vec<u8>) -> Self {
626        self.body = Some(bytes);
627        self
628    }
629
630    /// Set a plain-text body (also sets `Content-Type: text/plain`).
631    pub fn body_text(mut self, s: &str) -> Self {
632        self.headers
633            .push(("Content-Type".to_string(), "text/plain".to_string()));
634        self.body = Some(s.as_bytes().to_vec());
635        self
636    }
637
638    /// Set a JSON body (also sets `Content-Type: application/json`).
639    pub fn body_json(mut self, s: &str) -> Self {
640        self.headers.push((
641            "Content-Type".to_string(),
642            "application/json".to_string(),
643        ));
644        self.body = Some(s.as_bytes().to_vec());
645        self
646    }
647
648    /// Override the timeout for this request.
649    pub fn timeout_ms(mut self, ms: u64) -> Self {
650        self.timeout_ms = Some(ms);
651        self
652    }
653
654    /// Send the request and return the response.
655    ///
656    /// Automatically follows redirects up to the client's `max_redirects`
657    /// limit.
658    pub fn send(self) -> Result<Response, HttpClientError> {
659        let timeout = self.timeout_ms.unwrap_or(self.client.timeout_ms);
660        let max_redirects = self.client.max_redirects;
661
662        let mut method = self.method;
663        let mut url = self.url;
664        let headers = self.headers;
665        let mut body = self.body;
666        let mut redirects = 0u8;
667
668        loop {
669            let parsed = ParsedUrl::parse(&url)?;
670            let resp = send_once(&method, &parsed, &headers, &body, timeout)?;
671
672            if resp.is_redirect() && redirects < max_redirects {
673                let location = resp
674                    .header("location")
675                    .ok_or_else(|| HttpClientError("redirect with no Location header".into()))?
676                    .to_string();
677                url = resolve_url(&url, &location);
678                redirects += 1;
679                if matches!(resp.status(), 301 | 302 | 303) {
680                    method = "GET".to_string();
681                    body = None;
682                }
683                continue;
684            }
685
686            return Ok(resp);
687        }
688    }
689}
690
691// ── Async client (`http2` feature) ───────────────────────────────────────────
692
693#[cfg(feature = "http2")]
694pub use async_impl::{AsyncClient, AsyncRequestBuilder};
695
696#[cfg(feature = "http2")]
697mod async_impl {
698    use super::{
699        build_request_bytes, decode_chunked, parse_status, resolve_url, HttpClientError,
700        ParsedUrl, Response,
701    };
702    use std::sync::Arc;
703    use tokio::io::{AsyncReadExt, AsyncWriteExt};
704
705    async fn async_tls_connect(
706        host: &str,
707        stream: tokio::net::TcpStream,
708    ) -> Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>, HttpClientError> {
709        use rustls::pki_types::ServerName;
710        use rustls::ClientConfig;
711        use tokio_rustls::TlsConnector;
712
713        let root_store = rustls::RootCertStore::from_iter(
714            webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
715        );
716        let config = Arc::new(
717            ClientConfig::builder()
718                .with_root_certificates(root_store)
719                .with_no_client_auth(),
720        );
721        let connector = TlsConnector::from(config);
722        let server_name = ServerName::try_from(host.to_string())
723            .map_err(|e| HttpClientError(format!("invalid hostname '{host}': {e}")))?;
724        connector
725            .connect(server_name, stream)
726            .await
727            .map_err(|e| HttpClientError(format!("TLS handshake failed: {e}")))
728    }
729
730    async fn async_read_response(
731        stream: &mut (impl AsyncReadExt + Unpin),
732        is_head: bool,
733    ) -> Result<Response, HttpClientError> {
734        let mut buf: Vec<u8> = Vec::with_capacity(8192);
735        let mut tmp = vec![0u8; 4096];
736
737        let header_end = loop {
738            let n = stream
739                .read(&mut tmp)
740                .await
741                .map_err(|e| HttpClientError(format!("read error: {e}")))?;
742            if n == 0 {
743                if buf.is_empty() {
744                    return Err(HttpClientError(
745                        "server closed connection without a response".into(),
746                    ));
747                }
748                break buf.len();
749            }
750            buf.extend_from_slice(&tmp[..n]);
751            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
752                break pos + 4;
753            }
754        };
755
756        let header_block = std::str::from_utf8(&buf[..header_end])
757            .map_err(|_| HttpClientError("response headers not UTF-8".into()))?;
758
759        let mut lines = header_block.lines();
760        let status_line = lines
761            .next()
762            .ok_or_else(|| HttpClientError("empty response".into()))?;
763        let status = parse_status(status_line)?;
764
765        let response_headers: Vec<(String, String)> = lines
766            .filter_map(|line| {
767                let mut parts = line.splitn(2, ':');
768                let name = parts.next()?.trim().to_string();
769                let value = parts.next()?.trim().to_string();
770                if name.is_empty() { None } else { Some((name, value)) }
771            })
772            .collect();
773
774        let mut body = buf[header_end..].to_vec();
775
776        if !is_head {
777            let transfer_encoding = response_headers
778                .iter()
779                .find(|(k, _)| k.to_lowercase() == "transfer-encoding")
780                .map(|(_, v)| v.to_lowercase());
781
782            let content_length: Option<usize> = response_headers
783                .iter()
784                .find(|(k, _)| k.to_lowercase() == "content-length")
785                .and_then(|(_, v)| v.trim().parse().ok());
786
787            if transfer_encoding
788                .as_deref()
789                .map(|te| te.contains("chunked"))
790                .unwrap_or(false)
791            {
792                loop {
793                    let n = stream.read(&mut tmp).await
794                        .map_err(|e| HttpClientError(format!("read error: {e}")))?;
795                    if n == 0 { break; }
796                    body.extend_from_slice(&tmp[..n]);
797                }
798                body = decode_chunked(&body)?;
799            } else if let Some(len) = content_length {
800                while body.len() < len {
801                    let n = stream.read(&mut tmp).await
802                        .map_err(|e| HttpClientError(format!("read error: {e}")))?;
803                    if n == 0 { break; }
804                    body.extend_from_slice(&tmp[..n]);
805                }
806                body.truncate(len);
807            } else {
808                loop {
809                    let n = stream.read(&mut tmp).await
810                        .map_err(|e| HttpClientError(format!("read error: {e}")))?;
811                    if n == 0 { break; }
812                    body.extend_from_slice(&tmp[..n]);
813                }
814            }
815        } else {
816            body.clear();
817        }
818
819        Ok(Response { status, headers: response_headers, body })
820    }
821
822    async fn async_send_once(
823        method: &str,
824        parsed: &ParsedUrl,
825        headers: &[(String, String)],
826        body: &Option<Vec<u8>>,
827        timeout_ms: u64,
828    ) -> Result<Response, HttpClientError> {
829        use std::time::Duration;
830        use tokio::net::TcpStream;
831        use tokio::time::timeout;
832
833        let addr = format!("{}:{}", parsed.host, parsed.port);
834        let dur = Duration::from_millis(timeout_ms);
835        let request_bytes =
836            build_request_bytes(method, &parsed.path_and_query, &parsed.host, headers, body);
837        let is_head = method.eq_ignore_ascii_case("HEAD");
838
839        let tcp = timeout(dur, TcpStream::connect(&addr))
840            .await
841            .map_err(|_| HttpClientError(format!("connect to '{addr}' timed out")))?
842            .map_err(|e| HttpClientError(format!("connect to '{addr}' failed: {e}")))?;
843
844        if parsed.scheme == "https" {
845            let tls_stream = timeout(dur, async_tls_connect(&parsed.host, tcp))
846                .await
847                .map_err(|_| HttpClientError("TLS handshake timed out".into()))??;
848            let mut stream = tls_stream;
849            timeout(dur, stream.write_all(&request_bytes))
850                .await
851                .map_err(|_| HttpClientError("write timed out".into()))?
852                .map_err(|e| HttpClientError(format!("write error: {e}")))?;
853            return timeout(dur, async_read_response(&mut stream, is_head))
854                .await
855                .map_err(|_| HttpClientError("read timed out".into()))?;
856        }
857
858        let mut stream = tcp;
859        timeout(dur, stream.write_all(&request_bytes))
860            .await
861            .map_err(|_| HttpClientError("write timed out".into()))?
862            .map_err(|e| HttpClientError(format!("write error: {e}")))?;
863        timeout(dur, async_read_response(&mut stream, is_head))
864            .await
865            .map_err(|_| HttpClientError("read timed out".into()))?
866    }
867
868    /// Asynchronous HTTP/1.1 client (`http2` feature required).
869    pub struct AsyncClient {
870        timeout_ms: u64,
871        max_redirects: u8,
872    }
873
874    impl AsyncClient {
875        /// Create with default settings (30 s timeout, 10 redirects).
876        pub fn new() -> Self {
877            Self {
878                timeout_ms: 30_000,
879                max_redirects: 10,
880            }
881        }
882
883        /// Override the per-request timeout.
884        pub fn timeout_ms(mut self, ms: u64) -> Self {
885            self.timeout_ms = ms;
886            self
887        }
888
889        /// Maximum redirects to follow.
890        pub fn max_redirects(mut self, n: u8) -> Self {
891            self.max_redirects = n;
892            self
893        }
894
895        /// Start a GET request.
896        pub fn get(&self, url: &str) -> AsyncRequestBuilder<'_> {
897            self.request("GET", url)
898        }
899
900        /// Start a POST request.
901        pub fn post(&self, url: &str) -> AsyncRequestBuilder<'_> {
902            self.request("POST", url)
903        }
904
905        /// Start a PUT request.
906        pub fn put(&self, url: &str) -> AsyncRequestBuilder<'_> {
907            self.request("PUT", url)
908        }
909
910        /// Start a PATCH request.
911        pub fn patch(&self, url: &str) -> AsyncRequestBuilder<'_> {
912            self.request("PATCH", url)
913        }
914
915        /// Start a DELETE request.
916        pub fn delete(&self, url: &str) -> AsyncRequestBuilder<'_> {
917            self.request("DELETE", url)
918        }
919
920        /// Start a request with an arbitrary method.
921        pub fn request(&self, method: &str, url: &str) -> AsyncRequestBuilder<'_> {
922            AsyncRequestBuilder {
923                client: self,
924                method: method.to_uppercase(),
925                url: url.to_string(),
926                headers: Vec::new(),
927                body: None,
928                timeout_ms: None,
929            }
930        }
931    }
932
933    impl Default for AsyncClient {
934        fn default() -> Self {
935            Self::new()
936        }
937    }
938
939    /// Builder for an async HTTP request.
940    pub struct AsyncRequestBuilder<'a> {
941        client: &'a AsyncClient,
942        method: String,
943        url: String,
944        headers: Vec<(String, String)>,
945        body: Option<Vec<u8>>,
946        timeout_ms: Option<u64>,
947    }
948
949    impl<'a> AsyncRequestBuilder<'a> {
950        /// Add a request header.
951        pub fn header(mut self, name: &str, value: &str) -> Self {
952            self.headers.push((name.to_string(), value.to_string()));
953            self
954        }
955
956        /// Set a raw byte body.
957        pub fn body(mut self, bytes: Vec<u8>) -> Self {
958            self.body = Some(bytes);
959            self
960        }
961
962        /// Set a plain-text body (sets `Content-Type: text/plain`).
963        pub fn body_text(mut self, s: &str) -> Self {
964            self.headers
965                .push(("Content-Type".to_string(), "text/plain".to_string()));
966            self.body = Some(s.as_bytes().to_vec());
967            self
968        }
969
970        /// Set a JSON body (sets `Content-Type: application/json`).
971        pub fn body_json(mut self, s: &str) -> Self {
972            self.headers.push((
973                "Content-Type".to_string(),
974                "application/json".to_string(),
975            ));
976            self.body = Some(s.as_bytes().to_vec());
977            self
978        }
979
980        /// Override the timeout for this request.
981        pub fn timeout_ms(mut self, ms: u64) -> Self {
982            self.timeout_ms = Some(ms);
983            self
984        }
985
986        /// Send the request asynchronously.
987        pub async fn send(self) -> Result<Response, HttpClientError> {
988            let timeout = self.timeout_ms.unwrap_or(self.client.timeout_ms);
989            let max_redirects = self.client.max_redirects;
990
991            let mut method = self.method;
992            let mut url = self.url;
993            let headers = self.headers;
994            let mut body = self.body;
995            let mut redirects = 0u8;
996
997            loop {
998                let parsed = ParsedUrl::parse(&url)?;
999                let resp = async_send_once(&method, &parsed, &headers, &body, timeout).await?;
1000
1001                if resp.is_redirect() && redirects < max_redirects {
1002                    let location = resp
1003                        .header("location")
1004                        .ok_or_else(|| {
1005                            HttpClientError("redirect with no Location header".into())
1006                        })?
1007                        .to_string();
1008                    url = resolve_url(&url, &location);
1009                    redirects += 1;
1010                    if matches!(resp.status(), 301 | 302 | 303) {
1011                        method = "GET".to_string();
1012                        body = None;
1013                    }
1014                    continue;
1015                }
1016
1017                return Ok(resp);
1018            }
1019        }
1020    }
1021}