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