1#![deny(missing_docs)]
51
52use bytes::Bytes;
53use flate2::read::{GzDecoder, ZlibDecoder};
54use http::header::{
55 HeaderName, HeaderValue, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH,
56 CONTENT_TYPE, COOKIE, LOCATION, PROXY_AUTHORIZATION, TRANSFER_ENCODING, USER_AGENT,
57};
58use http::{HeaderMap, Method, Request, StatusCode, Uri};
59use http_body_util::{BodyExt, Full, Limited};
60use hyper_util::client::legacy::Client as HyperClient;
61use hyper_util::rt::TokioExecutor;
62use serde::de::DeserializeOwned;
63use serde::Serialize;
64use std::io::{self, Read, Write};
65use std::time::Duration;
66
67const DEFAULT_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
69const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
71const DEFAULT_MAX_REDIRECTS: usize = 10;
73
74#[cfg(feature = "tls")]
75type Connector = hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>;
76#[cfg(not(feature = "tls"))]
77type Connector = hyper_util::client::legacy::connect::HttpConnector;
78
79#[derive(Debug)]
81pub enum ClientError {
82 Url(String),
84 Request(String),
86 Transport(String),
88 Timeout(Duration),
90 BodyTooLarge(usize),
92 Body(String),
94 Decode(String),
96 TooManyRedirects(usize),
98}
99
100impl std::fmt::Display for ClientError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 Self::Url(why) => write!(f, "invalid url: {why}"),
104 Self::Request(why) => write!(f, "could not build request: {why}"),
105 Self::Transport(why) => write!(f, "transport failure: {why}"),
106 Self::Timeout(after) => write!(f, "request timed out after {after:?}"),
107 Self::BodyTooLarge(limit) => {
108 write!(f, "response body exceeded the {limit} byte limit")
109 }
110 Self::Body(why) => write!(f, "could not read response body: {why}"),
111 Self::Decode(why) => write!(f, "could not decode response: {why}"),
112 Self::TooManyRedirects(limit) => write!(f, "more than {limit} redirects"),
113 }
114 }
115}
116
117impl std::error::Error for ClientError {}
118
119#[derive(Clone, Debug)]
126pub struct Client {
127 inner: HyperClient<Connector, Full<Bytes>>,
128 timeout: Duration,
129 max_response_bytes: usize,
130 max_redirects: usize,
131 user_agent: HeaderValue,
132 default_headers: HeaderMap,
133 auto_decompress: bool,
135}
136
137impl Default for Client {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl Client {
144 pub fn new() -> Self {
147 #[cfg(feature = "tls")]
148 let connector = {
149 let mut http = hyper_util::client::legacy::connect::HttpConnector::new();
150 http.enforce_http(false);
153 hyper_rustls::HttpsConnectorBuilder::new()
154 .with_webpki_roots()
155 .https_or_http()
156 .enable_all_versions()
157 .wrap_connector(http)
158 };
159 #[cfg(not(feature = "tls"))]
160 let connector = hyper_util::client::legacy::connect::HttpConnector::new();
161
162 Self {
163 inner: HyperClient::builder(TokioExecutor::new()).build(connector),
164 timeout: DEFAULT_TIMEOUT,
165 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
166 max_redirects: DEFAULT_MAX_REDIRECTS,
167 user_agent: HeaderValue::from_static(concat!("churust/", env!("CARGO_PKG_VERSION"))),
168 default_headers: HeaderMap::new(),
169 auto_decompress: true,
170 }
171 }
172
173 pub fn timeout(mut self, d: Duration) -> Self {
178 self.timeout = d;
179 self
180 }
181
182 pub fn max_response_bytes(mut self, bytes: usize) -> Self {
188 self.max_response_bytes = bytes;
189 self
190 }
191
192 pub fn max_redirects(mut self, n: usize) -> Self {
194 self.max_redirects = n;
195 self
196 }
197
198 pub fn auto_decompress(mut self, enabled: bool) -> Self {
203 self.auto_decompress = enabled;
204 self
205 }
206
207 pub fn user_agent(mut self, value: &str) -> Result<Self, ClientError> {
213 self.user_agent =
214 HeaderValue::from_str(value).map_err(|e| ClientError::Request(e.to_string()))?;
215 Ok(self)
216 }
217
218 pub fn default_header(mut self, name: &str, value: &str) -> Result<Self, ClientError> {
224 let name = HeaderName::from_bytes(name.as_bytes())
225 .map_err(|e| ClientError::Request(e.to_string()))?;
226 let value =
227 HeaderValue::from_str(value).map_err(|e| ClientError::Request(e.to_string()))?;
228 self.default_headers.insert(name, value);
229 Ok(self)
230 }
231
232 pub fn request(&self, method: Method, url: impl Into<String>) -> RequestBuilder {
234 RequestBuilder {
235 client: self.clone(),
236 method,
237 url: url.into(),
238 headers: HeaderMap::new(),
239 body: Bytes::new(),
240 timeout: None,
241 error: None,
242 }
243 }
244
245 pub fn get(&self, url: impl Into<String>) -> RequestBuilder {
247 self.request(Method::GET, url)
248 }
249
250 pub fn post(&self, url: impl Into<String>) -> RequestBuilder {
252 self.request(Method::POST, url)
253 }
254
255 pub fn put(&self, url: impl Into<String>) -> RequestBuilder {
257 self.request(Method::PUT, url)
258 }
259
260 pub fn patch(&self, url: impl Into<String>) -> RequestBuilder {
262 self.request(Method::PATCH, url)
263 }
264
265 pub fn delete(&self, url: impl Into<String>) -> RequestBuilder {
267 self.request(Method::DELETE, url)
268 }
269
270 pub fn head(&self, url: impl Into<String>) -> RequestBuilder {
272 self.request(Method::HEAD, url)
273 }
274}
275
276#[derive(Debug)]
278pub struct RequestBuilder {
279 client: Client,
280 method: Method,
281 url: String,
282 headers: HeaderMap,
283 body: Bytes,
284 timeout: Option<Duration>,
285 error: Option<ClientError>,
288}
289
290impl RequestBuilder {
291 pub fn header(mut self, name: &str, value: &str) -> Self {
293 match (
294 HeaderName::from_bytes(name.as_bytes()),
295 HeaderValue::from_str(value),
296 ) {
297 (Ok(name), Ok(value)) => {
298 self.headers.insert(name, value);
299 }
300 _ => self.fail(ClientError::Request(format!("invalid header {name}"))),
301 }
302 self
303 }
304
305 pub fn bearer(self, token: &str) -> Self {
307 self.header(AUTHORIZATION.as_str(), &format!("Bearer {token}"))
308 }
309
310 pub fn query<T: Serialize>(mut self, pairs: &T) -> Self {
315 match serde_html_form::to_string(pairs) {
316 Ok(encoded) if encoded.is_empty() => {}
317 Ok(encoded) => {
318 let separator = if self.url.contains('?') { '&' } else { '?' };
319 self.url.push(separator);
320 self.url.push_str(&encoded);
321 }
322 Err(e) => self.fail(ClientError::Request(e.to_string())),
323 }
324 self
325 }
326
327 pub fn body(mut self, body: impl Into<Bytes>) -> Self {
329 self.body = body.into();
330 self
331 }
332
333 pub fn json<T: Serialize>(mut self, value: &T) -> Self {
335 match serde_json::to_vec(value) {
336 Ok(encoded) => {
337 self.body = Bytes::from(encoded);
338 self.headers
339 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
340 }
341 Err(e) => self.fail(ClientError::Request(e.to_string())),
342 }
343 self
344 }
345
346 pub fn form<T: Serialize>(mut self, value: &T) -> Self {
348 match serde_html_form::to_string(value) {
349 Ok(encoded) => {
350 self.body = Bytes::from(encoded);
351 self.headers.insert(
352 CONTENT_TYPE,
353 HeaderValue::from_static("application/x-www-form-urlencoded"),
354 );
355 }
356 Err(e) => self.fail(ClientError::Request(e.to_string())),
357 }
358 self
359 }
360
361 pub fn timeout(mut self, d: Duration) -> Self {
363 self.timeout = Some(d);
364 self
365 }
366
367 fn fail(&mut self, error: ClientError) {
371 if self.error.is_none() {
372 self.error = Some(error);
373 }
374 }
375
376 pub async fn send(self) -> Result<Response, ClientError> {
384 if let Some(error) = self.error {
385 return Err(error);
386 }
387 let deadline = self.timeout.unwrap_or(self.client.timeout);
388 let send = self.send_following();
389 match tokio::time::timeout(deadline, send).await {
390 Ok(result) => result,
391 Err(_) => Err(ClientError::Timeout(deadline)),
392 }
393 }
394
395 async fn send_following(self) -> Result<Response, ClientError> {
398 let RequestBuilder {
399 client,
400 method,
401 url,
402 headers,
403 body,
404 ..
405 } = self;
406 let mut headers = headers;
408
409 let mut uri: Uri = url.parse().map_err(|e| ClientError::Url(format!("{e}")))?;
410 let mut method = method;
411 let mut body = body;
412 let mut hops = 0usize;
413
414 let mut stripped: std::collections::HashSet<http::HeaderName> =
420 std::collections::HashSet::new();
421
422 loop {
423 check_scheme(&uri)?;
424
425 let mut request = Request::builder()
426 .method(method.clone())
427 .uri(uri.clone())
428 .body(Full::new(body.clone()))
429 .map_err(|e| ClientError::Request(e.to_string()))?;
430
431 let target = request.headers_mut();
432 for (name, value) in &client.default_headers {
433 if stripped.contains(name) {
437 continue;
438 }
439 target.insert(name, value.clone());
440 }
441 for (name, value) in &headers {
442 target.insert(name, value.clone());
443 }
444 target
445 .entry(USER_AGENT)
446 .or_insert_with(|| client.user_agent.clone());
447 if client.auto_decompress && !stripped.contains(&ACCEPT_ENCODING) {
451 target
452 .entry(ACCEPT_ENCODING)
453 .or_insert_with(|| HeaderValue::from_static("gzip, deflate"));
454 }
455
456 let response = client
457 .inner
458 .request(request)
459 .await
460 .map_err(|e| ClientError::Transport(e.to_string()))?;
461
462 let status = response.status();
463 let redirect = matches!(
464 status,
465 StatusCode::MOVED_PERMANENTLY
466 | StatusCode::FOUND
467 | StatusCode::SEE_OTHER
468 | StatusCode::TEMPORARY_REDIRECT
469 | StatusCode::PERMANENT_REDIRECT
470 );
471
472 if redirect && client.max_redirects > 0 {
473 if hops >= client.max_redirects {
474 return Err(ClientError::TooManyRedirects(client.max_redirects));
475 }
476 if let Some(next) = response
477 .headers()
478 .get(LOCATION)
479 .and_then(|v| v.to_str().ok())
480 .map(|v| v.to_string())
481 {
482 let previous = uri.clone();
483 uri = resolve(&uri, &next)?;
484 if !same_origin(&previous, &uri) {
490 for name in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION] {
491 headers.remove(&name);
492 stripped.insert(name);
493 }
494 }
495 if previous.scheme_str() == Some("https") && uri.scheme_str() == Some("http") {
500 return Err(ClientError::Url(
501 "refusing a redirect from https to http".into(),
502 ));
503 }
504 if matches!(
509 status,
510 StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND | StatusCode::SEE_OTHER
511 ) && method != Method::HEAD
512 {
513 method = Method::GET;
514 body = Bytes::new();
515 for name in [
525 CONTENT_TYPE,
526 CONTENT_LENGTH,
527 CONTENT_ENCODING,
528 TRANSFER_ENCODING,
529 ] {
530 headers.remove(&name);
531 stripped.insert(name);
532 }
533 }
534 hops += 1;
535 continue;
536 }
537 }
538
539 let (parts, incoming) = response.into_parts();
540 let collected = Limited::new(incoming, client.max_response_bytes)
541 .collect()
542 .await
543 .map_err(|e| {
544 if e.downcast_ref::<http_body_util::LengthLimitError>()
548 .is_some()
549 {
550 ClientError::BodyTooLarge(client.max_response_bytes)
551 } else {
552 ClientError::Body(e.to_string())
553 }
554 })?;
555
556 let mut headers = parts.headers;
557 let body = if client.auto_decompress {
558 maybe_decompress(
559 &mut headers,
560 collected.to_bytes(),
561 client.max_response_bytes,
562 )?
563 } else {
564 collected.to_bytes()
565 };
566
567 return Ok(Response {
568 status: parts.status,
569 headers,
570 body,
571 });
572 }
573 }
574}
575
576fn maybe_decompress(
582 headers: &mut HeaderMap,
583 body: Bytes,
584 max_bytes: usize,
585) -> Result<Bytes, ClientError> {
586 let Some(raw) = headers.get(CONTENT_ENCODING).and_then(|v| v.to_str().ok()) else {
587 return Ok(body);
588 };
589 let coding = raw
592 .split(',')
593 .next()
594 .unwrap_or("")
595 .trim()
596 .to_ascii_lowercase();
597 let decoded = match coding.as_str() {
598 "gzip" | "x-gzip" => inflate_limited(GzDecoder::new(body.as_ref()), max_bytes)?,
599 "deflate" => inflate_limited(ZlibDecoder::new(body.as_ref()), max_bytes)?,
603 "identity" | "" => return Ok(body),
604 _ => return Ok(body),
605 };
606 headers.remove(CONTENT_ENCODING);
607 headers.remove(CONTENT_LENGTH);
610 Ok(Bytes::from(decoded))
611}
612
613fn inflate_limited<R: Read>(mut r: R, max_bytes: usize) -> Result<Vec<u8>, ClientError> {
615 let mut out = LimitedWriter {
616 buf: Vec::new(),
617 max: max_bytes,
618 };
619 match std::io::copy(&mut r, &mut out) {
620 Ok(_) => Ok(out.buf),
621 Err(e) if e.kind() == io::ErrorKind::Other && e.to_string().contains("body too large") => {
622 Err(ClientError::BodyTooLarge(max_bytes))
623 }
624 Err(e) => Err(ClientError::Decode(format!("decompress: {e}"))),
625 }
626}
627
628struct LimitedWriter {
630 buf: Vec<u8>,
631 max: usize,
632}
633
634impl Write for LimitedWriter {
635 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
636 if self.buf.len().saturating_add(data.len()) > self.max {
637 return Err(io::Error::other("body too large"));
638 }
639 self.buf.extend_from_slice(data);
640 Ok(data.len())
641 }
642
643 fn flush(&mut self) -> io::Result<()> {
644 Ok(())
645 }
646}
647
648fn same_origin(a: &Uri, b: &Uri) -> bool {
653 fn port(u: &Uri) -> Option<u16> {
654 u.port_u16().or(match u.scheme_str() {
655 Some("http") => Some(80),
656 Some("https") => Some(443),
657 _ => None,
658 })
659 }
660 a.scheme_str() == b.scheme_str() && a.host() == b.host() && port(a) == port(b)
661}
662
663fn check_scheme(uri: &Uri) -> Result<(), ClientError> {
665 match uri.scheme_str() {
666 Some("http") => Ok(()),
667 #[cfg(feature = "tls")]
668 Some("https") => Ok(()),
669 #[cfg(not(feature = "tls"))]
670 Some("https") => Err(ClientError::Url(
671 "https needs the `tls` feature on churust-client".into(),
672 )),
673 Some(other) => Err(ClientError::Url(format!("unsupported scheme: {other}"))),
674 None => Err(ClientError::Url("no scheme in url".into())),
675 }
676}
677
678fn names_its_own_scheme(location: &str) -> bool {
695 let Some(boundary) = location.find([':', '/', '?', '#']) else {
696 return false;
697 };
698 if location.as_bytes()[boundary] != b':' {
699 return false;
700 }
701 let mut scheme = location[..boundary].chars();
706 scheme.next().is_some_and(|c| c.is_ascii_alphabetic())
707 && scheme.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
708}
709
710fn resolve(current: &Uri, location: &str) -> Result<Uri, ClientError> {
716 if names_its_own_scheme(location) {
717 return location
718 .parse()
719 .map_err(|e| ClientError::Url(format!("bad redirect target: {e}")));
720 }
721
722 let authority = current
723 .authority()
724 .ok_or_else(|| ClientError::Url("cannot resolve a relative redirect".into()))?;
725 let scheme = current.scheme_str().unwrap_or("http");
726
727 let joined = if location.starts_with('/') {
728 format!("{scheme}://{authority}{location}")
729 } else if location.starts_with('?') {
730 format!("{scheme}://{authority}{}{location}", current.path())
736 } else {
737 let base = current
738 .path()
739 .rsplit_once('/')
740 .map(|(head, _)| head)
741 .unwrap_or("");
742 format!("{scheme}://{authority}{base}/{location}")
743 };
744
745 joined
746 .parse()
747 .map_err(|e| ClientError::Url(format!("bad redirect target: {e}")))
748}
749
750#[derive(Clone, Debug)]
752pub struct Response {
753 status: StatusCode,
754 headers: HeaderMap,
755 body: Bytes,
756}
757
758impl Response {
759 pub fn status(&self) -> StatusCode {
761 self.status
762 }
763
764 pub fn headers(&self) -> &HeaderMap {
766 &self.headers
767 }
768
769 pub fn header(&self, name: &str) -> Option<&str> {
771 self.headers.get(name).and_then(|v| v.to_str().ok())
772 }
773
774 pub fn bytes(&self) -> &Bytes {
776 &self.body
777 }
778
779 pub fn text(&self) -> Result<String, ClientError> {
785 std::str::from_utf8(&self.body)
787 .map(str::to_owned)
788 .map_err(|e| ClientError::Decode(e.to_string()))
789 }
790
791 pub fn json<T: DeserializeOwned>(&self) -> Result<T, ClientError> {
797 serde_json::from_slice(&self.body).map_err(|e| ClientError::Decode(e.to_string()))
798 }
799
800 pub fn error_for_status(self) -> Result<Self, ClientError> {
807 if self.status.is_client_error() || self.status.is_server_error() {
808 let preview: String = self.text().unwrap_or_default().chars().take(200).collect();
809 return Err(ClientError::Transport(format!(
810 "{}: {preview}",
811 self.status
812 )));
813 }
814 Ok(self)
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 #[test]
823 fn a_relative_redirect_resolves_against_the_current_path() {
824 let current: Uri = "http://example.com/a/b".parse().unwrap();
825 assert_eq!(
826 resolve(¤t, "c").unwrap().to_string(),
827 "http://example.com/a/c"
828 );
829 }
830
831 #[test]
832 fn an_absolute_path_redirect_replaces_the_path() {
833 let current: Uri = "http://example.com/a/b".parse().unwrap();
834 assert_eq!(
835 resolve(¤t, "/z").unwrap().to_string(),
836 "http://example.com/z"
837 );
838 }
839
840 #[test]
841 fn an_absolute_redirect_is_taken_whole() {
842 let current: Uri = "http://example.com/a".parse().unwrap();
843 assert_eq!(
844 resolve(¤t, "http://other.test/x")
845 .unwrap()
846 .to_string(),
847 "http://other.test/x"
848 );
849 }
850
851 #[test]
852 fn a_relative_redirect_whose_query_contains_a_url_still_resolves() {
853 let current: Uri = "http://api.example.com/dashboard".parse().unwrap();
858 assert_eq!(
859 resolve(¤t, "/login?next=https://api.example.com/dashboard")
860 .unwrap()
861 .to_string(),
862 "http://api.example.com/login?next=https://api.example.com/dashboard"
863 );
864 }
865
866 #[test]
867 fn a_redirect_naming_a_scheme_without_a_double_slash_is_not_joined_onto_the_origin() {
868 let current: Uri = "http://example.com/a".parse().unwrap();
876 let target = resolve(¤t, "mailto:ops@example.com").unwrap();
877 assert_eq!(target.to_string(), "mailto:ops@example.com");
878 assert!(matches!(check_scheme(&target), Err(ClientError::Url(_))));
879 }
880
881 #[test]
882 fn a_query_only_redirect_keeps_the_path_it_came_from() {
883 let current: Uri = "http://example.com/a/b".parse().unwrap();
887 assert_eq!(
888 resolve(¤t, "?page=2").unwrap().to_string(),
889 "http://example.com/a/b?page=2"
890 );
891 }
892
893 #[test]
894 fn a_scheme_the_client_cannot_speak_is_refused() {
895 let uri: Uri = "ftp://example.com/x".parse().unwrap();
896 assert!(matches!(check_scheme(&uri), Err(ClientError::Url(_))));
897 }
898
899 #[tokio::test]
900 async fn a_file_url_never_reaches_the_connector() {
901 let err = Client::new()
905 .get("file:///etc/passwd")
906 .send()
907 .await
908 .expect_err("a file url must not be fetched");
909 assert!(matches!(err, ClientError::Url(_)), "{err:?}");
910 }
911
912 #[test]
913 fn plain_http_is_allowed() {
914 let uri: Uri = "http://example.com/".parse().unwrap();
915 assert!(check_scheme(&uri).is_ok());
916 }
917
918 #[cfg(not(feature = "tls"))]
919 #[test]
920 fn https_without_the_tls_feature_says_so() {
921 let uri: Uri = "https://example.com/".parse().unwrap();
922 match check_scheme(&uri) {
923 Err(ClientError::Url(why)) => assert!(why.contains("tls")),
924 other => panic!("expected a url error, got {other:?}"),
925 }
926 }
927
928 #[test]
929 fn query_pairs_are_appended_to_an_existing_query() {
930 let client = Client::new();
931 let req = client
932 .get("http://example.com/search?a=1")
933 .query(&[("b", "2")]);
934 assert_eq!(req.url, "http://example.com/search?a=1&b=2");
935 }
936
937 #[test]
938 fn query_pairs_open_a_query_when_there_is_none() {
939 let client = Client::new();
940 let req = client.get("http://example.com/search").query(&[("b", "2")]);
941 assert_eq!(req.url, "http://example.com/search?b=2");
942 }
943
944 #[test]
945 fn an_invalid_header_surfaces_at_send_not_at_the_builder() {
946 let client = Client::new();
947 let req = client.get("http://example.com/").header("bad header", "x");
948 assert!(matches!(req.error, Some(ClientError::Request(_))));
949 }
950
951 #[test]
952 fn json_sets_the_content_type() {
953 let client = Client::new();
954 let req = client
955 .post("http://example.com/")
956 .json(&serde_json::json!({"a": 1}));
957 assert_eq!(req.headers.get(CONTENT_TYPE).unwrap(), "application/json");
958 assert_eq!(req.body, Bytes::from(r#"{"a":1}"#));
959 }
960
961 #[test]
962 fn error_for_status_keeps_success() {
963 let ok = Response {
964 status: StatusCode::OK,
965 headers: HeaderMap::new(),
966 body: Bytes::from("fine"),
967 };
968 assert!(ok.error_for_status().is_ok());
969 }
970
971 #[test]
972 fn error_for_status_reports_the_body_of_a_failure() {
973 let bad = Response {
974 status: StatusCode::BAD_REQUEST,
975 headers: HeaderMap::new(),
976 body: Bytes::from("missing field: name"),
977 };
978 match bad.error_for_status() {
979 Err(ClientError::Transport(why)) => {
980 assert!(why.contains("400"));
981 assert!(why.contains("missing field"));
982 }
983 other => panic!("expected a transport error, got {other:?}"),
984 }
985 }
986}