Skip to main content

churust_client/
lib.rs

1//! An HTTP client for [Churust] applications.
2//!
3//! A service usually has to call other services: an identity provider, a
4//! payment gateway, its own sibling. This is the client for that, built on the
5//! same hyper the server side runs on, so a Churust binary carries one HTTP
6//! implementation rather than two.
7//!
8//! ```no_run
9//! use churust_client::Client;
10//!
11//! # async fn example() -> Result<(), churust_client::ClientError> {
12//! let client = Client::new();
13//! let res = client.get("http://127.0.0.1:8080/health").send().await?;
14//!
15//! assert_eq!(res.status().as_u16(), 200);
16//! println!("{}", res.text()?);
17//! # Ok(())
18//! # }
19//! ```
20//!
21//! # Scope
22//!
23//! Deliberately small: build a request, send it, read the response. Retries,
24//! circuit breaking, service discovery and tracing propagation are policy, and
25//! policy belongs to the application that knows what it is calling. What the
26//! client does own is the part that is easy to get wrong on your own: pooled
27//! connections, an enforced timeout, a bounded response body, and refusing to
28//! follow a redirect into a different scheme.
29//!
30//! # Responses are bounded
31//!
32//! A response body is read into memory up to [`Client::max_response_bytes`]
33//! (16 MiB by default) and refused past it. An unbounded read from a service
34//! you do not control is how one slow dependency becomes your own out-of-memory
35//! kill.
36//!
37//! [Churust]: https://docs.rs/churust
38
39#![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
54/// Default ceiling on a response body, in bytes.
55const DEFAULT_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
56/// Default per-request deadline.
57const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
58/// Default cap on redirects followed in one request.
59const 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/// Everything that can go wrong sending a request.
67#[derive(Debug)]
68pub enum ClientError {
69    /// The URL could not be parsed, or names a scheme this client cannot speak.
70    Url(String),
71    /// The request could not be built, usually an invalid header name or value.
72    Request(String),
73    /// The connection failed, or the peer did.
74    Transport(String),
75    /// The request did not finish inside its deadline.
76    Timeout(Duration),
77    /// The response body exceeded [`Client::max_response_bytes`].
78    BodyTooLarge(usize),
79    /// The body could not be read.
80    Body(String),
81    /// The body was not valid UTF-8, or did not deserialize.
82    Decode(String),
83    /// More redirects than [`Client::max_redirects`] allows.
84    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/// A pooled HTTP client.
107///
108/// Cloning is cheap and shares the connection pool, so build one per process
109/// and clone it into whatever needs it. Building one per request would open a
110/// fresh connection every time, which is the single most common way to make an
111/// outbound call slow.
112#[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    /// A client with pooled connections, a 30 second timeout, a 16 MiB response
130    /// ceiling, and redirect following up to 10 hops.
131    pub fn new() -> Self {
132        #[cfg(feature = "tls")]
133        let connector = {
134            let mut http = hyper_util::client::legacy::connect::HttpConnector::new();
135            // The HTTPS connector needs to be handed absolute URIs including
136            // plaintext ones, so http stays enforceable rather than impossible.
137            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    /// Fail a request that has not finished after `d`.
158    ///
159    /// The deadline covers the whole request including redirects, not each hop,
160    /// so a redirect chain cannot multiply the time a caller waits.
161    pub fn timeout(mut self, d: Duration) -> Self {
162        self.timeout = d;
163        self
164    }
165
166    /// Refuse a response body larger than `bytes`.
167    pub fn max_response_bytes(mut self, bytes: usize) -> Self {
168        self.max_response_bytes = bytes;
169        self
170    }
171
172    /// Follow at most `n` redirects. Zero returns the redirect response itself.
173    pub fn max_redirects(mut self, n: usize) -> Self {
174        self.max_redirects = n;
175        self
176    }
177
178    /// Replace the `User-Agent` sent with every request.
179    ///
180    /// # Errors
181    ///
182    /// If the value is not a valid header value.
183    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    /// Send a header with every request from this client.
190    ///
191    /// # Errors
192    ///
193    /// If the name or value is not valid.
194    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    /// Start a request with an arbitrary method.
204    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    /// Start a `GET`.
217    pub fn get(&self, url: impl Into<String>) -> RequestBuilder {
218        self.request(Method::GET, url)
219    }
220
221    /// Start a `POST`.
222    pub fn post(&self, url: impl Into<String>) -> RequestBuilder {
223        self.request(Method::POST, url)
224    }
225
226    /// Start a `PUT`.
227    pub fn put(&self, url: impl Into<String>) -> RequestBuilder {
228        self.request(Method::PUT, url)
229    }
230
231    /// Start a `PATCH`.
232    pub fn patch(&self, url: impl Into<String>) -> RequestBuilder {
233        self.request(Method::PATCH, url)
234    }
235
236    /// Start a `DELETE`.
237    pub fn delete(&self, url: impl Into<String>) -> RequestBuilder {
238        self.request(Method::DELETE, url)
239    }
240
241    /// Start a `HEAD`.
242    pub fn head(&self, url: impl Into<String>) -> RequestBuilder {
243        self.request(Method::HEAD, url)
244    }
245}
246
247/// A request under construction. Finish it with [`send`](RequestBuilder::send).
248#[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    /// The first construction error, reported by `send` rather than by every
257    /// builder method, so a chain reads without a `?` at each link.
258    error: Option<ClientError>,
259}
260
261impl RequestBuilder {
262    /// Set a header, replacing any previous value.
263    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    /// Set `Authorization: Bearer <token>`.
277    pub fn bearer(self, token: &str) -> Self {
278        self.header(AUTHORIZATION.as_str(), &format!("Bearer {token}"))
279    }
280
281    /// Append a query string built from `pairs`.
282    ///
283    /// Existing query parameters on the URL are kept; these are added after
284    /// them.
285    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    /// Send `body` verbatim, with no `Content-Type` of its own.
299    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
300        self.body = body.into();
301        self
302    }
303
304    /// Serialize `value` as JSON and set `Content-Type: application/json`.
305    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    /// Serialize `value` as a URL-encoded form body.
318    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    /// Override the client's timeout for this request only.
333    pub fn timeout(mut self, d: Duration) -> Self {
334        self.timeout = Some(d);
335        self
336    }
337
338    /// Record the first construction failure and keep the rest of the chain
339    /// building, so the caller sees one error at `send` rather than a compile
340    /// error at every link.
341    fn fail(&mut self, error: ClientError) {
342        if self.error.is_none() {
343            self.error = Some(error);
344        }
345    }
346
347    /// Send the request and read the whole response.
348    ///
349    /// # Errors
350    ///
351    /// Any [`ClientError`]. A non-2xx status is **not** an error: it is a
352    /// response, and whether a `404` is a failure is the caller's judgement.
353    /// Use [`Response::error_for_status`] to opt into the other convention.
354    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    /// The request itself, redirects included. Wrapped in one timeout by
367    /// [`send`](Self::send).
368    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        // Mutable because a cross-origin redirect drops credentials from it.
378        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        // Header names dropped for the remainder of this exchange: a
386        // credential once a redirect crossed an origin, an entity header once
387        // a redirect flipped the method and emptied the body. Recorded rather
388        // than merely removed, since the default headers are re-applied on
389        // every hop and would otherwise put back whatever was taken out.
390        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                // A default header is still a credential if it is one, and
405                // still describes a body if it is one, so anything this
406                // exchange has decided to drop stays dropped here too.
407                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                    // A credential is scoped to the origin it was issued for.
448                    // Re-sending it because a server said `Location:` hands it
449                    // to whatever host that server named — which is a
450                    // credential-harvesting primitive, not a redirect. curl and
451                    // reqwest both strip on a change of origin.
452                    if !same_origin(&previous, &uri) {
453                        for name in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION] {
454                            headers.remove(&name);
455                            stripped.insert(name);
456                        }
457                    }
458                    // A redirect must not walk a secure request down to
459                    // plaintext, which would send whatever survived in the
460                    // clear. `check_scheme` sees only the target, so the
461                    // comparison has to happen here.
462                    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                    // 303 always becomes a GET; 301 and 302 do in practice,
468                    // which every browser and every other client settled on
469                    // long ago. 307 and 308 preserve the method, which is the
470                    // whole reason they exist.
471                    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                        // The headers that described the body have to leave
479                        // with it. `json()` and `form()` set `Content-Type`,
480                        // and re-applying it on the next hop sends a `GET`
481                        // that announces `application/json` for a payload that
482                        // no longer exists — a request contradicting itself,
483                        // and one no other client sends: the Fetch standard
484                        // deletes the request-body-header-names on precisely
485                        // this transition, which is what curl, reqwest and
486                        // tower-http all implement.
487                        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                    // `Limited` reports the overrun through its own error type,
508                    // and the distinction matters: one is the peer misbehaving,
509                    // the other is the network.
510                    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
528/// Whether two URIs address the same origin: scheme, host and effective port.
529///
530/// The port is compared after defaulting, so `http://h` and `http://h:80` are
531/// one origin, matching the web's own definition rather than a string compare.
532fn 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
543/// Refuse a scheme the client cannot speak, before it reaches the connector.
544fn 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
558/// Whether a `Location` value opens with a scheme of its own, and is therefore
559/// an absolute target to be taken whole rather than joined onto the current one.
560///
561/// This used to be `location.contains("://")`, which searches the entire value
562/// for a substring that is perfectly ordinary *inside a query*. The return-to
563/// parameter that every login flow carries — `/login?next=https://host/page` —
564/// was therefore read as absolute, handed to `http` as-is, and parsed into a
565/// scheme-less origin-form URI, which `check_scheme` then rejected with "no
566/// scheme in url". A redirect the client should simply have followed became a
567/// hard error, and it did so on exactly the shape a client meets most.
568///
569/// RFC 3986 §4.2 decides this structurally instead: a reference is absolute
570/// only when its first segment, everything before the first `/`, `?` or `#`,
571/// is a scheme followed by a colon. A leading delimiter means there is no
572/// scheme at all, and a colon appearing after one belongs to the path, the
573/// query or the fragment and says nothing about this reference.
574fn 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    // RFC 3986 §3.1: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). The
582    // leading-ALPHA rule is what keeps a bare `8080:...` from being read as a
583    // scheme, and it is why the grammar is worth spelling out rather than just
584    // testing for a colon.
585    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
590/// Resolve a `Location` value against the URL it came from.
591///
592/// Relative targets are joined onto the current origin. A target that names its
593/// own scheme is taken as-is and then re-checked by [`check_scheme`], so a
594/// redirect cannot walk an `https` request down to `http`, or into `file:`.
595fn 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        // RFC 3986 §5.3: a reference that is only a query replaces the query
611        // and keeps the path whole. Falling through to the relative-path arm
612        // below would drop the last path segment instead, which used to be
613        // masked for the `?next=https://...` case because the old `://` scan
614        // sent it down the absolute branch and failed the request outright.
615        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/// A response, with its body already read.
631#[derive(Clone, Debug)]
632pub struct Response {
633    status: StatusCode,
634    headers: HeaderMap,
635    body: Bytes,
636}
637
638impl Response {
639    /// The status code.
640    pub fn status(&self) -> StatusCode {
641        self.status
642    }
643
644    /// All response headers.
645    pub fn headers(&self) -> &HeaderMap {
646        &self.headers
647    }
648
649    /// One header as a string, when it is valid UTF-8.
650    pub fn header(&self, name: &str) -> Option<&str> {
651        self.headers.get(name).and_then(|v| v.to_str().ok())
652    }
653
654    /// The raw body.
655    pub fn bytes(&self) -> &Bytes {
656        &self.body
657    }
658
659    /// The body as text.
660    ///
661    /// # Errors
662    ///
663    /// If the body is not valid UTF-8.
664    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    /// The body deserialized from JSON.
669    ///
670    /// # Errors
671    ///
672    /// If the body is not valid JSON for `T`.
673    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    /// Turn a 4xx or 5xx into an error, keeping 2xx and 3xx as values.
678    ///
679    /// # Errors
680    ///
681    /// [`ClientError::Transport`] carrying the status and the first part of the
682    /// body, which is usually where the reason is.
683    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(&current, "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(&current, "/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(&current, "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        // The `next=` return-to parameter is how every login flow remembers
731        // where the visitor was going, and its value is a whole URL. Deciding
732        // absoluteness by scanning the entire `Location` for `://` mistook this
733        // for an absolute target and then failed the request outright.
734        let current: Uri = "http://api.example.com/dashboard".parse().unwrap();
735        assert_eq!(
736            resolve(&current, "/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        // `mailto:` carries no `//`, so the old `://` scan called it relative
746        // and pasted it onto the current origin, turning a target the client
747        // must refuse into a genuine request for
748        // `http://example.com/mailto:ops@example.com`. Structurally it is
749        // absolute, so it is now taken whole; `http` parses it as the
750        // scheme-less, authority-only URI it is, and `check_scheme` refuses it
751        // — which is the outcome a non-HTTP redirect target should have.
752        let current: Uri = "http://example.com/a".parse().unwrap();
753        let target = resolve(&current, "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        // RFC 3986 §5.3: a reference that is nothing but a query replaces the
761        // query and leaves the path whole, rather than being treated as the
762        // last segment of a relative path.
763        let current: Uri = "http://example.com/a/b".parse().unwrap();
764        assert_eq!(
765            resolve(&current, "?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        // `file:///etc/passwd` does not even parse as an HTTP URI, so it is
779        // refused before any of this crate's own checks. Worth pinning: a
780        // client that reads local files on request is an SSRF primitive.
781        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}