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