1#[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
65fn 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
81fn 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#[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
104struct 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 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 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 let (host, port) = if let Some(bracket_end) = authority.find(']') {
140 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
177fn 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 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 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#[derive(Debug)]
213pub struct Response {
214 status: u16,
215 headers: Vec<(String, String)>,
216 body: Vec<u8>,
217}
218
219impl Response {
220 pub fn status(&self) -> u16 {
222 self.status
223 }
224
225 pub fn is_success(&self) -> bool {
227 (200..300).contains(&self.status)
228 }
229
230 pub fn is_redirect(&self) -> bool {
232 matches!(self.status, 301 | 302 | 303 | 307 | 308)
233 }
234
235 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 pub fn headers(&self) -> &[(String, String)] {
248 &self.headers
249 }
250
251 pub fn bytes(&self) -> &[u8] {
253 &self.body
254 }
255
256 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 #[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
270fn 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 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 if let Some(b) = body {
291 if !b.is_empty() {
292 let _ = write!(out, "Content-Length: {}\r\n", b.len());
293 }
294 }
295
296 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
310fn 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 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 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 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 let status_line = lines
343 .next()
344 .ok_or_else(|| HttpClientError("empty response".into()))?;
345 let status = parse_status(status_line)?;
346
347 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 let mut body = buf[header_end..].to_vec();
363
364 if !is_head {
365 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 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 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 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
440fn 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 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 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; if chunk_size == 0 {
461 break; }
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; }
471
472 Ok(out)
473}
474
475#[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
499fn 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 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 #[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 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
553pub struct Client {
561 timeout_ms: u64,
562 max_redirects: u8,
563}
564
565impl Client {
566 pub fn new() -> Self {
570 Self {
571 timeout_ms: 30_000,
572 max_redirects: 10,
573 }
574 }
575
576 pub fn timeout_ms(mut self, ms: u64) -> Self {
578 self.timeout_ms = ms;
579 self
580 }
581
582 pub fn max_redirects(mut self, n: u8) -> Self {
584 self.max_redirects = n;
585 self
586 }
587
588 pub fn get(&self, url: &str) -> RequestBuilder<'_> {
590 self.request("GET", url)
591 }
592
593 pub fn post(&self, url: &str) -> RequestBuilder<'_> {
595 self.request("POST", url)
596 }
597
598 pub fn put(&self, url: &str) -> RequestBuilder<'_> {
600 self.request("PUT", url)
601 }
602
603 pub fn patch(&self, url: &str) -> RequestBuilder<'_> {
605 self.request("PATCH", url)
606 }
607
608 pub fn delete(&self, url: &str) -> RequestBuilder<'_> {
610 self.request("DELETE", url)
611 }
612
613 pub fn head(&self, url: &str) -> RequestBuilder<'_> {
615 self.request("HEAD", url)
616 }
617
618 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
637pub 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 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 pub fn body(mut self, bytes: Vec<u8>) -> Self {
658 self.body = Some(bytes);
659 self
660 }
661
662 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 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 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 pub fn timeout_ms(mut self, ms: u64) -> Self {
705 self.timeout_ms = Some(ms);
706 self
707 }
708
709 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#[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 pub struct AsyncClient {
925 timeout_ms: u64,
926 max_redirects: u8,
927 }
928
929 impl AsyncClient {
930 pub fn new() -> Self {
932 Self {
933 timeout_ms: 30_000,
934 max_redirects: 10,
935 }
936 }
937
938 pub fn timeout_ms(mut self, ms: u64) -> Self {
940 self.timeout_ms = ms;
941 self
942 }
943
944 pub fn max_redirects(mut self, n: u8) -> Self {
946 self.max_redirects = n;
947 self
948 }
949
950 pub fn get(&self, url: &str) -> AsyncRequestBuilder<'_> {
952 self.request("GET", url)
953 }
954
955 pub fn post(&self, url: &str) -> AsyncRequestBuilder<'_> {
957 self.request("POST", url)
958 }
959
960 pub fn put(&self, url: &str) -> AsyncRequestBuilder<'_> {
962 self.request("PUT", url)
963 }
964
965 pub fn patch(&self, url: &str) -> AsyncRequestBuilder<'_> {
967 self.request("PATCH", url)
968 }
969
970 pub fn delete(&self, url: &str) -> AsyncRequestBuilder<'_> {
972 self.request("DELETE", url)
973 }
974
975 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 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 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 pub fn body(mut self, bytes: Vec<u8>) -> Self {
1013 self.body = Some(bytes);
1014 self
1015 }
1016
1017 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 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 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 pub fn timeout_ms(mut self, ms: u64) -> Self {
1048 self.timeout_ms = Some(ms);
1049 self
1050 }
1051
1052 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}