1#![deny(missing_docs)]
40
41use bytes::Bytes;
42use http::header::{
43 HeaderName, HeaderValue, AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, COOKIE,
44 LOCATION, PROXY_AUTHORIZATION, TRANSFER_ENCODING, USER_AGENT,
45};
46use http::{HeaderMap, Method, Request, StatusCode, Uri};
47use http_body_util::{BodyExt, Full, Limited};
48use hyper_util::client::legacy::Client as HyperClient;
49use hyper_util::rt::TokioExecutor;
50use serde::de::DeserializeOwned;
51use serde::Serialize;
52use std::time::Duration;
53
54const DEFAULT_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
56const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
58const DEFAULT_MAX_REDIRECTS: usize = 10;
60
61#[cfg(feature = "tls")]
62type Connector = hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>;
63#[cfg(not(feature = "tls"))]
64type Connector = hyper_util::client::legacy::connect::HttpConnector;
65
66#[derive(Debug)]
68pub enum ClientError {
69 Url(String),
71 Request(String),
73 Transport(String),
75 Timeout(Duration),
77 BodyTooLarge(usize),
79 Body(String),
81 Decode(String),
83 TooManyRedirects(usize),
85}
86
87impl std::fmt::Display for ClientError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 Self::Url(why) => write!(f, "invalid url: {why}"),
91 Self::Request(why) => write!(f, "could not build request: {why}"),
92 Self::Transport(why) => write!(f, "transport failure: {why}"),
93 Self::Timeout(after) => write!(f, "request timed out after {after:?}"),
94 Self::BodyTooLarge(limit) => {
95 write!(f, "response body exceeded the {limit} byte limit")
96 }
97 Self::Body(why) => write!(f, "could not read response body: {why}"),
98 Self::Decode(why) => write!(f, "could not decode response: {why}"),
99 Self::TooManyRedirects(limit) => write!(f, "more than {limit} redirects"),
100 }
101 }
102}
103
104impl std::error::Error for ClientError {}
105
106#[derive(Clone, Debug)]
113pub struct Client {
114 inner: HyperClient<Connector, Full<Bytes>>,
115 timeout: Duration,
116 max_response_bytes: usize,
117 max_redirects: usize,
118 user_agent: HeaderValue,
119 default_headers: HeaderMap,
120}
121
122impl Default for Client {
123 fn default() -> Self {
124 Self::new()
125 }
126}
127
128impl Client {
129 pub fn new() -> Self {
132 #[cfg(feature = "tls")]
133 let connector = {
134 let mut http = hyper_util::client::legacy::connect::HttpConnector::new();
135 http.enforce_http(false);
138 hyper_rustls::HttpsConnectorBuilder::new()
139 .with_webpki_roots()
140 .https_or_http()
141 .enable_all_versions()
142 .wrap_connector(http)
143 };
144 #[cfg(not(feature = "tls"))]
145 let connector = hyper_util::client::legacy::connect::HttpConnector::new();
146
147 Self {
148 inner: HyperClient::builder(TokioExecutor::new()).build(connector),
149 timeout: DEFAULT_TIMEOUT,
150 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
151 max_redirects: DEFAULT_MAX_REDIRECTS,
152 user_agent: HeaderValue::from_static(concat!("churust/", env!("CARGO_PKG_VERSION"))),
153 default_headers: HeaderMap::new(),
154 }
155 }
156
157 pub fn timeout(mut self, d: Duration) -> Self {
162 self.timeout = d;
163 self
164 }
165
166 pub fn max_response_bytes(mut self, bytes: usize) -> Self {
168 self.max_response_bytes = bytes;
169 self
170 }
171
172 pub fn max_redirects(mut self, n: usize) -> Self {
174 self.max_redirects = n;
175 self
176 }
177
178 pub fn user_agent(mut self, value: &str) -> Result<Self, ClientError> {
184 self.user_agent =
185 HeaderValue::from_str(value).map_err(|e| ClientError::Request(e.to_string()))?;
186 Ok(self)
187 }
188
189 pub fn default_header(mut self, name: &str, value: &str) -> Result<Self, ClientError> {
195 let name = HeaderName::from_bytes(name.as_bytes())
196 .map_err(|e| ClientError::Request(e.to_string()))?;
197 let value =
198 HeaderValue::from_str(value).map_err(|e| ClientError::Request(e.to_string()))?;
199 self.default_headers.insert(name, value);
200 Ok(self)
201 }
202
203 pub fn request(&self, method: Method, url: impl Into<String>) -> RequestBuilder {
205 RequestBuilder {
206 client: self.clone(),
207 method,
208 url: url.into(),
209 headers: HeaderMap::new(),
210 body: Bytes::new(),
211 timeout: None,
212 error: None,
213 }
214 }
215
216 pub fn get(&self, url: impl Into<String>) -> RequestBuilder {
218 self.request(Method::GET, url)
219 }
220
221 pub fn post(&self, url: impl Into<String>) -> RequestBuilder {
223 self.request(Method::POST, url)
224 }
225
226 pub fn put(&self, url: impl Into<String>) -> RequestBuilder {
228 self.request(Method::PUT, url)
229 }
230
231 pub fn patch(&self, url: impl Into<String>) -> RequestBuilder {
233 self.request(Method::PATCH, url)
234 }
235
236 pub fn delete(&self, url: impl Into<String>) -> RequestBuilder {
238 self.request(Method::DELETE, url)
239 }
240
241 pub fn head(&self, url: impl Into<String>) -> RequestBuilder {
243 self.request(Method::HEAD, url)
244 }
245}
246
247#[derive(Debug)]
249pub struct RequestBuilder {
250 client: Client,
251 method: Method,
252 url: String,
253 headers: HeaderMap,
254 body: Bytes,
255 timeout: Option<Duration>,
256 error: Option<ClientError>,
259}
260
261impl RequestBuilder {
262 pub fn header(mut self, name: &str, value: &str) -> Self {
264 match (
265 HeaderName::from_bytes(name.as_bytes()),
266 HeaderValue::from_str(value),
267 ) {
268 (Ok(name), Ok(value)) => {
269 self.headers.insert(name, value);
270 }
271 _ => self.fail(ClientError::Request(format!("invalid header {name}"))),
272 }
273 self
274 }
275
276 pub fn bearer(self, token: &str) -> Self {
278 self.header(AUTHORIZATION.as_str(), &format!("Bearer {token}"))
279 }
280
281 pub fn query<T: Serialize>(mut self, pairs: &T) -> Self {
286 match serde_html_form::to_string(pairs) {
287 Ok(encoded) if encoded.is_empty() => {}
288 Ok(encoded) => {
289 let separator = if self.url.contains('?') { '&' } else { '?' };
290 self.url.push(separator);
291 self.url.push_str(&encoded);
292 }
293 Err(e) => self.fail(ClientError::Request(e.to_string())),
294 }
295 self
296 }
297
298 pub fn body(mut self, body: impl Into<Bytes>) -> Self {
300 self.body = body.into();
301 self
302 }
303
304 pub fn json<T: Serialize>(mut self, value: &T) -> Self {
306 match serde_json::to_vec(value) {
307 Ok(encoded) => {
308 self.body = Bytes::from(encoded);
309 self.headers
310 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
311 }
312 Err(e) => self.fail(ClientError::Request(e.to_string())),
313 }
314 self
315 }
316
317 pub fn form<T: Serialize>(mut self, value: &T) -> Self {
319 match serde_html_form::to_string(value) {
320 Ok(encoded) => {
321 self.body = Bytes::from(encoded);
322 self.headers.insert(
323 CONTENT_TYPE,
324 HeaderValue::from_static("application/x-www-form-urlencoded"),
325 );
326 }
327 Err(e) => self.fail(ClientError::Request(e.to_string())),
328 }
329 self
330 }
331
332 pub fn timeout(mut self, d: Duration) -> Self {
334 self.timeout = Some(d);
335 self
336 }
337
338 fn fail(&mut self, error: ClientError) {
342 if self.error.is_none() {
343 self.error = Some(error);
344 }
345 }
346
347 pub async fn send(self) -> Result<Response, ClientError> {
355 if let Some(error) = self.error {
356 return Err(error);
357 }
358 let deadline = self.timeout.unwrap_or(self.client.timeout);
359 let send = self.send_following();
360 match tokio::time::timeout(deadline, send).await {
361 Ok(result) => result,
362 Err(_) => Err(ClientError::Timeout(deadline)),
363 }
364 }
365
366 async fn send_following(self) -> Result<Response, ClientError> {
369 let RequestBuilder {
370 client,
371 method,
372 url,
373 headers,
374 body,
375 ..
376 } = self;
377 let mut headers = headers;
379
380 let mut uri: Uri = url.parse().map_err(|e| ClientError::Url(format!("{e}")))?;
381 let mut method = method;
382 let mut body = body;
383 let mut hops = 0usize;
384
385 let mut stripped: std::collections::HashSet<http::HeaderName> =
391 std::collections::HashSet::new();
392
393 loop {
394 check_scheme(&uri)?;
395
396 let mut request = Request::builder()
397 .method(method.clone())
398 .uri(uri.clone())
399 .body(Full::new(body.clone()))
400 .map_err(|e| ClientError::Request(e.to_string()))?;
401
402 let target = request.headers_mut();
403 for (name, value) in &client.default_headers {
404 if stripped.contains(name) {
408 continue;
409 }
410 target.insert(name, value.clone());
411 }
412 for (name, value) in &headers {
413 target.insert(name, value.clone());
414 }
415 target
416 .entry(USER_AGENT)
417 .or_insert_with(|| client.user_agent.clone());
418
419 let response = client
420 .inner
421 .request(request)
422 .await
423 .map_err(|e| ClientError::Transport(e.to_string()))?;
424
425 let status = response.status();
426 let redirect = matches!(
427 status,
428 StatusCode::MOVED_PERMANENTLY
429 | StatusCode::FOUND
430 | StatusCode::SEE_OTHER
431 | StatusCode::TEMPORARY_REDIRECT
432 | StatusCode::PERMANENT_REDIRECT
433 );
434
435 if redirect && client.max_redirects > 0 {
436 if hops >= client.max_redirects {
437 return Err(ClientError::TooManyRedirects(client.max_redirects));
438 }
439 if let Some(next) = response
440 .headers()
441 .get(LOCATION)
442 .and_then(|v| v.to_str().ok())
443 .map(|v| v.to_string())
444 {
445 let previous = uri.clone();
446 uri = resolve(&uri, &next)?;
447 if !same_origin(&previous, &uri) {
453 for name in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION] {
454 headers.remove(&name);
455 stripped.insert(name);
456 }
457 }
458 if previous.scheme_str() == Some("https") && uri.scheme_str() == Some("http") {
463 return Err(ClientError::Url(
464 "refusing a redirect from https to http".into(),
465 ));
466 }
467 if matches!(
472 status,
473 StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND | StatusCode::SEE_OTHER
474 ) && method != Method::HEAD
475 {
476 method = Method::GET;
477 body = Bytes::new();
478 for name in [
488 CONTENT_TYPE,
489 CONTENT_LENGTH,
490 CONTENT_ENCODING,
491 TRANSFER_ENCODING,
492 ] {
493 headers.remove(&name);
494 stripped.insert(name);
495 }
496 }
497 hops += 1;
498 continue;
499 }
500 }
501
502 let (parts, incoming) = response.into_parts();
503 let collected = Limited::new(incoming, client.max_response_bytes)
504 .collect()
505 .await
506 .map_err(|e| {
507 if e.downcast_ref::<http_body_util::LengthLimitError>()
511 .is_some()
512 {
513 ClientError::BodyTooLarge(client.max_response_bytes)
514 } else {
515 ClientError::Body(e.to_string())
516 }
517 })?;
518
519 return Ok(Response {
520 status: parts.status,
521 headers: parts.headers,
522 body: collected.to_bytes(),
523 });
524 }
525 }
526}
527
528fn same_origin(a: &Uri, b: &Uri) -> bool {
533 fn port(u: &Uri) -> Option<u16> {
534 u.port_u16().or(match u.scheme_str() {
535 Some("http") => Some(80),
536 Some("https") => Some(443),
537 _ => None,
538 })
539 }
540 a.scheme_str() == b.scheme_str() && a.host() == b.host() && port(a) == port(b)
541}
542
543fn check_scheme(uri: &Uri) -> Result<(), ClientError> {
545 match uri.scheme_str() {
546 Some("http") => Ok(()),
547 #[cfg(feature = "tls")]
548 Some("https") => Ok(()),
549 #[cfg(not(feature = "tls"))]
550 Some("https") => Err(ClientError::Url(
551 "https needs the `tls` feature on churust-client".into(),
552 )),
553 Some(other) => Err(ClientError::Url(format!("unsupported scheme: {other}"))),
554 None => Err(ClientError::Url("no scheme in url".into())),
555 }
556}
557
558fn names_its_own_scheme(location: &str) -> bool {
575 let Some(boundary) = location.find([':', '/', '?', '#']) else {
576 return false;
577 };
578 if location.as_bytes()[boundary] != b':' {
579 return false;
580 }
581 let mut scheme = location[..boundary].chars();
586 scheme.next().is_some_and(|c| c.is_ascii_alphabetic())
587 && scheme.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
588}
589
590fn resolve(current: &Uri, location: &str) -> Result<Uri, ClientError> {
596 if names_its_own_scheme(location) {
597 return location
598 .parse()
599 .map_err(|e| ClientError::Url(format!("bad redirect target: {e}")));
600 }
601
602 let authority = current
603 .authority()
604 .ok_or_else(|| ClientError::Url("cannot resolve a relative redirect".into()))?;
605 let scheme = current.scheme_str().unwrap_or("http");
606
607 let joined = if location.starts_with('/') {
608 format!("{scheme}://{authority}{location}")
609 } else if location.starts_with('?') {
610 format!("{scheme}://{authority}{}{location}", current.path())
616 } else {
617 let base = current
618 .path()
619 .rsplit_once('/')
620 .map(|(head, _)| head)
621 .unwrap_or("");
622 format!("{scheme}://{authority}{base}/{location}")
623 };
624
625 joined
626 .parse()
627 .map_err(|e| ClientError::Url(format!("bad redirect target: {e}")))
628}
629
630#[derive(Clone, Debug)]
632pub struct Response {
633 status: StatusCode,
634 headers: HeaderMap,
635 body: Bytes,
636}
637
638impl Response {
639 pub fn status(&self) -> StatusCode {
641 self.status
642 }
643
644 pub fn headers(&self) -> &HeaderMap {
646 &self.headers
647 }
648
649 pub fn header(&self, name: &str) -> Option<&str> {
651 self.headers.get(name).and_then(|v| v.to_str().ok())
652 }
653
654 pub fn bytes(&self) -> &Bytes {
656 &self.body
657 }
658
659 pub fn text(&self) -> Result<String, ClientError> {
665 String::from_utf8(self.body.to_vec()).map_err(|e| ClientError::Decode(e.to_string()))
666 }
667
668 pub fn json<T: DeserializeOwned>(&self) -> Result<T, ClientError> {
674 serde_json::from_slice(&self.body).map_err(|e| ClientError::Decode(e.to_string()))
675 }
676
677 pub fn error_for_status(self) -> Result<Self, ClientError> {
684 if self.status.is_client_error() || self.status.is_server_error() {
685 let preview: String = self.text().unwrap_or_default().chars().take(200).collect();
686 return Err(ClientError::Transport(format!(
687 "{}: {preview}",
688 self.status
689 )));
690 }
691 Ok(self)
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 #[test]
700 fn a_relative_redirect_resolves_against_the_current_path() {
701 let current: Uri = "http://example.com/a/b".parse().unwrap();
702 assert_eq!(
703 resolve(¤t, "c").unwrap().to_string(),
704 "http://example.com/a/c"
705 );
706 }
707
708 #[test]
709 fn an_absolute_path_redirect_replaces_the_path() {
710 let current: Uri = "http://example.com/a/b".parse().unwrap();
711 assert_eq!(
712 resolve(¤t, "/z").unwrap().to_string(),
713 "http://example.com/z"
714 );
715 }
716
717 #[test]
718 fn an_absolute_redirect_is_taken_whole() {
719 let current: Uri = "http://example.com/a".parse().unwrap();
720 assert_eq!(
721 resolve(¤t, "http://other.test/x")
722 .unwrap()
723 .to_string(),
724 "http://other.test/x"
725 );
726 }
727
728 #[test]
729 fn a_relative_redirect_whose_query_contains_a_url_still_resolves() {
730 let current: Uri = "http://api.example.com/dashboard".parse().unwrap();
735 assert_eq!(
736 resolve(¤t, "/login?next=https://api.example.com/dashboard")
737 .unwrap()
738 .to_string(),
739 "http://api.example.com/login?next=https://api.example.com/dashboard"
740 );
741 }
742
743 #[test]
744 fn a_redirect_naming_a_scheme_without_a_double_slash_is_not_joined_onto_the_origin() {
745 let current: Uri = "http://example.com/a".parse().unwrap();
753 let target = resolve(¤t, "mailto:ops@example.com").unwrap();
754 assert_eq!(target.to_string(), "mailto:ops@example.com");
755 assert!(matches!(check_scheme(&target), Err(ClientError::Url(_))));
756 }
757
758 #[test]
759 fn a_query_only_redirect_keeps_the_path_it_came_from() {
760 let current: Uri = "http://example.com/a/b".parse().unwrap();
764 assert_eq!(
765 resolve(¤t, "?page=2").unwrap().to_string(),
766 "http://example.com/a/b?page=2"
767 );
768 }
769
770 #[test]
771 fn a_scheme_the_client_cannot_speak_is_refused() {
772 let uri: Uri = "ftp://example.com/x".parse().unwrap();
773 assert!(matches!(check_scheme(&uri), Err(ClientError::Url(_))));
774 }
775
776 #[tokio::test]
777 async fn a_file_url_never_reaches_the_connector() {
778 let err = Client::new()
782 .get("file:///etc/passwd")
783 .send()
784 .await
785 .expect_err("a file url must not be fetched");
786 assert!(matches!(err, ClientError::Url(_)), "{err:?}");
787 }
788
789 #[test]
790 fn plain_http_is_allowed() {
791 let uri: Uri = "http://example.com/".parse().unwrap();
792 assert!(check_scheme(&uri).is_ok());
793 }
794
795 #[cfg(not(feature = "tls"))]
796 #[test]
797 fn https_without_the_tls_feature_says_so() {
798 let uri: Uri = "https://example.com/".parse().unwrap();
799 match check_scheme(&uri) {
800 Err(ClientError::Url(why)) => assert!(why.contains("tls")),
801 other => panic!("expected a url error, got {other:?}"),
802 }
803 }
804
805 #[test]
806 fn query_pairs_are_appended_to_an_existing_query() {
807 let client = Client::new();
808 let req = client
809 .get("http://example.com/search?a=1")
810 .query(&[("b", "2")]);
811 assert_eq!(req.url, "http://example.com/search?a=1&b=2");
812 }
813
814 #[test]
815 fn query_pairs_open_a_query_when_there_is_none() {
816 let client = Client::new();
817 let req = client.get("http://example.com/search").query(&[("b", "2")]);
818 assert_eq!(req.url, "http://example.com/search?b=2");
819 }
820
821 #[test]
822 fn an_invalid_header_surfaces_at_send_not_at_the_builder() {
823 let client = Client::new();
824 let req = client.get("http://example.com/").header("bad header", "x");
825 assert!(matches!(req.error, Some(ClientError::Request(_))));
826 }
827
828 #[test]
829 fn json_sets_the_content_type() {
830 let client = Client::new();
831 let req = client
832 .post("http://example.com/")
833 .json(&serde_json::json!({"a": 1}));
834 assert_eq!(req.headers.get(CONTENT_TYPE).unwrap(), "application/json");
835 assert_eq!(req.body, Bytes::from(r#"{"a":1}"#));
836 }
837
838 #[test]
839 fn error_for_status_keeps_success() {
840 let ok = Response {
841 status: StatusCode::OK,
842 headers: HeaderMap::new(),
843 body: Bytes::from("fine"),
844 };
845 assert!(ok.error_for_status().is_ok());
846 }
847
848 #[test]
849 fn error_for_status_reports_the_body_of_a_failure() {
850 let bad = Response {
851 status: StatusCode::BAD_REQUEST,
852 headers: HeaderMap::new(),
853 body: Bytes::from("missing field: name"),
854 };
855 match bad.error_for_status() {
856 Err(ClientError::Transport(why)) => {
857 assert!(why.contains("400"));
858 assert!(why.contains("missing field"));
859 }
860 other => panic!("expected a transport error, got {other:?}"),
861 }
862 }
863}