1#[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
67fn 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
83fn 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#[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
106struct 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 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 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 let (host, port) = if let Some(bracket_end) = authority.find(']') {
142 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
179fn 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 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 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#[derive(Debug)]
215pub struct Response {
216 status: u16,
217 headers: Vec<(String, String)>,
218 body: Vec<u8>,
219}
220
221impl Response {
222 pub fn status(&self) -> u16 {
224 self.status
225 }
226
227 pub fn is_success(&self) -> bool {
229 (200..300).contains(&self.status)
230 }
231
232 pub fn is_redirect(&self) -> bool {
234 matches!(self.status, 301 | 302 | 303 | 307 | 308)
235 }
236
237 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 pub fn headers(&self) -> &[(String, String)] {
250 &self.headers
251 }
252
253 pub fn bytes(&self) -> &[u8] {
255 &self.body
256 }
257
258 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 #[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
272fn 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 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 if let Some(b) = body {
293 if !b.is_empty() {
294 let _ = write!(out, "Content-Length: {}\r\n", b.len());
295 }
296 }
297
298 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
312fn 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 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 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 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 let status_line = lines
345 .next()
346 .ok_or_else(|| HttpClientError("empty response".into()))?;
347 let status = parse_status(status_line)?;
348
349 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 let mut body = buf[header_end..].to_vec();
365
366 if !is_head {
367 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 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 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 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
442fn 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 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 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; if chunk_size == 0 {
463 break; }
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; }
473
474 Ok(out)
475}
476
477#[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#[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 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 #[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 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#[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 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
685pub struct Client {
693 timeout_ms: u64,
694 max_redirects: u8,
695}
696
697impl Client {
698 pub fn new() -> Self {
702 Self {
703 timeout_ms: 30_000,
704 max_redirects: 10,
705 }
706 }
707
708 pub fn timeout_ms(mut self, ms: u64) -> Self {
710 self.timeout_ms = ms;
711 self
712 }
713
714 pub fn max_redirects(mut self, n: u8) -> Self {
716 self.max_redirects = n;
717 self
718 }
719
720 pub fn get(&self, url: &str) -> RequestBuilder<'_> {
722 self.request("GET", url)
723 }
724
725 pub fn post(&self, url: &str) -> RequestBuilder<'_> {
727 self.request("POST", url)
728 }
729
730 pub fn put(&self, url: &str) -> RequestBuilder<'_> {
732 self.request("PUT", url)
733 }
734
735 pub fn patch(&self, url: &str) -> RequestBuilder<'_> {
737 self.request("PATCH", url)
738 }
739
740 pub fn delete(&self, url: &str) -> RequestBuilder<'_> {
742 self.request("DELETE", url)
743 }
744
745 pub fn head(&self, url: &str) -> RequestBuilder<'_> {
747 self.request("HEAD", url)
748 }
749
750 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
769pub 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 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 pub fn body(mut self, bytes: Vec<u8>) -> Self {
790 self.body = Some(bytes);
791 self
792 }
793
794 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 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 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 pub fn timeout_ms(mut self, ms: u64) -> Self {
837 self.timeout_ms = Some(ms);
838 self
839 }
840
841 pub fn send(self) -> Result<Response, HttpClientError> {
846 #[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#[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 pub struct AsyncClient {
1084 timeout_ms: u64,
1085 max_redirects: u8,
1086 }
1087
1088 impl AsyncClient {
1089 pub fn new() -> Self {
1091 Self {
1092 timeout_ms: 30_000,
1093 max_redirects: 10,
1094 }
1095 }
1096
1097 pub fn timeout_ms(mut self, ms: u64) -> Self {
1099 self.timeout_ms = ms;
1100 self
1101 }
1102
1103 pub fn max_redirects(mut self, n: u8) -> Self {
1105 self.max_redirects = n;
1106 self
1107 }
1108
1109 pub fn get(&self, url: &str) -> AsyncRequestBuilder<'_> {
1111 self.request("GET", url)
1112 }
1113
1114 pub fn post(&self, url: &str) -> AsyncRequestBuilder<'_> {
1116 self.request("POST", url)
1117 }
1118
1119 pub fn put(&self, url: &str) -> AsyncRequestBuilder<'_> {
1121 self.request("PUT", url)
1122 }
1123
1124 pub fn patch(&self, url: &str) -> AsyncRequestBuilder<'_> {
1126 self.request("PATCH", url)
1127 }
1128
1129 pub fn delete(&self, url: &str) -> AsyncRequestBuilder<'_> {
1131 self.request("DELETE", url)
1132 }
1133
1134 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 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 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 pub fn body(mut self, bytes: Vec<u8>) -> Self {
1172 self.body = Some(bytes);
1173 self
1174 }
1175
1176 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 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 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 pub fn timeout_ms(mut self, ms: u64) -> Self {
1207 self.timeout_ms = Some(ms);
1208 self
1209 }
1210
1211 pub async fn send(self) -> Result<Response, HttpClientError> {
1213 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}