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