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