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//! # Compressed responses
38//!
39//! By default the client advertises `Accept-Encoding: gzip, deflate` and
40//! transparently inflates those encodings, so a peer that compresses — CDNs,
41//! many affiliate APIs — is not left as a bag of deflate bits on the caller.
42//! The decompressed size is still bounded by [`Client::max_response_bytes`]:
43//! a tiny compressed bomb that expands past the ceiling is refused, which is
44//! the same limit that protects against an uncompressed flood. Disable with
45//! [`Client::auto_decompress`]`(false)` when you need the raw bytes or want to
46//! negotiate encoding yourself.
47//!
48//! [Churust]: https://docs.rs/churust
49
50#![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
67/// Default ceiling on a response body, in bytes.
68const DEFAULT_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
69/// Default per-request deadline.
70const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
71/// Default cap on redirects followed in one request.
72const 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/// Everything that can go wrong sending a request.
80#[derive(Debug)]
81pub enum ClientError {
82    /// The URL could not be parsed, or names a scheme this client cannot speak.
83    Url(String),
84    /// The request could not be built, usually an invalid header name or value.
85    Request(String),
86    /// The connection failed, or the peer did.
87    Transport(String),
88    /// The request did not finish inside its deadline.
89    Timeout(Duration),
90    /// The response body exceeded [`Client::max_response_bytes`].
91    BodyTooLarge(usize),
92    /// The body could not be read.
93    Body(String),
94    /// The body was not valid UTF-8, or did not deserialize.
95    Decode(String),
96    /// More redirects than [`Client::max_redirects`] allows.
97    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/// A pooled HTTP client.
120///
121/// Cloning is cheap and shares the connection pool, so build one per process
122/// and clone it into whatever needs it. Building one per request would open a
123/// fresh connection every time, which is the single most common way to make an
124/// outbound call slow.
125#[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    /// When true (the default), advertise and decode gzip/deflate responses.
134    auto_decompress: bool,
135}
136
137impl Default for Client {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl Client {
144    /// A client with pooled connections, a 30 second timeout, a 16 MiB response
145    /// ceiling, and redirect following up to 10 hops.
146    pub fn new() -> Self {
147        #[cfg(feature = "tls")]
148        let connector = {
149            let mut http = hyper_util::client::legacy::connect::HttpConnector::new();
150            // The HTTPS connector needs to be handed absolute URIs including
151            // plaintext ones, so http stays enforceable rather than impossible.
152            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    /// Fail a request that has not finished after `d`.
174    ///
175    /// The deadline covers the whole request including redirects, not each hop,
176    /// so a redirect chain cannot multiply the time a caller waits.
177    pub fn timeout(mut self, d: Duration) -> Self {
178        self.timeout = d;
179        self
180    }
181
182    /// Refuse a response body larger than `bytes`.
183    ///
184    /// The ceiling applies to the **payload the caller sees**: after transparent
185    /// decompression when that is enabled, so a compressed response cannot
186    /// expand past this limit either.
187    pub fn max_response_bytes(mut self, bytes: usize) -> Self {
188        self.max_response_bytes = bytes;
189        self
190    }
191
192    /// Follow at most `n` redirects. Zero returns the redirect response itself.
193    pub fn max_redirects(mut self, n: usize) -> Self {
194        self.max_redirects = n;
195        self
196    }
197
198    /// Advertise and decode `gzip` / `deflate` response bodies.
199    ///
200    /// Enabled by default. Set to `false` to leave `Content-Encoding` bodies
201    /// untouched and stop sending `Accept-Encoding`.
202    pub fn auto_decompress(mut self, enabled: bool) -> Self {
203        self.auto_decompress = enabled;
204        self
205    }
206
207    /// Replace the `User-Agent` sent with every request.
208    ///
209    /// # Errors
210    ///
211    /// If the value is not a valid header value.
212    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    /// Send a header with every request from this client.
219    ///
220    /// # Errors
221    ///
222    /// If the name or value is not valid.
223    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    /// Start a request with an arbitrary method.
233    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    /// Start a `GET`.
246    pub fn get(&self, url: impl Into<String>) -> RequestBuilder {
247        self.request(Method::GET, url)
248    }
249
250    /// Start a `POST`.
251    pub fn post(&self, url: impl Into<String>) -> RequestBuilder {
252        self.request(Method::POST, url)
253    }
254
255    /// Start a `PUT`.
256    pub fn put(&self, url: impl Into<String>) -> RequestBuilder {
257        self.request(Method::PUT, url)
258    }
259
260    /// Start a `PATCH`.
261    pub fn patch(&self, url: impl Into<String>) -> RequestBuilder {
262        self.request(Method::PATCH, url)
263    }
264
265    /// Start a `DELETE`.
266    pub fn delete(&self, url: impl Into<String>) -> RequestBuilder {
267        self.request(Method::DELETE, url)
268    }
269
270    /// Start a `HEAD`.
271    pub fn head(&self, url: impl Into<String>) -> RequestBuilder {
272        self.request(Method::HEAD, url)
273    }
274}
275
276/// A request under construction. Finish it with [`send`](RequestBuilder::send).
277#[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    /// The first construction error, reported by `send` rather than by every
286    /// builder method, so a chain reads without a `?` at each link.
287    error: Option<ClientError>,
288}
289
290impl RequestBuilder {
291    /// Set a header, replacing any previous value.
292    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    /// Set `Authorization: Bearer <token>`.
306    pub fn bearer(self, token: &str) -> Self {
307        self.header(AUTHORIZATION.as_str(), &format!("Bearer {token}"))
308    }
309
310    /// Append a query string built from `pairs`.
311    ///
312    /// Existing query parameters on the URL are kept; these are added after
313    /// them.
314    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    /// Send `body` verbatim, with no `Content-Type` of its own.
328    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
329        self.body = body.into();
330        self
331    }
332
333    /// Serialize `value` as JSON and set `Content-Type: application/json`.
334    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    /// Serialize `value` as a URL-encoded form body.
347    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    /// Override the client's timeout for this request only.
362    pub fn timeout(mut self, d: Duration) -> Self {
363        self.timeout = Some(d);
364        self
365    }
366
367    /// Record the first construction failure and keep the rest of the chain
368    /// building, so the caller sees one error at `send` rather than a compile
369    /// error at every link.
370    fn fail(&mut self, error: ClientError) {
371        if self.error.is_none() {
372            self.error = Some(error);
373        }
374    }
375
376    /// Send the request and read the whole response.
377    ///
378    /// # Errors
379    ///
380    /// Any [`ClientError`]. A non-2xx status is **not** an error: it is a
381    /// response, and whether a `404` is a failure is the caller's judgement.
382    /// Use [`Response::error_for_status`] to opt into the other convention.
383    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    /// The request itself, redirects included. Wrapped in one timeout by
396    /// [`send`](Self::send).
397    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        // Mutable because a cross-origin redirect drops credentials from it.
407        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        // Header names dropped for the remainder of this exchange: a
415        // credential once a redirect crossed an origin, an entity header once
416        // a redirect flipped the method and emptied the body. Recorded rather
417        // than merely removed, since the default headers are re-applied on
418        // every hop and would otherwise put back whatever was taken out.
419        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                // A default header is still a credential if it is one, and
434                // still describes a body if it is one, so anything this
435                // exchange has decided to drop stays dropped here too.
436                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            // Only advertise encodings we can actually inflate. Without this,
448            // a peer that compresses by default hands back bytes the caller
449            // cannot read; with it, decompression is the common path.
450            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                    // A credential is scoped to the origin it was issued for.
485                    // Re-sending it because a server said `Location:` hands it
486                    // to whatever host that server named — which is a
487                    // credential-harvesting primitive, not a redirect. curl and
488                    // reqwest both strip on a change of origin.
489                    if !same_origin(&previous, &uri) {
490                        for name in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION] {
491                            headers.remove(&name);
492                            stripped.insert(name);
493                        }
494                    }
495                    // A redirect must not walk a secure request down to
496                    // plaintext, which would send whatever survived in the
497                    // clear. `check_scheme` sees only the target, so the
498                    // comparison has to happen here.
499                    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                    // 303 always becomes a GET; 301 and 302 do in practice,
505                    // which every browser and every other client settled on
506                    // long ago. 307 and 308 preserve the method, which is the
507                    // whole reason they exist.
508                    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                        // The headers that described the body have to leave
516                        // with it. `json()` and `form()` set `Content-Type`,
517                        // and re-applying it on the next hop sends a `GET`
518                        // that announces `application/json` for a payload that
519                        // no longer exists — a request contradicting itself,
520                        // and one no other client sends: the Fetch standard
521                        // deletes the request-body-header-names on precisely
522                        // this transition, which is what curl, reqwest and
523                        // tower-http all implement.
524                        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                    // `Limited` reports the overrun through its own error type,
545                    // and the distinction matters: one is the peer misbehaving,
546                    // the other is the network.
547                    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
576/// Inflate a gzip/deflate body when `Content-Encoding` says so, then strip the
577/// headers that no longer describe the payload.
578///
579/// Unknown encodings are left alone so a future `br` peer still works once a
580/// decoder is added; the caller then sees the raw stream and the header.
581fn 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    // Take the first coding only — multi-layer stacks are rare on the open web
590    // and supporting them without streaming is more code than they are worth.
591    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        // RFC 9110 §8.4.1.2: "deflate" is the zlib wrapper (RFC 1950), not raw
600        // DEFLATE. ZlibDecoder matches that; a peer that sends raw DEFLATE is
601        // non-conformant and fails here rather than silently corrupting data.
602        "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    // Length described the compressed wire form; keep it and every length
608    // check on the body is wrong by exactly the compression ratio.
609    headers.remove(CONTENT_LENGTH);
610    Ok(Bytes::from(decoded))
611}
612
613/// Read from `r` into a buffer that refuses to grow past `max_bytes`.
614fn 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
628/// A [`Write`] that errors once the accumulated length would exceed `max`.
629struct 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
648/// Whether two URIs address the same origin: scheme, host and effective port.
649///
650/// The port is compared after defaulting, so `http://h` and `http://h:80` are
651/// one origin, matching the web's own definition rather than a string compare.
652fn 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
663/// Refuse a scheme the client cannot speak, before it reaches the connector.
664fn 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
678/// Whether a `Location` value opens with a scheme of its own, and is therefore
679/// an absolute target to be taken whole rather than joined onto the current one.
680///
681/// This used to be `location.contains("://")`, which searches the entire value
682/// for a substring that is perfectly ordinary *inside a query*. The return-to
683/// parameter that every login flow carries — `/login?next=https://host/page` —
684/// was therefore read as absolute, handed to `http` as-is, and parsed into a
685/// scheme-less origin-form URI, which `check_scheme` then rejected with "no
686/// scheme in url". A redirect the client should simply have followed became a
687/// hard error, and it did so on exactly the shape a client meets most.
688///
689/// RFC 3986 §4.2 decides this structurally instead: a reference is absolute
690/// only when its first segment, everything before the first `/`, `?` or `#`,
691/// is a scheme followed by a colon. A leading delimiter means there is no
692/// scheme at all, and a colon appearing after one belongs to the path, the
693/// query or the fragment and says nothing about this reference.
694fn 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    // RFC 3986 §3.1: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). The
702    // leading-ALPHA rule is what keeps a bare `8080:...` from being read as a
703    // scheme, and it is why the grammar is worth spelling out rather than just
704    // testing for a colon.
705    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
710/// Resolve a `Location` value against the URL it came from.
711///
712/// Relative targets are joined onto the current origin. A target that names its
713/// own scheme is taken as-is and then re-checked by [`check_scheme`], so a
714/// redirect cannot walk an `https` request down to `http`, or into `file:`.
715fn 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        // RFC 3986 §5.3: a reference that is only a query replaces the query
731        // and keeps the path whole. Falling through to the relative-path arm
732        // below would drop the last path segment instead, which used to be
733        // masked for the `?next=https://...` case because the old `://` scan
734        // sent it down the absolute branch and failed the request outright.
735        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/// A response, with its body already read.
751#[derive(Clone, Debug)]
752pub struct Response {
753    status: StatusCode,
754    headers: HeaderMap,
755    body: Bytes,
756}
757
758impl Response {
759    /// The status code.
760    pub fn status(&self) -> StatusCode {
761        self.status
762    }
763
764    /// All response headers.
765    pub fn headers(&self) -> &HeaderMap {
766        &self.headers
767    }
768
769    /// One header as a string, when it is valid UTF-8.
770    pub fn header(&self, name: &str) -> Option<&str> {
771        self.headers.get(name).and_then(|v| v.to_str().ok())
772    }
773
774    /// The raw body.
775    pub fn bytes(&self) -> &Bytes {
776        &self.body
777    }
778
779    /// The body as text.
780    ///
781    /// # Errors
782    ///
783    /// If the body is not valid UTF-8.
784    pub fn text(&self) -> Result<String, ClientError> {
785        // Validate in place, then allocate once — avoid `to_vec` + from_utf8.
786        std::str::from_utf8(&self.body)
787            .map(str::to_owned)
788            .map_err(|e| ClientError::Decode(e.to_string()))
789    }
790
791    /// The body deserialized from JSON.
792    ///
793    /// # Errors
794    ///
795    /// If the body is not valid JSON for `T`.
796    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    /// Turn a 4xx or 5xx into an error, keeping 2xx and 3xx as values.
801    ///
802    /// # Errors
803    ///
804    /// [`ClientError::Transport`] carrying the status and the first part of the
805    /// body, which is usually where the reason is.
806    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(&current, "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(&current, "/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(&current, "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        // The `next=` return-to parameter is how every login flow remembers
854        // where the visitor was going, and its value is a whole URL. Deciding
855        // absoluteness by scanning the entire `Location` for `://` mistook this
856        // for an absolute target and then failed the request outright.
857        let current: Uri = "http://api.example.com/dashboard".parse().unwrap();
858        assert_eq!(
859            resolve(&current, "/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        // `mailto:` carries no `//`, so the old `://` scan called it relative
869        // and pasted it onto the current origin, turning a target the client
870        // must refuse into a genuine request for
871        // `http://example.com/mailto:ops@example.com`. Structurally it is
872        // absolute, so it is now taken whole; `http` parses it as the
873        // scheme-less, authority-only URI it is, and `check_scheme` refuses it
874        // — which is the outcome a non-HTTP redirect target should have.
875        let current: Uri = "http://example.com/a".parse().unwrap();
876        let target = resolve(&current, "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        // RFC 3986 §5.3: a reference that is nothing but a query replaces the
884        // query and leaves the path whole, rather than being treated as the
885        // last segment of a relative path.
886        let current: Uri = "http://example.com/a/b".parse().unwrap();
887        assert_eq!(
888            resolve(&current, "?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        // `file:///etc/passwd` does not even parse as an HTTP URI, so it is
902        // refused before any of this crate's own checks. Worth pinning: a
903        // client that reads local files on request is an SSRF primitive.
904        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}