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