Skip to main content

nym_http_api_client/
lib.rs

1// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(deprecated)]
5// silences clippy warning: use of deprecated tuple variant `HttpClientError::GenericRequestFailure`: use another more strongly typed variant - this variant is only left for compatibility reasons - TODO
6
7//! Nym HTTP API Client
8//!
9//! Centralizes and implements the core API client functionality. This crate provides custom,
10//! configurable middleware for a re-usable HTTP client that takes advantage of connection pooling
11//! and other benefits provided by the [`reqwest`] `Client`.
12//!
13//! ## Making GET requests
14//!
15//! Create an HTTP `Client` and use it to make a GET request.
16//!
17//! ```rust
18//! # use url::Url;
19//! # use nym_http_api_client::{ApiClient, NO_PARAMS, HttpClientError};
20//!
21//! # type Err = HttpClientError;
22//! # async fn run() -> Result<(), Err> {
23//! let url: Url = "https://nymvpn.com".parse()?;
24//! let client = nym_http_api_client::Client::new(url, None);
25//!
26//! // Send a get request to the `/v1/status` path with no query parameters.
27//! let resp = client.send_get_request(&["v1", "status"], NO_PARAMS).await?;
28//! let body = resp.text().await?;
29//!
30//! println!("body = {body:?}");
31//! # Ok(())
32//! # }
33//! ```
34//!
35//! ## JSON
36//!
37//! There are also json helper methods that assist in executing requests that send or receive json.
38//! It can take any value that can be serialized into JSON.
39//!
40//! ```rust
41//! # use std::collections::HashMap;
42//! # use std::time::Duration;
43//! use nym_http_api_client::{ApiClient, HttpClientError, NO_PARAMS};
44//!
45//! # use serde::{Serialize, Deserialize};
46//! #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
47//! pub struct ApiHealthResponse {
48//!     pub status: ApiStatus,
49//!     pub uptime: u64,
50//! }
51//!
52//! #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
53//! pub enum ApiStatus {
54//!     Up,
55//! }
56//!
57//! # type Err = HttpClientError;
58//! # async fn run() -> Result<(), Err> {
59//! // This will POST a body of `{"lang":"rust","body":"json"}`
60//! let mut map = HashMap::new();
61//! map.insert("lang", "rust");
62//! map.insert("body", "json");
63//!
64//! // Create a client using the ClientBuilder and set a custom timeout.
65//! let client = nym_http_api_client::Client::builder("https://nymvpn.com")?
66//!     .with_timeout(Duration::from_secs(10))
67//!     .build()?;
68//!
69//! // Send a POST request with our json `map` as the body and attempt to parse the body
70//! // of the response as an ApiHealthResponse from json.
71//! let res: ApiHealthResponse = client.post_json(&["v1", "status"], NO_PARAMS, &map)
72//!     .await?;
73//! # Ok(())
74//! # }
75//! ```
76//!
77//! ## Creating an ApiClient Wrapper
78//!
79//! An example API implementation that relies on this crate for managing the HTTP client.
80//!
81//! ```rust
82//! # use async_trait::async_trait;
83//! use nym_http_api_client::{ApiClient, HttpClientError, NO_PARAMS};
84//!
85//! mod routes {
86//!     pub const API_VERSION: &str = "v1";
87//!     pub const API_STATUS_ROUTES: &str = "api-status";
88//!     pub const HEALTH: &str = "health";
89//! }
90//!
91//! mod responses {
92//!     # use serde::{Serialize, Deserialize};
93//!     #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
94//!     pub struct ApiHealthResponse {
95//!         pub status: ApiStatus,
96//!         pub uptime: u64,
97//!     }
98//!
99//!     #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
100//!     pub enum ApiStatus {
101//!         Up,
102//!     }
103//! }
104//!
105//! mod error {
106//!     # use serde::{Serialize, Deserialize};
107//!     # use core::fmt::{Display, Formatter, Result as FmtResult};
108//!     #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
109//!     pub struct RequestError {
110//!         message: String,
111//!     }
112//!
113//!     impl Display for RequestError {
114//!         fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
115//!             Display::fmt(&self.message, f)
116//!         }
117//!     }
118//! }
119//!
120//! pub type SpecificAPIError = HttpClientError;
121//!
122//! #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
123//! #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
124//! pub trait SpecificApi: ApiClient {
125//!     async fn health(&self) -> Result<responses::ApiHealthResponse, SpecificAPIError> {
126//!         self.get_json(
127//!             &[
128//!                 routes::API_VERSION,
129//!                 routes::API_STATUS_ROUTES,
130//!                 routes::HEALTH,
131//!             ],
132//!             NO_PARAMS,
133//!         )
134//!         .await
135//!     }
136//! }
137//!
138//! impl<T: ApiClient> SpecificApi for T {}
139//! ```
140#![warn(missing_docs)]
141
142use http::header::USER_AGENT;
143pub use inventory;
144pub use reqwest::{self, ClientBuilder as ReqwestClientBuilder, StatusCode};
145use std::error::Error;
146use std::sync::Mutex;
147use std::time::Instant;
148
149pub mod registry;
150
151use crate::path::RequestPath;
152use async_trait::async_trait;
153use bytes::Bytes;
154use cfg_if::cfg_if;
155use http::{
156    HeaderMap,
157    header::{ACCEPT, CONTENT_TYPE},
158};
159use itertools::Itertools;
160use mime::Mime;
161use reqwest::{RequestBuilder, Response, header::HeaderValue};
162use serde::{Deserialize, Serialize, de::DeserializeOwned};
163#[cfg(not(target_arch = "wasm32"))]
164use std::io::ErrorKind;
165use std::{
166    fmt::Display,
167    sync::atomic::{AtomicUsize, Ordering},
168    time::Duration,
169};
170use thiserror::Error;
171use tracing::{debug, instrument, warn};
172
173use std::sync::{Arc, LazyLock};
174
175#[cfg(feature = "tunneling")]
176mod fronted;
177#[cfg(feature = "tunneling")]
178pub use fronted::FrontPolicy;
179mod url;
180pub use url::{IntoUrl, Url};
181mod user_agent;
182pub use user_agent::UserAgent;
183
184#[cfg(not(target_arch = "wasm32"))]
185pub mod dns;
186mod path;
187
188#[cfg(not(target_arch = "wasm32"))]
189pub use dns::{HickoryDnsResolver, ResolveError};
190
191// helper for generating user agent based on binary information
192#[cfg(not(target_arch = "wasm32"))]
193use crate::registry::default_builder;
194#[doc(hidden)]
195pub use nym_bin_common::bin_info;
196#[cfg(not(target_arch = "wasm32"))]
197use nym_http_api_client_macro::client_defaults;
198
199/// Default HTTP request connection timeout.
200///
201/// The timeout is relatively high as we are often making requests over the mixnet, where latency is
202/// high and chatty protocols take a while to complete.
203pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
204
205const NYM_OUTER_SNI_HEADER: &str = "NYM-ORIGINAL-OUTER-SNI";
206
207#[cfg(not(target_arch = "wasm32"))]
208client_defaults!(
209    priority = -100;
210    gzip = true,
211    deflate = true,
212    brotli = true,
213    zstd = true,
214    timeout = DEFAULT_TIMEOUT,
215    user_agent = format!("nym-http-api-client/{}", env!("CARGO_PKG_VERSION"))
216);
217
218static SHARED_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
219    tracing::info!("Initializing shared HTTP client");
220    cfg_if! {
221        if #[cfg(target_arch = "wasm32")] {
222            reqwest::ClientBuilder::new().build()
223                .expect("failed to initialize shared http client")
224        } else {
225            let mut builder = default_builder();
226
227            builder = builder.dns_resolver(Arc::new(HickoryDnsResolver::default()));
228
229            builder
230                .build()
231                .expect("failed to initialize shared http client")
232        }
233    }
234});
235
236pub(crate) static SHARED_NETWORK_RECONFIGURATION: LazyLock<Arc<Mutex<Option<Instant>>>> =
237    LazyLock::new(|| Arc::new(Mutex::new(None)));
238
239/// Indicate to the shared marker that a network reconfiguration happened.
240pub fn network_reconfigured() {
241    *SHARED_NETWORK_RECONFIGURATION.lock().unwrap() = Some(Instant::now());
242}
243
244/// Collection of URL Path Segments
245pub type PathSegments<'a> = &'a [&'a str];
246/// Collection of HTTP Request Parameters
247pub type Params<'a, K, V> = &'a [(K, V)];
248
249/// Empty collection of HTTP Request Parameters.
250pub const NO_PARAMS: Params<'_, &'_ str, &'_ str> = &[];
251
252/// Serialization format for API requests and responses
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum SerializationFormat {
255    /// Use JSON serialization (default, always works)
256    Json,
257    /// Use bincode serialization (must be explicitly opted into)
258    Bincode,
259    /// Use YAML serialization
260    Yaml,
261    /// Use Text serialization
262    Text,
263}
264
265impl Display for SerializationFormat {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        match self {
268            SerializationFormat::Json => write!(f, "json"),
269            SerializationFormat::Bincode => write!(f, "bincode"),
270            SerializationFormat::Yaml => write!(f, "yaml"),
271            SerializationFormat::Text => write!(f, "text"),
272        }
273    }
274}
275
276impl SerializationFormat {
277    #[allow(missing_docs)]
278    pub fn content_type(&self) -> String {
279        match self {
280            SerializationFormat::Json => "application/json".to_string(),
281            SerializationFormat::Bincode => "application/bincode".to_string(),
282            SerializationFormat::Yaml => "application/yaml".to_string(),
283            SerializationFormat::Text => "text/plain".to_string(),
284        }
285    }
286}
287
288#[allow(missing_docs)]
289#[derive(Debug)]
290pub struct ReqwestErrorWrapper(reqwest::Error);
291
292impl Display for ReqwestErrorWrapper {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        cfg_if::cfg_if! {
295            if #[cfg(not(target_arch = "wasm32"))] {
296                if self.0.is_connect() {
297                    write!(f, "failed to connect: ")?;
298                }
299            }
300        }
301
302        if self.0.is_timeout() {
303            write!(f, "timed out: ")?;
304        }
305        if self.0.is_redirect()
306            && let Some(final_stop) = self.0.url()
307        {
308            write!(f, "redirect loop at {final_stop}: ")?;
309        }
310
311        self.0.fmt(f)?;
312        if let Some(status_code) = self.0.status() {
313            write!(f, " status: {status_code}")?;
314        } else {
315            write!(f, " unknown status code")?;
316        }
317
318        if let Some(source) = self.0.source() {
319            write!(f, " source: {source:?}")?;
320        } else {
321            write!(f, " unknown lower-level error source")?;
322        }
323
324        Ok(())
325    }
326}
327
328impl std::error::Error for ReqwestErrorWrapper {}
329
330/// The Errors that may occur when creating or using an HTTP client.
331#[derive(Debug, Error)]
332#[allow(missing_docs)]
333pub enum HttpClientError {
334    #[error("did not provide any valid client URLs")]
335    NoUrlsProvided,
336
337    #[error("failed to construct inner reqwest client: {source}")]
338    ReqwestBuildError {
339        #[source]
340        source: reqwest::Error,
341    },
342
343    #[deprecated(
344        note = "use another more strongly typed variant - this variant is only left for compatibility reasons"
345    )]
346    #[error("request failed with error message: {0}")]
347    GenericRequestFailure(String),
348
349    #[deprecated(
350        note = "use another more strongly typed variant - this variant is only left for compatibility reasons"
351    )]
352    #[error("there was an issue with the REST request: {source}")]
353    ReqwestClientError {
354        #[from]
355        source: reqwest::Error,
356    },
357
358    #[error("failed to parse {raw} as a valid URL: {source}")]
359    MalformedUrl {
360        raw: String,
361        #[source]
362        source: reqwest::Error,
363    },
364
365    #[error("failed to parse header value: {source}")]
366    InvalidHeaderValue {
367        #[source]
368        source: http::Error,
369    },
370
371    #[error("failed to send request for {url}: {source}")]
372    RequestSendFailure {
373        url: Box<reqwest::Url>,
374        #[source]
375        source: ReqwestErrorWrapper,
376    },
377
378    #[error("failed to read response body from {url}: {source}")]
379    ResponseReadFailure {
380        url: Box<reqwest::Url>,
381        headers: Box<HeaderMap>,
382        status: StatusCode,
383        #[source]
384        source: ReqwestErrorWrapper,
385    },
386
387    #[error("failed to deserialize received response: {source}")]
388    ResponseDeserialisationFailure { source: serde_json::Error },
389
390    #[error("provided url is malformed: {source}")]
391    UrlParseFailure {
392        #[from]
393        source: url::ParseError,
394    },
395
396    #[error("the requested resource could not be found at {url}")]
397    NotFound { url: Box<reqwest::Url> },
398
399    #[error("attempted to use domain fronting and clone a request containing stream data")]
400    AttemptedToCloneStreamRequest,
401
402    // #[error("request failed with error message: {0}")]
403    // GenericRequestFailure(String),
404    //
405    #[error(
406        "the request for {url} failed with status '{status}'. no additional error message provided. response headers: {headers:?}"
407    )]
408    RequestFailure {
409        url: Box<reqwest::Url>,
410        status: StatusCode,
411        headers: Box<HeaderMap>,
412    },
413
414    #[error(
415        "the returned response from {url} was empty. status: '{status}'. response headers: {headers:?}"
416    )]
417    EmptyResponse {
418        url: Box<reqwest::Url>,
419        status: StatusCode,
420        headers: Box<HeaderMap>,
421    },
422
423    #[error(
424        "failed to resolve request for {url}. status: '{status}'. response headers: {headers:?}. additional error message: {error}"
425    )]
426    EndpointFailure {
427        url: Box<reqwest::Url>,
428        status: StatusCode,
429        headers: Box<HeaderMap>,
430        error: String,
431    },
432
433    #[error("failed to decode response body: {message} from {content}")]
434    ResponseDecodeFailure { message: String, content: String },
435
436    #[error("failed to resolve request to {url} due to data inconsistency: {details}")]
437    InternalResponseInconsistency { url: ::url::Url, details: String },
438
439    #[cfg(not(target_arch = "wasm32"))]
440    #[error("encountered dns failure: {inner}")]
441    DnsLookupFailure {
442        #[from]
443        inner: ResolveError,
444    },
445
446    #[error("Failed to encode bincode: {0}")]
447    Bincode(#[from] bincode::Error),
448
449    #[error("Failed to json: {0}")]
450    Json(#[from] serde_json::Error),
451
452    #[error("Failed to yaml: {0}")]
453    Yaml(#[from] serde_yaml::Error),
454
455    #[error("Failed to plain: {0}")]
456    Plain(#[from] serde_plain::Error),
457
458    #[cfg(target_arch = "wasm32")]
459    #[error("the request has timed out")]
460    RequestTimeout,
461}
462
463#[allow(missing_docs)]
464#[allow(deprecated)]
465impl HttpClientError {
466    /// Returns true if the error is a timeout.
467    pub fn is_timeout(&self) -> bool {
468        match self {
469            HttpClientError::ReqwestClientError { source } => source.is_timeout(),
470            HttpClientError::RequestSendFailure { source, .. } => source.0.is_timeout(),
471            HttpClientError::ResponseReadFailure { source, .. } => source.0.is_timeout(),
472            #[cfg(not(target_arch = "wasm32"))]
473            HttpClientError::DnsLookupFailure { inner } => inner.is_timeout(),
474            #[cfg(target_arch = "wasm32")]
475            HttpClientError::RequestTimeout => true,
476            _ => false,
477        }
478    }
479
480    /// Returns the HTTP status code if available.
481    pub fn status_code(&self) -> Option<StatusCode> {
482        match self {
483            HttpClientError::ResponseReadFailure { status, .. } => Some(*status),
484            HttpClientError::RequestFailure { status, .. } => Some(*status),
485            HttpClientError::EmptyResponse { status, .. } => Some(*status),
486            HttpClientError::EndpointFailure { status, .. } => Some(*status),
487            _ => None,
488        }
489    }
490
491    pub fn reqwest_client_build_error(source: reqwest::Error) -> Self {
492        HttpClientError::ReqwestBuildError { source }
493    }
494
495    pub fn request_send_error(url: reqwest::Url, source: reqwest::Error) -> Self {
496        HttpClientError::RequestSendFailure {
497            url: Box::new(url),
498            source: ReqwestErrorWrapper(source),
499        }
500    }
501
502    pub fn is_data_inconsistency(&self) -> bool {
503        matches!(self, HttpClientError::InternalResponseInconsistency { .. })
504    }
505}
506
507/// Core functionality required for types acting as API clients.
508///
509/// This trait defines the "skinny waist" of behaviors that are required by an API client. More
510/// likely downstream libraries should use functions from the [`ApiClient`] interface which provide
511/// a more ergonomic set of functionalities.
512#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
513#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
514pub trait ApiClientCore {
515    /// Create an HTTP request using the host configured in this client.
516    fn create_request<P, B, K, V>(
517        &self,
518        method: reqwest::Method,
519        path: P,
520        params: Params<'_, K, V>,
521        body: Option<&B>,
522    ) -> Result<RequestBuilder, HttpClientError>
523    where
524        P: RequestPath,
525        B: Serialize + ?Sized,
526        K: AsRef<str>,
527        V: AsRef<str>;
528
529    /// Create an HTTP request using the host configured in this client and an API endpoint (i.e.
530    /// `"/api/v1/mixnodes?since=12345"`). If the provided endpoint fails to parse as path (and
531    /// optionally query parameters).
532    ///
533    /// Endpoint Examples
534    /// - `"/api/v1/mixnodes?since=12345"`
535    /// - `"/api/v1/mixnodes"`
536    /// - `"/api/v1/mixnodes/img.png"`
537    /// - `"/api/v1/mixnodes/img.png?since=12345"`
538    /// - `"/"`
539    /// - `"/?since=12345"`
540    /// - `""`
541    /// - `"?since=12345"`
542    ///
543    /// for more information about URL percent encodings see [`url::Url::set_path()`]
544    fn create_request_endpoint<B, S>(
545        &self,
546        method: reqwest::Method,
547        endpoint: S,
548        body: Option<&B>,
549    ) -> Result<RequestBuilder, HttpClientError>
550    where
551        B: Serialize + ?Sized,
552        S: AsRef<str>,
553    {
554        // Use a stand-in url to extract the path and queries from the provided endpoint string
555        // which could potentially fail.
556        //
557        // This parse cannot fail
558        let mut standin_url: Url = "http://example.com".parse().unwrap();
559
560        match endpoint.as_ref().split_once("?") {
561            Some((path, query)) => {
562                standin_url.set_path(path);
563                standin_url.set_query(Some(query));
564            }
565            // There is no query in the provided endpoint
566            None => standin_url.set_path(endpoint.as_ref()),
567        }
568
569        let path: Vec<&str> = match standin_url.path_segments() {
570            Some(segments) => segments.collect(),
571            None => Vec::new(),
572        };
573        let params: Vec<(String, String)> = standin_url.query_pairs().into_owned().collect();
574
575        self.create_request(method, path.as_slice(), &params, body)
576    }
577
578    /// Send a created HTTP request.
579    ///
580    /// A [`RequestBuilder`] can be created with [`ApiClientCore::create_request`] or
581    /// [`ApiClientCore::create_request_endpoint`] or if absolutely necessary, using reqwest
582    /// tooling directly.
583    async fn send(&self, request: RequestBuilder) -> Result<Response, HttpClientError>;
584
585    /// Create and send a created HTTP request.
586    async fn send_request<P, B, K, V>(
587        &self,
588        method: reqwest::Method,
589        path: P,
590        params: Params<'_, K, V>,
591        json_body: Option<&B>,
592    ) -> Result<Response, HttpClientError>
593    where
594        P: RequestPath + Send + Sync,
595        B: Serialize + ?Sized + Sync,
596        K: AsRef<str> + Sync,
597        V: AsRef<str> + Sync,
598    {
599        let req = self.create_request(method, path, params, json_body)?;
600        self.send(req).await
601    }
602
603    /// If multiple base urls are available rotate to next (e.g. when the current one resulted in an error)
604    ///
605    /// Takes an optional URL argument. If this is none, the current host will be updated automatically.
606    /// If a url is provided first check that the CURRENT host matches the hostname in the URL before
607    /// triggering a rotation. This is meant to prevent parallel requests that fail from rotating the host
608    /// multiple times.
609    fn maybe_rotate_hosts(&self, offending_url: Option<Url>);
610
611    /// If the fronting policy for the client is set to `OnRetry` this function will enable the
612    /// fronting if not already enabled.
613    #[cfg(feature = "tunneling")]
614    fn maybe_enable_fronting(&self, context: impl std::fmt::Debug);
615}
616
617/// A `ClientBuilder` can be used to create a [`Client`] with custom configuration applied consistently
618/// and state tracked across subsequent requests.
619pub struct ClientBuilder {
620    urls: Vec<Url>,
621
622    timeout: Option<Duration>,
623    custom_user_agent: Option<HeaderValue>,
624    reqwest_client_builder: Option<reqwest::ClientBuilder>,
625    #[allow(dead_code)] // not dead code, just unused in wasm
626    use_secure_dns: bool,
627
628    #[cfg(feature = "tunneling")]
629    front: fronted::Front,
630
631    retry_limit: usize,
632    serialization: SerializationFormat,
633
634    error: Option<HttpClientError>,
635}
636
637impl ClientBuilder {
638    /// Constructs a new `ClientBuilder`.
639    ///
640    /// This is the same as `Client::builder()`.
641    pub fn new<U>(url: U) -> Result<Self, HttpClientError>
642    where
643        U: IntoUrl,
644    {
645        let str_url = url.as_str();
646
647        // a naive check: if the provided URL does not start with http(s), add that scheme
648        if !str_url.starts_with("http") {
649            let alt = format!("http://{str_url}");
650            warn!(
651                "the provided url ('{str_url}') does not contain scheme information. Changing it to '{alt}' ..."
652            );
653            // TODO: or should we maybe default to https?
654            Self::new(alt)
655        } else {
656            let url = url.to_url()?;
657            Self::new_with_urls(vec![url])
658        }
659    }
660
661    /// Create a client builder from network details with sensible defaults
662    #[cfg(feature = "network-defaults")]
663    // deprecating function since it's not clear from its signature whether the client
664    // would be constructed using `nym_api_urls` or `nym_vpn_api_urls`
665    #[deprecated(note = "use explicit Self::new_with_fronted_urls instead")]
666    pub fn from_network(
667        network: &nym_network_defaults::NymNetworkDetails,
668    ) -> Result<Self, HttpClientError> {
669        let urls = network.nym_api_urls();
670        Self::new_with_fronted_urls(urls.clone())
671    }
672
673    /// Create a client builder using the provided set of domain-fronted URLs
674    #[cfg(feature = "network-defaults")]
675    pub fn new_with_fronted_urls(
676        urls: Vec<nym_network_defaults::ApiUrl>,
677    ) -> Result<Self, HttpClientError> {
678        let urls = urls
679            .into_iter()
680            .map(|api_url| {
681                // Convert ApiUrl to our Url type with fronting support
682                let mut url = Url::parse(&api_url.url)?;
683
684                // Add fronting domains if available
685                #[cfg(feature = "tunneling")]
686                if let Some(ref front_hosts) = api_url.front_hosts {
687                    let fronts: Vec<String> = front_hosts
688                        .iter()
689                        .map(|host| format!("https://{}", host))
690                        .collect();
691                    url = Url::new(api_url.url.clone(), Some(fronts)).map_err(|source| {
692                        HttpClientError::MalformedUrl {
693                            raw: api_url.url.clone(),
694                            source,
695                        }
696                    })?;
697                }
698
699                Ok(url)
700            })
701            .collect::<Result<Vec<_>, HttpClientError>>()?;
702
703        let mut builder = Self::new_with_urls(urls)?;
704
705        // Enable domain fronting using the shared fronting policy
706        #[cfg(feature = "tunneling")]
707        {
708            builder = builder.with_fronting(None);
709        }
710
711        Ok(builder)
712    }
713
714    /// Constructs a new http `ClientBuilder` from a valid url.
715    pub fn new_with_urls(urls: Vec<Url>) -> Result<Self, HttpClientError> {
716        if urls.is_empty() {
717            return Err(HttpClientError::NoUrlsProvided);
718        }
719
720        let urls = Self::check_urls(urls);
721
722        Ok(ClientBuilder {
723            urls,
724            timeout: None,
725            custom_user_agent: None,
726            reqwest_client_builder: None,
727            use_secure_dns: true,
728            #[cfg(feature = "tunneling")]
729            front: fronted::Front::off(),
730
731            retry_limit: 0,
732            serialization: SerializationFormat::Json,
733            error: None,
734        })
735    }
736
737    /// Configure use of an independent HTTP request executor. This prevents use of beneficial
738    /// features like connection pooling under the hood.
739    #[cfg(not(target_arch = "wasm32"))]
740    pub fn non_shared(mut self) -> Self {
741        if self.reqwest_client_builder.is_none() {
742            self.reqwest_client_builder = Some(default_builder());
743        }
744        self
745    }
746
747    /// Add an additional URL to the set usable by this constructed `Client`
748    pub fn add_url(mut self, url: Url) -> Self {
749        self.urls.push(url);
750        self
751    }
752
753    fn check_urls(mut urls: Vec<Url>) -> Vec<Url> {
754        // remove any duplicate URLs
755        urls = urls.into_iter().unique().collect();
756
757        // warn about any invalid URLs
758        urls.iter()
759            .filter(|url| !url.scheme().contains("http") && !url.scheme().contains("https"))
760            .for_each(|url| {
761                warn!("the provided url ('{url}') does not use HTTP / HTTPS scheme");
762            });
763
764        urls
765    }
766
767    /// Enables a total request timeout other than the default.
768    ///
769    /// The timeout is applied from when the request starts connecting until the response body has finished. Also considered a total deadline.
770    ///
771    /// Default is [`DEFAULT_TIMEOUT`].
772    pub fn with_timeout(mut self, timeout: Duration) -> Self {
773        self.timeout = Some(timeout);
774        self
775    }
776
777    /// Sets the maximum number of retries for a request. This defaults to 0, indicating no retries.
778    ///
779    /// Note that setting a retry limit of 3 (for example) will result in 4 attempts to send the
780    /// request in the case that all are unsuccessful.
781    ///
782    /// If multiple urls (or fronting configurations if enabled) are available, retried requests
783    /// will be sent to the next URL in the list.
784    pub fn with_retries(mut self, retry_limit: usize) -> Self {
785        self.retry_limit = retry_limit;
786        self
787    }
788
789    /// Provide a pre-configured [`reqwest::ClientBuilder`]
790    pub fn with_reqwest_builder(mut self, reqwest_builder: reqwest::ClientBuilder) -> Self {
791        self.reqwest_client_builder = Some(reqwest_builder);
792        self
793    }
794
795    /// Sets the `User-Agent` header to be used by this client.
796    pub fn with_user_agent<V>(mut self, value: V) -> Self
797    where
798        V: TryInto<HeaderValue>,
799        V::Error: Into<http::Error>,
800    {
801        match value.try_into() {
802            Ok(v) => self.custom_user_agent = Some(v),
803            Err(err) => {
804                self.error = Some(HttpClientError::InvalidHeaderValue { source: err.into() })
805            }
806        }
807        self
808    }
809
810    /// Set the serialization format for API requests and responses
811    pub fn with_serialization(mut self, format: SerializationFormat) -> Self {
812        self.serialization = format;
813        self
814    }
815
816    /// Configure the client to use bincode serialization
817    pub fn with_bincode(self) -> Self {
818        self.with_serialization(SerializationFormat::Bincode)
819    }
820
821    /// Returns a Client that uses this ClientBuilder configuration.
822    pub fn build(self) -> Result<Client, HttpClientError> {
823        if let Some(err) = self.error {
824            return Err(err);
825        }
826
827        #[cfg(target_arch = "wasm32")]
828        let reqwest_client = Some(reqwest::ClientBuilder::new().build()?);
829
830        #[cfg(not(target_arch = "wasm32"))]
831        let reqwest_client = self
832            .reqwest_client_builder
833            .map(|mut builder| {
834                // unless explicitly disabled use the DoT/DoH enabled resolver
835                if self.use_secure_dns {
836                    builder = builder.dns_resolver(Arc::new(HickoryDnsResolver::default()));
837                }
838
839                builder
840                    .build()
841                    .map_err(HttpClientError::reqwest_client_build_error)
842            })
843            .transpose()?;
844
845        let client = Client {
846            base_urls: self.urls,
847            current_idx: Arc::new(AtomicUsize::new(0)),
848            reqwest_client,
849            custom_user_agent: self.custom_user_agent,
850
851            #[cfg(feature = "tunneling")]
852            front: self.front,
853
854            #[cfg(target_arch = "wasm32")]
855            request_timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
856            retry_limit: self.retry_limit,
857            serialization: self.serialization,
858        };
859
860        Ok(client)
861    }
862}
863
864/// A simple extendable client wrapper for http request with extra url sanitization.
865#[derive(Debug, Clone)]
866pub struct Client {
867    base_urls: Vec<Url>,
868    current_idx: Arc<AtomicUsize>,
869    reqwest_client: Option<reqwest::Client>,
870    custom_user_agent: Option<HeaderValue>,
871
872    #[cfg(feature = "tunneling")]
873    front: fronted::Front,
874
875    #[cfg(target_arch = "wasm32")]
876    request_timeout: Duration,
877
878    retry_limit: usize,
879    serialization: SerializationFormat,
880}
881
882impl Client {
883    /// Create a new http `Client`
884    // no timeout until https://github.com/seanmonstar/reqwest/issues/1135 is fixed
885    //
886    // In order to prevent interference in API requests at the DNS phase we default to a resolver
887    // that uses DoT and DoH.
888    pub fn new(base_url: ::url::Url, timeout: Option<Duration>) -> Self {
889        Self::new_url(base_url, timeout).expect(
890            "we provided valid url and we were unwrapping previous construction errors anyway",
891        )
892    }
893
894    /// Attempt to create a new http client from a something that can be converted to a URL
895    pub fn new_url<U>(url: U, timeout: Option<Duration>) -> Result<Self, HttpClientError>
896    where
897        U: IntoUrl,
898    {
899        let builder = Self::builder(url)?;
900        match timeout {
901            Some(timeout) => builder.with_timeout(timeout).build(),
902            None => builder.build(),
903        }
904    }
905
906    /// Creates a [`ClientBuilder`] to configure a [`Client`].
907    ///
908    /// This is the same as [`ClientBuilder::new()`].
909    pub fn builder<U>(url: U) -> Result<ClientBuilder, HttpClientError>
910    where
911        U: IntoUrl,
912    {
913        ClientBuilder::new(url)
914    }
915
916    /// Update the set of hosts that this client uses when sending API requests.
917    pub fn change_base_urls(&mut self, new_urls: Vec<Url>) {
918        self.current_idx.store(0, Ordering::Relaxed);
919        self.base_urls = new_urls
920    }
921
922    /// Create new instance of `Client` using the provided base url and existing client config
923    pub fn clone_with_new_url(&self, new_url: Url) -> Self {
924        Client {
925            base_urls: vec![new_url],
926            current_idx: Arc::new(Default::default()),
927            reqwest_client: None,
928            custom_user_agent: None,
929
930            #[cfg(feature = "tunneling")]
931            front: self.front.clone(),
932            retry_limit: self.retry_limit,
933
934            #[cfg(target_arch = "wasm32")]
935            request_timeout: self.request_timeout,
936            serialization: self.serialization,
937        }
938    }
939
940    /// Get the currently configured host that this client uses when sending API requests.
941    pub fn current_url(&self) -> &Url {
942        &self.base_urls[self.current_idx.load(std::sync::atomic::Ordering::Relaxed)]
943    }
944
945    /// Get the currently configured host that this client uses when sending API requests.
946    pub fn base_urls(&self) -> &[Url] {
947        &self.base_urls
948    }
949
950    /// Get a mutable reference to the hosts that this client uses when sending API requests.
951    pub fn base_urls_mut(&mut self) -> &mut [Url] {
952        &mut self.base_urls
953    }
954
955    /// Change the currently configured limit on the number of retries for a request.
956    pub fn change_retry_limit(&mut self, limit: usize) {
957        self.retry_limit = limit;
958    }
959
960    #[cfg(feature = "tunneling")]
961    fn matches_current_host(&self, url: &Url) -> bool {
962        // Only compare against the front host if the current url actually has one configured -
963        // otherwise requests to it go out unfronted, so the offending host will be the real one.
964        if self.front.is_enabled() && self.current_url().has_front() {
965            url.host_str() == self.current_url().front_str()
966        } else {
967            url.host_str() == self.current_url().host_str()
968        }
969    }
970
971    #[cfg(not(feature = "tunneling"))]
972    fn matches_current_host(&self, url: &Url) -> bool {
973        url.host_str() == self.current_url().host_str()
974    }
975
976    /// If multiple base urls are available rotate to next (e.g. when the current one resulted in an error)
977    ///
978    /// Takes an optional URL argument. If this is none, the current host will be updated automatically.
979    /// If a url is provided first check that the CURRENT host matches the hostname in the URL before
980    /// triggering a rotation. This is meant to prevent parallel requests that fail from rotating the host
981    /// multiple times.
982    fn update_host(&self, maybe_url: Option<Url>) {
983        // If a causal url is provided and it doesn't match the hostname currently in use, skip update.
984        if let Some(err_url) = maybe_url
985            && !self.matches_current_host(&err_url)
986        {
987            return;
988        }
989
990        #[cfg(feature = "tunneling")]
991        if self.front.is_enabled() {
992            // if we are using fronting, try updating to the next front
993            let url = self.current_url();
994
995            // try to update the current host to use a next front, if one is available, otherwise
996            // we move on and try the next base url (if one is available)
997            if url.has_front() && !url.update() {
998                // we swapped to the next front for the current host
999                return;
1000            }
1001        }
1002
1003        if self.base_urls.len() > 1 {
1004            let orig = self.current_idx.load(Ordering::Relaxed);
1005
1006            #[allow(unused_mut)]
1007            let mut next = (orig + 1) % self.base_urls.len();
1008
1009            // if fronting is enabled we want to update to a host that has fronts configured
1010            #[cfg(feature = "tunneling")]
1011            if self.front.is_enabled() {
1012                while next != orig {
1013                    if self.base_urls[next].has_front() {
1014                        // we have a front for the next host, so we can use it
1015                        break;
1016                    }
1017
1018                    next = (next + 1) % self.base_urls.len();
1019                }
1020            }
1021
1022            self.current_idx.store(next, Ordering::Relaxed);
1023            debug!(
1024                "http client rotating host {} -> {}",
1025                self.base_urls[orig], self.base_urls[next]
1026            );
1027        }
1028    }
1029
1030    /// Make modifications to the request to apply the current state of this client i.e. the
1031    /// currently configured host. This is required as a caller may use this client to create a
1032    /// request, but then have the state of the client change before the caller uses the client to
1033    /// send their request.
1034    ///
1035    /// This enures that the outgoing requests benefit from the configured fallback mechanisms, even
1036    /// for requests that were created before the state of the client changed.
1037    ///
1038    /// This method assumes that any updates to the state of the client are made before the call to
1039    /// this method. For example, if the client is configured to rotate hosts after each error, this
1040    /// method should be called after the host has been updated -- i.e. as part of the subsequent
1041    /// send.
1042    pub(crate) fn apply_hosts_to_req(&self, r: &mut reqwest::Request) -> (&str, Option<&str>) {
1043        let url = self.current_url();
1044        r.url_mut().set_host(url.host_str()).unwrap();
1045
1046        #[cfg(feature = "tunneling")]
1047        if self.front.is_enabled() {
1048            if let Some(front_host) = url.front_str() {
1049                if let Some(actual_host) = url.host_str() {
1050                    tracing::debug!(
1051                        "Domain fronting enabled: routing via CDN {} to actual host {}",
1052                        front_host,
1053                        actual_host
1054                    );
1055
1056                    // this should never fail as we are transplanting the host from one url to another
1057                    r.url_mut().set_host(Some(front_host)).unwrap();
1058
1059                    let actual_host_header: HeaderValue =
1060                        actual_host.parse().unwrap_or(HeaderValue::from_static(""));
1061                    // If the map did have this key present, the new value is associated with the key
1062                    // and all previous values are removed. (reqwest HeaderMap docs)
1063                    _ = r
1064                        .headers_mut()
1065                        .insert(reqwest::header::HOST, actual_host_header);
1066
1067                    // Set a custom header to capture the outer host (used in the SNI) of the request
1068                    let front_host_header: HeaderValue =
1069                        front_host.parse().unwrap_or(HeaderValue::from_static(""));
1070                    _ = r
1071                        .headers_mut()
1072                        .insert(NYM_OUTER_SNI_HEADER, front_host_header);
1073
1074                    return (url.as_str(), url.front_str());
1075                } else {
1076                    tracing::debug!(
1077                        "Domain fronting is enabled, but no host_url is defined for current URL"
1078                    )
1079                }
1080            } else {
1081                tracing::debug!(
1082                    "Domain fronting is enabled, but current URL has no front_hosts configured"
1083                )
1084            }
1085        }
1086        (url.as_str(), None)
1087    }
1088}
1089
1090#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1091#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1092impl ApiClientCore for Client {
1093    #[instrument(level = "debug", skip_all, fields(path=?path))]
1094    fn create_request<P, B, K, V>(
1095        &self,
1096        method: reqwest::Method,
1097        path: P,
1098        params: Params<'_, K, V>,
1099        body: Option<&B>,
1100    ) -> Result<RequestBuilder, HttpClientError>
1101    where
1102        P: RequestPath,
1103        B: Serialize + ?Sized,
1104        K: AsRef<str>,
1105        V: AsRef<str>,
1106    {
1107        let url = self.current_url();
1108        let url = sanitize_url(url, path, params);
1109
1110        let mut req = reqwest::Request::new(method, url.into());
1111
1112        self.apply_hosts_to_req(&mut req);
1113
1114        let client = if let Some(client) = &self.reqwest_client {
1115            client.clone()
1116        } else {
1117            SHARED_CLIENT.clone()
1118        };
1119        let mut rb = RequestBuilder::from_parts(client, req);
1120
1121        rb = rb
1122            .header(ACCEPT, self.serialization.content_type())
1123            .header(CONTENT_TYPE, self.serialization.content_type());
1124
1125        if let Some(user_agent) = &self.custom_user_agent {
1126            rb = rb.header(USER_AGENT, user_agent.clone());
1127        }
1128
1129        if let Some(body) = body {
1130            match self.serialization {
1131                SerializationFormat::Json => {
1132                    rb = rb.json(body);
1133                }
1134                SerializationFormat::Bincode => {
1135                    let body = bincode::serialize(body)?;
1136                    rb = rb.body(body);
1137                }
1138                SerializationFormat::Yaml => {
1139                    let mut body_bytes = Vec::new();
1140                    serde_yaml::to_writer(&mut body_bytes, &body)?;
1141                    rb = rb.body(body_bytes);
1142                }
1143                SerializationFormat::Text => {
1144                    let body = serde_plain::to_string(&body)?.as_bytes().to_vec();
1145                    rb = rb.body(body);
1146                }
1147            }
1148        }
1149
1150        Ok(rb)
1151    }
1152
1153    async fn send(&self, request: RequestBuilder) -> Result<Response, HttpClientError> {
1154        let mut attempts = 0;
1155        loop {
1156            // try_clone may fail if the body is a stream in which case using retries is not advised.
1157            let r = request
1158                .try_clone()
1159                .ok_or(HttpClientError::AttemptedToCloneStreamRequest)?;
1160
1161            // apply any changes based on the current state of the client wrt. hosts,
1162            // fronting domains, etc.
1163            let mut req = r
1164                .build()
1165                .map_err(HttpClientError::reqwest_client_build_error)?;
1166            self.apply_hosts_to_req(&mut req);
1167            let url: Url = req.url().clone().into();
1168
1169            let request_start = Instant::now();
1170
1171            #[cfg(target_arch = "wasm32")]
1172            let response: Result<Response, HttpClientError> = {
1173                let client = self
1174                    .reqwest_client
1175                    .as_ref()
1176                    .unwrap_or_else(|| &*SHARED_CLIENT);
1177                Ok(
1178                    wasmtimer::tokio::timeout(self.request_timeout, client.execute(req))
1179                        .await
1180                        .map_err(|_timeout| HttpClientError::RequestTimeout)??,
1181                )
1182            };
1183
1184            #[cfg(not(target_arch = "wasm32"))]
1185            let response = {
1186                let client = self
1187                    .reqwest_client
1188                    .as_ref()
1189                    .unwrap_or_else(|| &*SHARED_CLIENT);
1190                client.execute(req).await
1191            };
1192
1193            match response {
1194                Ok(resp) => {
1195                    // Check if the response includes a rate limit error from the vercel API
1196                    if is_http_rate_limit_err(&resp) {
1197                        warn!("encountered vercel rate limit error for {}", url.as_str());
1198                        // if we have multiple urls, update to the next
1199                        self.maybe_rotate_hosts(Some(url.clone()));
1200                    }
1201
1202                    return Ok(resp);
1203                }
1204                Err(err) => {
1205                    let last_network_reconfiguration =
1206                        *SHARED_NETWORK_RECONFIGURATION.lock().unwrap();
1207                    let network_reconfigured =
1208                        last_network_reconfiguration.is_some_and(|last| last > request_start);
1209
1210                    #[cfg(target_arch = "wasm32")]
1211                    let is_network_err = err.is_timeout();
1212                    #[cfg(not(target_arch = "wasm32"))]
1213                    let is_network_err = might_be_network_interference(&err);
1214
1215                    if is_network_err & !network_reconfigured {
1216                        // if we have multiple urls, update to the next
1217                        self.maybe_rotate_hosts(Some(url.clone()));
1218
1219                        #[cfg(feature = "tunneling")]
1220                        self.maybe_enable_fronting(("network", url.as_str(), &err));
1221                    }
1222
1223                    if attempts < self.retry_limit {
1224                        attempts += 1;
1225                        warn!(
1226                            "Retrying request due to http error on attempt ({attempts}/{}): {err}",
1227                            self.retry_limit
1228                        );
1229                        continue;
1230                    }
1231
1232                    // if we have exhausted our attempts, return the error
1233                    cfg_if::cfg_if! {
1234                        if #[cfg(target_arch = "wasm32")] {
1235                            return Err(err);
1236                        } else {
1237                            return Err(HttpClientError::request_send_error(url.into(), err));
1238                        }
1239                    }
1240                }
1241            }
1242        }
1243    }
1244
1245    fn maybe_rotate_hosts(&self, offending: Option<Url>) {
1246        self.update_host(offending);
1247    }
1248
1249    #[cfg(feature = "tunneling")]
1250    fn maybe_enable_fronting(&self, context: impl std::fmt::Debug) {
1251        // If fronting is set to be OnRetry, enable domain fronting as we
1252        // have encountered an error.
1253        let was_enabled = self.front.is_enabled();
1254        self.front.retry_enable();
1255        if !was_enabled && self.front.is_enabled() {
1256            tracing::debug!("Domain fronting activated after failure: {context:?}",);
1257        }
1258    }
1259}
1260
1261const VERCEL_CHALLENGE_HEADER: &str = "x-vercel-mitigated";
1262const VERCEL_CHALLENGE_VALUE: &[u8] = b"challenge";
1263
1264/// Check for Rate Limit challenge response from the vercel API
1265pub(crate) fn is_http_rate_limit_err(resp: &Response) -> bool {
1266    let status = resp.status() == StatusCode::FORBIDDEN;
1267    let header = resp
1268        .headers()
1269        .get(VERCEL_CHALLENGE_HEADER)
1270        .is_some_and(|v| v.as_bytes() == VERCEL_CHALLENGE_VALUE);
1271    let content_type = resp
1272        .headers()
1273        .get(CONTENT_TYPE)
1274        .and_then(|value| value.to_str().ok())
1275        .and_then(|value| value.parse::<Mime>().ok())
1276        .is_some_and(|mime_type| {
1277            mime_type.type_() == mime::TEXT && mime_type.subtype() == mime::HTML
1278        });
1279
1280    status && header && content_type
1281}
1282
1283#[cfg(not(target_arch = "wasm32"))]
1284const MAX_ERR_SOURCE_ITERATIONS: usize = 6;
1285
1286/// This functions attempts to check the error returned by reqwest to see if rotating host
1287/// information (for clients with multiple hosts defined) could be helpful. This looks for
1288/// situations where the error could plausibly be caused by a network adversary, or where rotating
1289/// to an equivalent hostname might help.
1290///
1291/// For example --> NetworkUnreachable will not be helped by rotating domains, but ConnectionReset
1292/// might be caused by a network adversary blocking by SNI which could possibly benefit from
1293/// rotating domains.
1294#[cfg(not(target_arch = "wasm32"))]
1295pub(crate) fn might_be_network_interference(err: &reqwest::Error) -> bool {
1296    if err.is_timeout() {
1297        return true;
1298    }
1299
1300    if !(err.is_connect() || err.is_request()) {
1301        return false;
1302    }
1303
1304    // The io::Error source is several layers deep, for clarity this is done as a loop
1305    // * reqwest::Error -> hyper_util::Error
1306    // * hyper_util::Error -> hyper_util::ClientError
1307    // * hyper_util::ClientError -> io::Error
1308    let mut inner = err.source();
1309    for _ in 0..MAX_ERR_SOURCE_ITERATIONS {
1310        if let Some(e) = inner {
1311            if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
1312                // try downcast to io::Error from <dyn std::error:Error>
1313                match io_err.kind() {
1314                    // device not connected to the internet
1315                    ErrorKind::NetworkUnreachable | ErrorKind::NetworkDown => return false,
1316                    // connection errors can indicate connection interference
1317                    ErrorKind::ConnectionReset
1318                    | ErrorKind::HostUnreachable
1319                    | ErrorKind::ConnectionRefused => return true,
1320                    // TLS errors get wrapped in custom io::Errors
1321                    ErrorKind::Other | ErrorKind::InvalidData => {
1322                        // io::Error get_ref works while source doesn't here -_-
1323                        //   if you don't like it take it up with the rust devs https://users.rust-lang.org/t/question-about-implementation-of-std-source/121117
1324                        inner = io_err.get_ref().map(|e| e as &dyn std::error::Error);
1325                    }
1326                    _ => return false,
1327                }
1328            } else if let Some(_tls_err) = e.downcast_ref::<rustls::Error>() {
1329                // try downcast to TLS error
1330                return true;
1331            } else if let Some(resolve_err) = e.downcast_ref::<hickory_resolver::net::NetError>() {
1332                // try downcast to DNS error
1333                return resolve_err.is_nx_domain();
1334            } else if let Some(h2_err) = e.downcast_ref::<h2::Error>() {
1335                // try downcast to a h2 (HTTP/2) error. hyper only wraps these as io::Error
1336                // when they are actually backed by one (see `hyper::Error::new_h2`), so if we
1337                // get here it's a protocol-level RST_STREAM or GOAWAY. TLS integrity protection
1338                // means this can only have been sent by whichever party actually terminates the
1339                // TLS connection - for a fronted request that's the front's CDN edge. So it
1340                // indicates that host/front may have actively rejected the connection (e.g.
1341                // detected fronting, rate limiting, or its own backend failing). This has enough
1342                // potentially to continue breaking things that we should rotate hosts if we can.
1343                return h2_err.is_remote() && (h2_err.is_reset() || h2_err.is_go_away());
1344            } else {
1345                inner = e.source();
1346            }
1347        } else {
1348            break;
1349        }
1350    }
1351
1352    false
1353}
1354
1355/// Common usage functionality for the http client.
1356///
1357/// These functions allow for cleaner downstream usage free of type parameters and unneeded imports.
1358#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1359#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1360pub trait ApiClient: ApiClientCore {
1361    /// Create an HTTP GET Request with the provided path and parameters
1362    fn create_get_request<P, K, V>(
1363        &self,
1364        path: P,
1365        params: Params<'_, K, V>,
1366    ) -> Result<RequestBuilder, HttpClientError>
1367    where
1368        P: RequestPath,
1369        K: AsRef<str>,
1370        V: AsRef<str>,
1371    {
1372        self.create_request(reqwest::Method::GET, path, params, None::<&()>)
1373    }
1374
1375    /// Create an HTTP POST Request with the provided path, parameters, and json body
1376    fn create_post_request<P, B, K, V>(
1377        &self,
1378        path: P,
1379        params: Params<'_, K, V>,
1380        json_body: &B,
1381    ) -> Result<RequestBuilder, HttpClientError>
1382    where
1383        P: RequestPath,
1384        B: Serialize + ?Sized,
1385        K: AsRef<str>,
1386        V: AsRef<str>,
1387    {
1388        self.create_request(reqwest::Method::POST, path, params, Some(json_body))
1389    }
1390
1391    /// Create an HTTP DELETE Request with the provided path and parameters
1392    fn create_delete_request<P, K, V>(
1393        &self,
1394        path: P,
1395        params: Params<'_, K, V>,
1396    ) -> Result<RequestBuilder, HttpClientError>
1397    where
1398        P: RequestPath,
1399        K: AsRef<str>,
1400        V: AsRef<str>,
1401    {
1402        self.create_request(reqwest::Method::DELETE, path, params, None::<&()>)
1403    }
1404
1405    /// Create an HTTP PATCH Request with the provided path, parameters, and json body
1406    fn create_patch_request<P, B, K, V>(
1407        &self,
1408        path: P,
1409        params: Params<'_, K, V>,
1410        json_body: &B,
1411    ) -> Result<RequestBuilder, HttpClientError>
1412    where
1413        P: RequestPath,
1414        B: Serialize + ?Sized,
1415        K: AsRef<str>,
1416        V: AsRef<str>,
1417    {
1418        self.create_request(reqwest::Method::PATCH, path, params, Some(json_body))
1419    }
1420
1421    /// Create and send an HTTP GET Request with the provided path and parameters
1422    #[instrument(level = "debug", skip_all, fields(path=?path))]
1423    async fn send_get_request<P, K, V>(
1424        &self,
1425        path: P,
1426        params: Params<'_, K, V>,
1427    ) -> Result<Response, HttpClientError>
1428    where
1429        P: RequestPath + Send + Sync,
1430        K: AsRef<str> + Sync,
1431        V: AsRef<str> + Sync,
1432    {
1433        self.send_request(reqwest::Method::GET, path, params, None::<&()>)
1434            .await
1435    }
1436
1437    /// Create and send an HTTP POST Request with the provided path, parameters, and json data
1438    async fn send_post_request<P, B, K, V>(
1439        &self,
1440        path: P,
1441        params: Params<'_, K, V>,
1442        json_body: &B,
1443    ) -> Result<Response, HttpClientError>
1444    where
1445        P: RequestPath + Send + Sync,
1446        B: Serialize + ?Sized + Sync,
1447        K: AsRef<str> + Sync,
1448        V: AsRef<str> + Sync,
1449    {
1450        self.send_request(reqwest::Method::POST, path, params, Some(json_body))
1451            .await
1452    }
1453
1454    /// Create and send an HTTP DELETE Request with the provided path and parameters
1455    async fn send_delete_request<P, K, V>(
1456        &self,
1457        path: P,
1458        params: Params<'_, K, V>,
1459    ) -> Result<Response, HttpClientError>
1460    where
1461        P: RequestPath + Send + Sync,
1462        K: AsRef<str> + Sync,
1463        V: AsRef<str> + Sync,
1464    {
1465        self.send_request(reqwest::Method::DELETE, path, params, None::<&()>)
1466            .await
1467    }
1468
1469    /// Create and send an HTTP PATCH Request with the provided path, parameters, and json data
1470    async fn send_patch_request<P, B, K, V>(
1471        &self,
1472        path: P,
1473        params: Params<'_, K, V>,
1474        json_body: &B,
1475    ) -> Result<Response, HttpClientError>
1476    where
1477        P: RequestPath + Send + Sync,
1478        B: Serialize + ?Sized + Sync,
1479        K: AsRef<str> + Sync,
1480        V: AsRef<str> + Sync,
1481    {
1482        self.send_request(reqwest::Method::PATCH, path, params, Some(json_body))
1483            .await
1484    }
1485
1486    /// 'get' json data from the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
1487    /// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
1488    /// into the provided type `T`.
1489    #[instrument(level = "debug", skip_all, fields(path=?path))]
1490    // TODO: deprecate in favour of get_response that works based on mime type in the response
1491    async fn get_json<P, T, K, V>(
1492        &self,
1493        path: P,
1494        params: Params<'_, K, V>,
1495    ) -> Result<T, HttpClientError>
1496    where
1497        P: RequestPath + Send + Sync,
1498        for<'a> T: Deserialize<'a>,
1499        K: AsRef<str> + Sync,
1500        V: AsRef<str> + Sync,
1501    {
1502        self.get_response(path, params).await
1503    }
1504
1505    /// Attempt to parse a response object from an HTTP response
1506    async fn parse_response<T>(
1507        &self,
1508        res: Response,
1509        allow_empty: bool,
1510    ) -> Result<T, HttpClientError>
1511    where
1512        T: DeserializeOwned,
1513    {
1514        let url = Url::from(res.url());
1515        parse_response(res, allow_empty).await.inspect_err(|e| {
1516            if matches!(
1517                // if we encounter a read error while we attempt to parse it could be caused by censorship and we should
1518                // rotate hosts / enable fronting.
1519                e,
1520                HttpClientError::ResponseReadFailure {
1521                    url: _,
1522                    headers: _,
1523                    status: _,
1524                    source: _,
1525                }
1526            ) {
1527                self.maybe_rotate_hosts(Some(url.clone()));
1528                #[cfg(feature = "tunneling")]
1529                self.maybe_enable_fronting(("parse/read", url.as_str(), e));
1530            }
1531        })
1532    }
1533
1534    /// 'get' data from the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
1535    /// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
1536    /// into the provided type `T` based on the content type header
1537    #[instrument(level = "debug", skip_all, fields(path=?path))]
1538    async fn get_response<P, T, K, V>(
1539        &self,
1540        path: P,
1541        params: Params<'_, K, V>,
1542    ) -> Result<T, HttpClientError>
1543    where
1544        P: RequestPath + Send + Sync,
1545        for<'a> T: Deserialize<'a>,
1546        K: AsRef<str> + Sync,
1547        V: AsRef<str> + Sync,
1548    {
1549        let res = self
1550            .send_request(reqwest::Method::GET, path, params, None::<&()>)
1551            .await?;
1552
1553        self.parse_response(res, false).await
1554    }
1555
1556    /// 'post' json data to the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
1557    /// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
1558    /// into the provided type `T`.
1559    async fn post_json<P, B, T, K, V>(
1560        &self,
1561        path: P,
1562        params: Params<'_, K, V>,
1563        json_body: &B,
1564    ) -> Result<T, HttpClientError>
1565    where
1566        P: RequestPath + Send + Sync,
1567        B: Serialize + ?Sized + Sync,
1568        for<'a> T: Deserialize<'a>,
1569        K: AsRef<str> + Sync,
1570        V: AsRef<str> + Sync,
1571    {
1572        let res = self
1573            .send_request(reqwest::Method::POST, path, params, Some(json_body))
1574            .await?;
1575        self.parse_response(res, false).await
1576    }
1577
1578    /// 'delete' json data from the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with
1579    /// tuple defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the
1580    /// response into the provided type `T`.
1581    async fn delete_json<P, T, K, V>(
1582        &self,
1583        path: P,
1584        params: Params<'_, K, V>,
1585    ) -> Result<T, HttpClientError>
1586    where
1587        P: RequestPath + Send + Sync,
1588        for<'a> T: Deserialize<'a>,
1589        K: AsRef<str> + Sync,
1590        V: AsRef<str> + Sync,
1591    {
1592        let res = self
1593            .send_request(reqwest::Method::DELETE, path, params, None::<&()>)
1594            .await?;
1595        self.parse_response(res, false).await
1596    }
1597
1598    /// 'patch' json data at the segment-defined path, e.g. `["api", "v1", "mixnodes"]`, with tuple
1599    /// defined key-value parameters, e.g. `[("since", "12345")]`. Attempt to parse the response
1600    /// into the provided type `T`.
1601    async fn patch_json<P, B, T, K, V>(
1602        &self,
1603        path: P,
1604        params: Params<'_, K, V>,
1605        json_body: &B,
1606    ) -> Result<T, HttpClientError>
1607    where
1608        P: RequestPath + Send + Sync,
1609        B: Serialize + ?Sized + Sync,
1610        for<'a> T: Deserialize<'a>,
1611        K: AsRef<str> + Sync,
1612        V: AsRef<str> + Sync,
1613    {
1614        let res = self
1615            .send_request(reqwest::Method::PATCH, path, params, Some(json_body))
1616            .await?;
1617        self.parse_response(res, false).await
1618    }
1619
1620    /// `get` json data from the provided absolute endpoint, e.g. `"/api/v1/mixnodes?since=12345"`.
1621    /// Attempt to parse the response into the provided type `T`.
1622    async fn get_json_from<T, S>(&self, endpoint: S) -> Result<T, HttpClientError>
1623    where
1624        for<'a> T: Deserialize<'a>,
1625        S: AsRef<str> + Sync + Send,
1626    {
1627        let req = self.create_request_endpoint(reqwest::Method::GET, endpoint, None::<&()>)?;
1628        let res = self.send(req).await?;
1629        self.parse_response(res, false).await
1630    }
1631
1632    /// `post` json data to the provided absolute endpoint, e.g. `"/api/v1/mixnodes?since=12345"`.
1633    /// Attempt to parse the response into the provided type `T`.
1634    async fn post_json_data_to<B, T, S>(
1635        &self,
1636        endpoint: S,
1637        json_body: &B,
1638    ) -> Result<T, HttpClientError>
1639    where
1640        B: Serialize + ?Sized + Sync,
1641        for<'a> T: Deserialize<'a>,
1642        S: AsRef<str> + Sync + Send,
1643    {
1644        let req = self.create_request_endpoint(reqwest::Method::POST, endpoint, Some(json_body))?;
1645        let res = self.send(req).await?;
1646        self.parse_response(res, false).await
1647    }
1648
1649    /// `delete` json data from the provided absolute endpoint, e.g.
1650    /// `"/api/v1/mixnodes?since=12345"`. Attempt to parse the response into the provided type `T`.
1651    async fn delete_json_from<T, S>(&self, endpoint: S) -> Result<T, HttpClientError>
1652    where
1653        for<'a> T: Deserialize<'a>,
1654        S: AsRef<str> + Sync + Send,
1655    {
1656        let req = self.create_request_endpoint(reqwest::Method::DELETE, endpoint, None::<&()>)?;
1657        let res = self.send(req).await?;
1658        self.parse_response(res, false).await
1659    }
1660
1661    /// `patch` json data at the provided absolute endpoint, e.g. `"/api/v1/mixnodes?since=12345"`.
1662    /// Attempt to parse the response into the provided type `T`.
1663    async fn patch_json_data_at<B, T, S>(
1664        &self,
1665        endpoint: S,
1666        json_body: &B,
1667    ) -> Result<T, HttpClientError>
1668    where
1669        B: Serialize + ?Sized + Sync,
1670        for<'a> T: Deserialize<'a>,
1671        S: AsRef<str> + Sync + Send,
1672    {
1673        let req =
1674            self.create_request_endpoint(reqwest::Method::PATCH, endpoint, Some(json_body))?;
1675        let res = self.send(req).await?;
1676        self.parse_response(res, false).await
1677    }
1678}
1679
1680#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1681#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1682impl<C> ApiClient for C where C: ApiClientCore + Sync {}
1683
1684/// utility function that should solve the double slash problem in API urls forever.
1685fn sanitize_url<K: AsRef<str>, V: AsRef<str>>(
1686    base: &Url,
1687    request_path: impl RequestPath,
1688    params: Params<'_, K, V>,
1689) -> Url {
1690    let mut url = base.clone();
1691    let mut path_segments = url
1692        .path_segments_mut()
1693        .expect("provided validator url does not have a base!");
1694
1695    path_segments.pop_if_empty();
1696
1697    for segment in request_path.to_sanitized_segments() {
1698        path_segments.push(segment);
1699    }
1700
1701    // I don't understand why compiler couldn't figure out that it's no longer used
1702    // and can be dropped
1703    drop(path_segments);
1704
1705    if !params.is_empty() {
1706        url.query_pairs_mut().extend_pairs(params);
1707    }
1708
1709    url
1710}
1711
1712fn decode_as_text(bytes: &bytes::Bytes, headers: &HeaderMap) -> String {
1713    use encoding_rs::{Encoding, UTF_8};
1714
1715    let content_type = try_get_mime_type(headers);
1716
1717    let encoding_name = content_type
1718        .as_ref()
1719        .and_then(|mime| mime.get_param("charset").map(|charset| charset.as_str()))
1720        .unwrap_or("utf-8");
1721
1722    let encoding = Encoding::for_label(encoding_name.as_bytes()).unwrap_or(UTF_8);
1723
1724    let (text, _, _) = encoding.decode(bytes);
1725    text.into_owned()
1726}
1727
1728/// Attempt to parse a response object from an HTTP response
1729#[instrument(level = "debug", skip_all)]
1730pub async fn parse_response<T>(res: Response, allow_empty: bool) -> Result<T, HttpClientError>
1731where
1732    T: DeserializeOwned,
1733{
1734    let status = res.status();
1735    let headers = res.headers().clone();
1736    let url = res.url().clone();
1737
1738    tracing::trace!("status: {status} (success: {})", status.is_success());
1739    tracing::trace!("headers: {headers:?}");
1740
1741    if !allow_empty && let Some(0) = res.content_length() {
1742        return Err(HttpClientError::EmptyResponse {
1743            url: Box::new(url),
1744            status,
1745            headers: Box::new(headers),
1746        });
1747    }
1748
1749    if res.status().is_success() {
1750        // internally reqwest is first retrieving bytes and then performing parsing via serde_json
1751        // (and similarly does the same thing for text())
1752        let full = res
1753            .bytes()
1754            .await
1755            .map_err(|source| HttpClientError::ResponseReadFailure {
1756                url: Box::new(url),
1757                headers: Box::new(headers.clone()),
1758                status,
1759                source: ReqwestErrorWrapper(source),
1760            })?;
1761        decode_raw_response(&headers, full)
1762    } else if res.status() == StatusCode::NOT_FOUND {
1763        Err(HttpClientError::NotFound { url: Box::new(url) })
1764    } else if is_http_rate_limit_err(&res) {
1765        Err(HttpClientError::EndpointFailure {
1766            url: Box::new(url),
1767            status,
1768            headers: Box::new(headers),
1769            error: String::from("received vercel rate limit challenge response"),
1770        })
1771    } else {
1772        let Ok(plaintext) = res.text().await else {
1773            return Err(HttpClientError::RequestFailure {
1774                url: Box::new(url),
1775                status,
1776                headers: Box::new(headers),
1777            });
1778        };
1779
1780        Err(HttpClientError::EndpointFailure {
1781            url: Box::new(url),
1782            status,
1783            headers: Box::new(headers),
1784            error: plaintext,
1785        })
1786    }
1787}
1788
1789fn decode_as_json<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
1790where
1791    T: DeserializeOwned,
1792{
1793    match serde_json::from_slice(&content) {
1794        Ok(data) => Ok(data),
1795        Err(err) => {
1796            let content = decode_as_text(&content, headers);
1797            Err(HttpClientError::ResponseDecodeFailure {
1798                message: err.to_string(),
1799                content,
1800            })
1801        }
1802    }
1803}
1804
1805fn decode_as_bincode<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
1806where
1807    T: DeserializeOwned,
1808{
1809    use bincode::Options;
1810
1811    let opts = nym_http_api_common::make_bincode_serializer();
1812    match opts.deserialize(&content) {
1813        Ok(data) => Ok(data),
1814        Err(err) => {
1815            let content = decode_as_text(&content, headers);
1816            Err(HttpClientError::ResponseDecodeFailure {
1817                message: err.to_string(),
1818                content,
1819            })
1820        }
1821    }
1822}
1823
1824fn decode_raw_response<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
1825where
1826    T: DeserializeOwned,
1827{
1828    // if content type header is missing, fallback to our old default, json
1829    let mime = try_get_mime_type(headers).unwrap_or(mime::APPLICATION_JSON);
1830
1831    debug!("attempting to parse response as {mime}");
1832
1833    // unfortunately we can't use stronger typing for subtype as "bincode" is not a defined mime type
1834    match (mime.type_(), mime.subtype().as_str()) {
1835        (mime::APPLICATION, "json") => decode_as_json(headers, content),
1836        (mime::APPLICATION, "bincode") => decode_as_bincode(headers, content),
1837        (_, _) => {
1838            debug!("unrecognised mime type {mime}. falling back to json decoding...");
1839            decode_as_json(headers, content)
1840        }
1841    }
1842}
1843
1844fn try_get_mime_type(headers: &HeaderMap) -> Option<Mime> {
1845    headers
1846        .get(CONTENT_TYPE)
1847        .and_then(|value| value.to_str().ok())
1848        .and_then(|value| value.parse::<Mime>().ok())
1849}
1850
1851#[cfg(test)]
1852mod tests;