Skip to main content

ullun/
client.rs

1//! Outbound HTTP client
2//!
3//! Services call other services. [`HttpClient`] is a small client for that with
4//! the settings production calls need and ad-hoc code forgets:
5//!
6//! * timeouts for connecting and for the whole attempt, and a cap on the
7//!   response size
8//! * connection pooling (keep-alive) per host
9//! * retries with exponential backoff and jitter for connection failures and
10//!   `502/503/504/429` (honouring `Retry-After`), only for requests that are safe
11//!   to repeat: idempotent methods or requests carrying an `Idempotency-Key`
12//! * redirects (followed, up to a limit; credentials are dropped when the
13//!   target is another origin)
14//! * an SSRF guard ([`HttpClient::public_only`]): every connection, including
15//!   those after a redirect, goes to a vetted public address
16//! * HTTPS with the Mozilla root certificates (feature `client-tls`)
17//!
18//! ```no_run
19//! use std::time::Duration;
20//! use ullun::client::HttpClient;
21//!
22//! # async fn run() -> Result<(), ullun::client::ClientError> {
23//! let client = HttpClient::new()
24//!     .timeout(Duration::from_secs(5))
25//!     .retries(2)
26//!     .public_only();
27//! let res = client.get("http://example.com/api/items?limit=10").send().await?;
28//! if res.status() == 200 {
29//!     let items: serde_json::Value = res.json()?;
30//!     println!("{items}");
31//! }
32//! # Ok(())
33//! # }
34//! ```
35//!
36//! [`ClientError`] converts into [`Error`](crate::error::Error) (`504` for
37//! timeouts, `502` otherwise), so handlers can use `?` on client calls.
38
39use bytes::Bytes;
40use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
41use http_body_util::{BodyExt, Full};
42use hyper::body::Incoming;
43use hyper::client::conn::http1::SendRequest;
44use hyper_util::rt::TokioIo;
45use serde::{de::DeserializeOwned, Serialize};
46use std::collections::HashMap;
47use std::net::{IpAddr, SocketAddr};
48use std::sync::{Arc, Mutex};
49use std::time::{Duration, Instant};
50use tokio::io::{AsyncRead, AsyncWrite};
51use tokio::net::TcpStream;
52
53/// Why a client call failed
54#[derive(Debug)]
55#[non_exhaustive]
56pub enum ClientError {
57    /// The URL is not a usable `http://` or `https://` URL
58    InvalidUrl(String),
59    /// Name resolution or connecting failed
60    Connect(String),
61    /// The TLS handshake failed or `https` is not available
62    Tls(String),
63    /// The target resolved to a non-public address (with [`HttpClient::public_only`])
64    Blocked(IpAddr),
65    /// The attempt took longer than the timeout
66    Timeout,
67    /// The peer did not speak valid HTTP or closed the connection early
68    Protocol(String),
69    /// The response body exceeds [`HttpClient::max_response_bytes`]
70    TooLarge(usize),
71    /// More redirects than [`HttpClient::max_redirects`]
72    TooManyRedirects,
73    /// The response body is not the expected JSON / UTF-8
74    Decode(String),
75}
76
77impl std::fmt::Display for ClientError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            ClientError::InvalidUrl(u) => write!(f, "invalid URL: {u}"),
81            ClientError::Connect(e) => write!(f, "connection failed: {e}"),
82            ClientError::Tls(e) => write!(f, "TLS error: {e}"),
83            ClientError::Blocked(ip) => write!(f, "target address {ip} is not public"),
84            ClientError::Timeout => f.write_str("request timed out"),
85            ClientError::Protocol(e) => write!(f, "invalid response: {e}"),
86            ClientError::TooLarge(max) => write!(f, "response larger than {max} bytes"),
87            ClientError::TooManyRedirects => f.write_str("too many redirects"),
88            ClientError::Decode(e) => write!(f, "cannot decode response: {e}"),
89        }
90    }
91}
92
93impl std::error::Error for ClientError {}
94
95impl From<ClientError> for crate::error::Error {
96    fn from(e: ClientError) -> Self {
97        let status = if matches!(e, ClientError::Timeout) {
98            504
99        } else {
100            502
101        };
102        crate::error::Error::custom(status, format!("Upstream request failed: {e}"))
103    }
104}
105
106trait Io: AsyncRead + AsyncWrite + Unpin + Send {}
107impl<T: AsyncRead + AsyncWrite + Unpin + Send> Io for T {}
108
109type Sender = SendRequest<Full<Bytes>>;
110
111#[derive(Hash, PartialEq, Eq, Clone)]
112struct PoolKey {
113    https: bool,
114    host: String,
115    port: u16,
116}
117
118struct Idle {
119    sender: Sender,
120    since: Instant,
121}
122
123struct Shared {
124    pool: Mutex<HashMap<PoolKey, Vec<Idle>>>,
125    #[cfg(feature = "client-tls")]
126    tls: tokio_rustls::TlsConnector,
127}
128
129/// HTTP client, see the [module documentation](self). Cloning shares the connection pool.
130#[derive(Clone)]
131pub struct HttpClient {
132    shared: Arc<Shared>,
133    timeout: Duration,
134    connect_timeout: Duration,
135    max_response: usize,
136    max_redirects: usize,
137    retries: u32,
138    backoff: Duration,
139    public_only: bool,
140    user_agent: String,
141    headers: HeaderMap,
142    idle_timeout: Duration,
143    max_idle_per_host: usize,
144}
145
146impl Default for HttpClient {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl HttpClient {
153    pub fn new() -> Self {
154        Self {
155            shared: Arc::new(Shared {
156                pool: Mutex::new(HashMap::new()),
157                #[cfg(feature = "client-tls")]
158                tls: tls_connector(&[]).expect("default TLS configuration"),
159            }),
160            timeout: Duration::from_secs(30),
161            connect_timeout: Duration::from_secs(10),
162            max_response: 10 * 1024 * 1024,
163            max_redirects: 5,
164            retries: 0,
165            backoff: Duration::from_millis(200),
166            public_only: false,
167            user_agent: concat!("ullun-api/", env!("CARGO_PKG_VERSION")).to_string(),
168            headers: HeaderMap::new(),
169            idle_timeout: Duration::from_secs(30),
170            max_idle_per_host: 8,
171        }
172    }
173
174    /// Longest a single attempt (connect, send, read the whole response) may take (default 30 s)
175    pub fn timeout(mut self, timeout: Duration) -> Self {
176        self.timeout = timeout;
177        self
178    }
179
180    /// Longest name resolution + TCP/TLS connect may take (default 10 s)
181    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
182        self.connect_timeout = timeout;
183        self
184    }
185
186    /// Largest response body accepted (default 10 MiB)
187    pub fn max_response_bytes(mut self, max: usize) -> Self {
188        self.max_response = max;
189        self
190    }
191
192    /// Redirects followed per call (default 5, 0 returns the 3xx response itself)
193    pub fn max_redirects(mut self, max: usize) -> Self {
194        self.max_redirects = max;
195        self
196    }
197
198    /// Extra attempts after a failure that is safe to repeat (default 0)
199    pub fn retries(mut self, retries: u32) -> Self {
200        self.retries = retries;
201        self
202    }
203
204    /// First retry delay, doubled each time with jitter (default 200 ms)
205    pub fn retry_backoff(mut self, base: Duration) -> Self {
206        self.backoff = base;
207        self
208    }
209
210    /// Only connect to public addresses: loopback, private, link-local and
211    /// cloud-metadata targets are refused with [`ClientError::Blocked`]. Use it
212    /// whenever the URL comes from a user (webhooks, link previews, imports).
213    pub fn public_only(mut self) -> Self {
214        self.public_only = true;
215        self
216    }
217
218    pub fn user_agent(mut self, agent: &str) -> Self {
219        self.user_agent = agent.to_string();
220        self
221    }
222
223    /// Header sent with every request (unless the request sets it itself)
224    pub fn default_header(mut self, name: &str, value: &str) -> Self {
225        if let (Ok(n), Ok(v)) = (
226            HeaderName::from_bytes(name.as_bytes()),
227            HeaderValue::from_str(value),
228        ) {
229            self.headers.insert(n, v);
230        }
231        self
232    }
233
234    /// Keep idle connections this long for reuse (default 30 s, zero disables pooling)
235    pub fn pool_idle_timeout(mut self, timeout: Duration) -> Self {
236        self.idle_timeout = timeout;
237        self
238    }
239
240    /// Trust these PEM root certificates in addition to the built-in ones, e.g.
241    /// a private CA (feature `client-tls`)
242    #[cfg(feature = "client-tls")]
243    pub fn add_root_certificates_pem(mut self, pem: &[u8]) -> Result<Self, ClientError> {
244        let connector = tls_connector(&[pem])?;
245        let shared = Shared {
246            pool: Mutex::new(HashMap::new()),
247            tls: connector,
248        };
249        self.shared = Arc::new(shared);
250        Ok(self)
251    }
252
253    pub fn request(&self, method: Method, url: &str) -> RequestBuilder {
254        RequestBuilder {
255            client: self.clone(),
256            method,
257            url: url.to_string(),
258            headers: HeaderMap::new(),
259            body: Bytes::new(),
260            timeout: None,
261            error: None,
262        }
263    }
264
265    pub fn get(&self, url: &str) -> RequestBuilder {
266        self.request(Method::GET, url)
267    }
268    pub fn post(&self, url: &str) -> RequestBuilder {
269        self.request(Method::POST, url)
270    }
271    pub fn put(&self, url: &str) -> RequestBuilder {
272        self.request(Method::PUT, url)
273    }
274    pub fn patch(&self, url: &str) -> RequestBuilder {
275        self.request(Method::PATCH, url)
276    }
277    pub fn delete(&self, url: &str) -> RequestBuilder {
278        self.request(Method::DELETE, url)
279    }
280    pub fn head(&self, url: &str) -> RequestBuilder {
281        self.request(Method::HEAD, url)
282    }
283}
284
285/// A request under construction, created by the verbs of [`HttpClient`]
286pub struct RequestBuilder {
287    client: HttpClient,
288    method: Method,
289    url: String,
290    headers: HeaderMap,
291    body: Bytes,
292    timeout: Option<Duration>,
293    error: Option<ClientError>,
294}
295
296impl RequestBuilder {
297    pub fn header(mut self, name: &str, value: &str) -> Self {
298        match (
299            HeaderName::from_bytes(name.as_bytes()),
300            HeaderValue::from_str(value),
301        ) {
302            (Ok(n), Ok(v)) => {
303                self.headers.insert(n, v);
304            }
305            _ => self.error = Some(ClientError::InvalidUrl(format!("invalid header {name}"))),
306        }
307        self
308    }
309
310    pub fn bearer(self, token: &str) -> Self {
311        self.header("authorization", &format!("Bearer {token}"))
312    }
313
314    pub fn json<T: Serialize>(mut self, value: &T) -> Self {
315        match serde_json::to_vec(value) {
316            Ok(body) => {
317                self.body = Bytes::from(body);
318                self.header("content-type", "application/json")
319            }
320            Err(e) => {
321                self.error = Some(ClientError::Decode(e.to_string()));
322                self
323            }
324        }
325    }
326
327    pub fn form<T: Serialize>(mut self, value: &T) -> Self {
328        match serde_urlencoded::to_string(value) {
329            Ok(body) => {
330                self.body = Bytes::from(body);
331                self.header("content-type", "application/x-www-form-urlencoded")
332            }
333            Err(e) => {
334                self.error = Some(ClientError::Decode(e.to_string()));
335                self
336            }
337        }
338    }
339
340    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
341        self.body = body.into();
342        self
343    }
344
345    /// Append query parameters (percent-encoded)
346    pub fn query<T: Serialize>(mut self, params: &T) -> Self {
347        match serde_urlencoded::to_string(params) {
348            Ok(q) if !q.is_empty() => {
349                self.url
350                    .push(if self.url.contains('?') { '&' } else { '?' });
351                self.url.push_str(&q);
352            }
353            Ok(_) => {}
354            Err(e) => self.error = Some(ClientError::Decode(e.to_string())),
355        }
356        self
357    }
358
359    /// Continue the trace of the request being handled: sends `traceparent`
360    /// with the same trace id and this service's span as the callee's parent,
361    /// and passes `tracestate` on (needs [`TraceLayer`](crate::trace::TraceLayer);
362    /// does nothing without it)
363    pub fn trace(self, incoming: &crate::request::Request) -> Self {
364        let Some(ctx) = incoming.trace() else {
365            return self;
366        };
367        let this = self.header("traceparent", &ctx.traceparent());
368        match &ctx.tracestate {
369            Some(state) => this.header("tracestate", state),
370            None => this,
371        }
372    }
373
374    /// Per-attempt timeout for this request only
375    pub fn timeout(mut self, timeout: Duration) -> Self {
376        self.timeout = Some(timeout);
377        self
378    }
379
380    pub async fn send(self) -> Result<ClientResponse, ClientError> {
381        if let Some(e) = self.error {
382            return Err(e);
383        }
384        let c = &self.client;
385        let timeout = self.timeout.unwrap_or(c.timeout);
386        let repeatable = is_repeatable(&self.method, &self.headers);
387        let mut attempt = 0;
388        loop {
389            let outcome = match tokio::time::timeout(timeout, c.run(&self)).await {
390                Ok(r) => r,
391                Err(_) => Err(ClientError::Timeout),
392            };
393            let retry_after = match &outcome {
394                Ok(res) if retryable_status(res.status) => Some(res.retry_after()),
395                Ok(_) => None,
396                Err(e) if retryable_error(e) => Some(None),
397                Err(_) => None,
398            };
399            match retry_after {
400                Some(hint) if repeatable && attempt < c.retries => {
401                    let wait = hint.unwrap_or_else(|| c.delay(attempt));
402                    // do not sleep past a sensible bound for a server's Retry-After
403                    tokio::time::sleep(wait.min(Duration::from_secs(10))).await;
404                    attempt += 1;
405                }
406                _ => return outcome,
407            }
408        }
409    }
410}
411
412fn is_repeatable(method: &Method, headers: &HeaderMap) -> bool {
413    matches!(
414        *method,
415        Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
416    ) || headers.contains_key("idempotency-key")
417}
418
419fn retryable_status(status: StatusCode) -> bool {
420    matches!(status.as_u16(), 429 | 502 | 503 | 504)
421}
422
423fn retryable_error(e: &ClientError) -> bool {
424    matches!(
425        e,
426        ClientError::Connect(_) | ClientError::Timeout | ClientError::Protocol(_)
427    )
428}
429
430impl HttpClient {
431    /// Exponential backoff with up to 50 % jitter
432    fn delay(&self, attempt: u32) -> Duration {
433        let base = self.backoff.saturating_mul(1 << attempt.min(8));
434        let jitter = crate::security::random_bytes(1)
435            .ok()
436            .and_then(|b| b.first().copied())
437            .unwrap_or(0) as f64
438            / 255.0
439            * 0.5;
440        base.mul_f64(1.0 + jitter)
441    }
442
443    /// One attempt, following redirects
444    async fn run(&self, req: &RequestBuilder) -> Result<ClientResponse, ClientError> {
445        let mut url = req.url.clone();
446        let mut method = req.method.clone();
447        let mut body = req.body.clone();
448        let mut headers = req.headers.clone();
449        let origin = origin_of(&req.url)?;
450        for _ in 0..=self.max_redirects {
451            let uri: Uri = url
452                .parse()
453                .map_err(|_| ClientError::InvalidUrl(url.clone()))?;
454            let res = self.exchange(&uri, &method, &headers, &body).await?;
455            let status = res.status;
456            let location = res
457                .headers
458                .get(http::header::LOCATION)
459                .and_then(|v| v.to_str().ok());
460            if self.max_redirects == 0 || !status.is_redirection() || location.is_none() {
461                return Ok(res);
462            }
463            let next = resolve_location(&uri, location.unwrap_or_default())
464                .ok_or_else(|| ClientError::Protocol("bad redirect location".into()))?;
465            // 301/302/303 turn into GET without a body; 307/308 repeat the request
466            if matches!(status.as_u16(), 301..=303) && method != Method::HEAD {
467                method = Method::GET;
468                body = Bytes::new();
469                headers.remove(http::header::CONTENT_TYPE);
470                headers.remove(http::header::CONTENT_LENGTH);
471            }
472            if origin_of(&next)? != origin {
473                // never hand credentials to another origin
474                headers.remove(http::header::AUTHORIZATION);
475                headers.remove(http::header::COOKIE);
476            }
477            url = next;
478        }
479        Err(ClientError::TooManyRedirects)
480    }
481
482    async fn exchange(
483        &self,
484        uri: &Uri,
485        method: &Method,
486        extra: &HeaderMap,
487        body: &Bytes,
488    ) -> Result<ClientResponse, ClientError> {
489        let (https, host, port) = target(uri)?;
490        let key = PoolKey {
491            https,
492            host: host.clone(),
493            port,
494        };
495        let path = uri
496            .path_and_query()
497            .map(|p| p.as_str())
498            .unwrap_or("/")
499            .to_string();
500
501        let build = || -> Result<http::Request<Full<Bytes>>, ClientError> {
502            let mut b = http::Request::builder().method(method.clone()).uri(&path);
503            let h = b.headers_mut().expect("builder has headers");
504            let host_value = if (https && port == 443) || (!https && port == 80) {
505                host.clone()
506            } else {
507                format!("{host}:{port}")
508            };
509            h.insert(
510                http::header::HOST,
511                HeaderValue::from_str(&host_value)
512                    .map_err(|_| ClientError::InvalidUrl(uri.to_string()))?,
513            );
514            if let Ok(agent) = HeaderValue::from_str(&self.user_agent) {
515                h.insert(http::header::USER_AGENT, agent);
516            }
517            for (n, v) in self.headers.iter().chain(extra.iter()) {
518                h.insert(n.clone(), v.clone());
519            }
520            if !body.is_empty() || matches!(*method, Method::POST | Method::PUT | Method::PATCH) {
521                h.insert(http::header::CONTENT_LENGTH, body.len().into());
522            }
523            b.body(Full::new(body.clone()))
524                .map_err(|e| ClientError::Protocol(e.to_string()))
525        };
526
527        // a pooled connection may have been closed by the peer meanwhile: one
528        // fresh attempt is then always safe if nothing was received
529        let mut sender = self.checkout(&key);
530        let mut response = None;
531        if let Some(s) = sender.as_mut() {
532            match s.send_request(build()?).await {
533                Ok(r) => response = Some(r),
534                Err(_) => sender = None,
535            }
536        }
537        let response = match response {
538            Some(r) => r,
539            None => {
540                let mut fresh = self.connect(&key).await?;
541                let r = fresh
542                    .send_request(build()?)
543                    .await
544                    .map_err(|e| ClientError::Protocol(e.to_string()))?;
545                sender = Some(fresh);
546                r
547            }
548        };
549
550        let (parts, incoming) = response.into_parts();
551        let bytes = read_limited(incoming, self.max_response).await?;
552        let keep = parts.version == http::Version::HTTP_11
553            && !parts
554                .headers
555                .get(http::header::CONNECTION)
556                .and_then(|v| v.to_str().ok())
557                .is_some_and(|v| v.eq_ignore_ascii_case("close"));
558        if let (true, Some(s)) = (keep, sender) {
559            self.checkin(key, s);
560        }
561        Ok(ClientResponse {
562            status: parts.status,
563            headers: parts.headers,
564            body: bytes,
565        })
566    }
567
568    fn checkout(&self, key: &PoolKey) -> Option<Sender> {
569        let mut pool = self.shared.pool.lock().unwrap_or_else(|e| e.into_inner());
570        let list = pool.get_mut(key)?;
571        while let Some(idle) = list.pop() {
572            if !idle.sender.is_closed() && idle.since.elapsed() < self.idle_timeout {
573                return Some(idle.sender);
574            }
575        }
576        None
577    }
578
579    fn checkin(&self, key: PoolKey, sender: Sender) {
580        if self.idle_timeout.is_zero() || sender.is_closed() {
581            return;
582        }
583        let mut pool = self.shared.pool.lock().unwrap_or_else(|e| e.into_inner());
584        let list = pool.entry(key).or_default();
585        list.retain(|i| !i.sender.is_closed() && i.since.elapsed() < self.idle_timeout);
586        if list.len() < self.max_idle_per_host {
587            list.push(Idle {
588                sender,
589                since: Instant::now(),
590            });
591        }
592    }
593
594    async fn connect(&self, key: &PoolKey) -> Result<Sender, ClientError> {
595        #[cfg(not(feature = "client-tls"))]
596        if key.https {
597            return Err(ClientError::Tls(
598                "https needs the `client-tls` feature of ullun-api".into(),
599            ));
600        }
601        let connect = async {
602            let addrs: Vec<SocketAddr> = tokio::net::lookup_host((key.host.as_str(), key.port))
603                .await
604                .map_err(|e| ClientError::Connect(e.to_string()))?
605                .collect();
606            if addrs.is_empty() {
607                return Err(ClientError::Connect("name resolution failed".into()));
608            }
609            // every answer must be public, not just the one used, and we connect
610            // to the vetted address itself (no second lookup to rebind)
611            if self.public_only {
612                if let Some(bad) = addrs
613                    .iter()
614                    .find(|a| !crate::security::is_public_ip(a.ip()))
615                {
616                    return Err(ClientError::Blocked(bad.ip()));
617                }
618            }
619            // try the addresses in order: "localhost" may list ::1 first
620            let mut last = String::new();
621            let mut connected = None;
622            for addr in addrs {
623                match TcpStream::connect(addr).await {
624                    Ok(tcp) => {
625                        connected = Some(tcp);
626                        break;
627                    }
628                    Err(e) => last = e.to_string(),
629                }
630            }
631            let tcp = connected.ok_or(ClientError::Connect(last))?;
632            let _ = tcp.set_nodelay(true);
633            let io: Box<dyn Io> = if key.https {
634                self.tls(tcp, &key.host).await?
635            } else {
636                Box::new(tcp)
637            };
638            let (sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
639                .await
640                .map_err(|e| ClientError::Protocol(e.to_string()))?;
641            tokio::spawn(async move {
642                let _ = conn.await;
643            });
644            Ok::<_, ClientError>(sender)
645        };
646        tokio::time::timeout(self.connect_timeout, connect)
647            .await
648            .map_err(|_| ClientError::Timeout)?
649    }
650
651    #[cfg(feature = "client-tls")]
652    async fn tls(&self, tcp: TcpStream, host: &str) -> Result<Box<dyn Io>, ClientError> {
653        let name = tokio_rustls::rustls::pki_types::ServerName::try_from(host.to_string())
654            .map_err(|_| ClientError::Tls(format!("invalid server name {host}")))?;
655        let stream = self
656            .shared
657            .tls
658            .connect(name, tcp)
659            .await
660            .map_err(|e| ClientError::Tls(e.to_string()))?;
661        Ok(Box::new(stream))
662    }
663
664    #[cfg(not(feature = "client-tls"))]
665    async fn tls(&self, _tcp: TcpStream, _host: &str) -> Result<Box<dyn Io>, ClientError> {
666        Err(ClientError::Tls(
667            "https needs the `client-tls` feature of ullun-api".into(),
668        ))
669    }
670}
671
672#[cfg(feature = "client-tls")]
673fn tls_connector(extra_pem: &[&[u8]]) -> Result<tokio_rustls::TlsConnector, ClientError> {
674    use tokio_rustls::rustls::pki_types::pem::PemObject;
675    use tokio_rustls::rustls::pki_types::CertificateDer;
676    use tokio_rustls::rustls::{ClientConfig, RootCertStore};
677
678    let mut roots = RootCertStore::empty();
679    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
680    for pem in extra_pem {
681        let mut added = 0;
682        for cert in CertificateDer::pem_slice_iter(pem) {
683            let cert = cert.map_err(|e| ClientError::Tls(e.to_string()))?;
684            roots
685                .add(cert)
686                .map_err(|e| ClientError::Tls(e.to_string()))?;
687            added += 1;
688        }
689        if added == 0 {
690            return Err(ClientError::Tls("no certificate in the PEM data".into()));
691        }
692    }
693    let mut config = ClientConfig::builder_with_provider(Arc::new(
694        tokio_rustls::rustls::crypto::ring::default_provider(),
695    ))
696    .with_safe_default_protocol_versions()
697    .map_err(|e| ClientError::Tls(e.to_string()))?
698    .with_root_certificates(roots)
699    .with_no_client_auth();
700    config.alpn_protocols = vec![b"http/1.1".to_vec()];
701    Ok(tokio_rustls::TlsConnector::from(Arc::new(config)))
702}
703
704async fn read_limited(mut body: Incoming, max: usize) -> Result<Bytes, ClientError> {
705    use bytes::BufMut;
706    use hyper::body::Body;
707    if body.size_hint().lower() > max as u64 {
708        return Err(ClientError::TooLarge(max));
709    }
710    let mut out = bytes::BytesMut::new();
711    while let Some(frame) = body.frame().await {
712        let frame = frame.map_err(|e| ClientError::Protocol(e.to_string()))?;
713        if let Some(data) = frame.data_ref() {
714            if out.len() + data.len() > max {
715                return Err(ClientError::TooLarge(max));
716            }
717            out.put_slice(data);
718        }
719    }
720    Ok(out.freeze())
721}
722
723/// (https, host, port) of an absolute `http(s)` URL
724fn target(uri: &Uri) -> Result<(bool, String, u16), ClientError> {
725    let https = match uri.scheme_str() {
726        Some("http") => false,
727        Some("https") => true,
728        _ => return Err(ClientError::InvalidUrl(uri.to_string())),
729    };
730    let host = uri
731        .host()
732        .filter(|h| !h.is_empty())
733        .ok_or_else(|| ClientError::InvalidUrl(uri.to_string()))?;
734    // IPv6 literals come bracketed
735    let host = host
736        .trim_start_matches('[')
737        .trim_end_matches(']')
738        .to_string();
739    let port = uri.port_u16().unwrap_or(if https { 443 } else { 80 });
740    Ok((https, host, port))
741}
742
743fn origin_of(url: &str) -> Result<(bool, String, u16), ClientError> {
744    let uri: Uri = url
745        .parse()
746        .map_err(|_| ClientError::InvalidUrl(url.to_string()))?;
747    target(&uri)
748}
749
750/// Absolute URL for a (possibly relative) `Location` value
751fn resolve_location(base: &Uri, location: &str) -> Option<String> {
752    if location.starts_with("http://") || location.starts_with("https://") {
753        return Some(location.to_string());
754    }
755    let scheme = base.scheme_str()?;
756    let authority = base.authority()?;
757    if location.starts_with('/') {
758        return Some(format!("{scheme}://{authority}{location}"));
759    }
760    // relative to the directory of the current path
761    let dir = base.path().rsplit_once('/').map(|(d, _)| d).unwrap_or("");
762    Some(format!("{scheme}://{authority}{dir}/{location}"))
763}
764
765/// What the server answered
766#[derive(Debug, Clone)]
767pub struct ClientResponse {
768    status: StatusCode,
769    headers: HeaderMap,
770    body: Bytes,
771}
772
773impl ClientResponse {
774    pub fn status(&self) -> u16 {
775        self.status.as_u16()
776    }
777
778    pub fn is_success(&self) -> bool {
779        self.status.is_success()
780    }
781
782    pub fn headers(&self) -> &HeaderMap {
783        &self.headers
784    }
785
786    /// Header value, empty when absent or not text
787    pub fn header(&self, name: &str) -> &str {
788        self.headers
789            .get(name)
790            .and_then(|v| v.to_str().ok())
791            .unwrap_or("")
792    }
793
794    pub fn bytes(&self) -> &Bytes {
795        &self.body
796    }
797
798    pub fn text(&self) -> Result<String, ClientError> {
799        String::from_utf8(self.body.to_vec()).map_err(|e| ClientError::Decode(e.to_string()))
800    }
801
802    pub fn json<T: DeserializeOwned>(&self) -> Result<T, ClientError> {
803        serde_json::from_slice(&self.body).map_err(|e| ClientError::Decode(e.to_string()))
804    }
805
806    /// Seconds from a numeric `Retry-After` header
807    fn retry_after(&self) -> Option<Duration> {
808        self.header("retry-after")
809            .trim()
810            .parse::<u64>()
811            .ok()
812            .map(Duration::from_secs)
813    }
814}