#![warn(missing_docs)]
pub use inventory;
pub use reqwest;
pub use reqwest::ClientBuilder as ReqwestClientBuilder;
pub use reqwest::StatusCode;
use std::error::Error;
pub mod registry;
use crate::path::RequestPath;
use async_trait::async_trait;
use bytes::Bytes;
use http::HeaderMap;
use http::header::{ACCEPT, CONTENT_TYPE};
use itertools::Itertools;
use mime::Mime;
use reqwest::header::HeaderValue;
use reqwest::{RequestBuilder, Response};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use thiserror::Error;
use tracing::{debug, instrument, warn};
#[cfg(not(target_arch = "wasm32"))]
use std::net::SocketAddr;
use std::sync::Arc;
#[cfg(feature = "tunneling")]
mod fronted;
#[cfg(feature = "tunneling")]
pub use fronted::FrontPolicy;
mod url;
pub use url::{IntoUrl, Url};
mod user_agent;
pub use user_agent::UserAgent;
#[cfg(not(target_arch = "wasm32"))]
pub mod dns;
mod path;
#[cfg(not(target_arch = "wasm32"))]
pub use dns::{HickoryDnsResolver, ResolveError};
#[cfg(not(target_arch = "wasm32"))]
use crate::registry::default_builder;
#[doc(hidden)]
pub use nym_bin_common::bin_info;
#[cfg(not(target_arch = "wasm32"))]
use nym_http_api_client_macro::client_defaults;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(not(target_arch = "wasm32"))]
client_defaults!(
priority = -100;
gzip = true,
deflate = true,
brotli = true,
zstd = true,
timeout = DEFAULT_TIMEOUT,
user_agent = format!("nym-http-api-client/{}", env!("CARGO_PKG_VERSION"))
);
pub type PathSegments<'a> = &'a [&'a str];
pub type Params<'a, K, V> = &'a [(K, V)];
pub const NO_PARAMS: Params<'_, &'_ str, &'_ str> = &[];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SerializationFormat {
Json,
Bincode,
Yaml,
Text,
}
impl Display for SerializationFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SerializationFormat::Json => write!(f, "json"),
SerializationFormat::Bincode => write!(f, "bincode"),
SerializationFormat::Yaml => write!(f, "yaml"),
SerializationFormat::Text => write!(f, "text"),
}
}
}
impl SerializationFormat {
#[allow(missing_docs)]
pub fn content_type(&self) -> String {
match self {
SerializationFormat::Json => "application/json".to_string(),
SerializationFormat::Bincode => "application/bincode".to_string(),
SerializationFormat::Yaml => "application/yaml".to_string(),
SerializationFormat::Text => "text/plain".to_string(),
}
}
}
#[allow(missing_docs)]
#[derive(Debug)]
pub struct ReqwestErrorWrapper(reqwest::Error);
impl Display for ReqwestErrorWrapper {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
cfg_if::cfg_if! {
if #[cfg(not(target_arch = "wasm32"))] {
if self.0.is_connect() {
write!(f, "failed to connect: ")?;
}
}
}
if self.0.is_timeout() {
write!(f, "timed out: ")?;
}
if self.0.is_redirect()
&& let Some(final_stop) = self.0.url()
{
write!(f, "redirect loop at {final_stop}: ")?;
}
self.0.fmt(f)?;
if let Some(status_code) = self.0.status() {
write!(f, " status: {status_code}")?;
} else {
write!(f, " unknown status code")?;
}
if let Some(source) = self.0.source() {
write!(f, " source: {source}")?;
} else {
write!(f, " unknown lower-level error source")?;
}
Ok(())
}
}
impl std::error::Error for ReqwestErrorWrapper {}
#[derive(Debug, Error)]
#[allow(missing_docs)]
pub enum HttpClientError {
#[error("did not provide any valid client URLs")]
NoUrlsProvided,
#[error("failed to construct inner reqwest client: {source}")]
ReqwestBuildError {
#[source]
source: reqwest::Error,
},
#[deprecated(
note = "use another more strongly typed variant - this variant is only left for compatibility reasons"
)]
#[error("request failed with error message: {0}")]
GenericRequestFailure(String),
#[deprecated(
note = "use another more strongly typed variant - this variant is only left for compatibility reasons"
)]
#[error("there was an issue with the REST request: {source}")]
ReqwestClientError {
#[from]
source: reqwest::Error,
},
#[error("failed to parse {raw} as a valid URL: {source}")]
MalformedUrl {
raw: String,
#[source]
source: reqwest::Error,
},
#[error("failed to send request for {url}: {source}")]
RequestSendFailure {
url: reqwest::Url,
#[source]
source: ReqwestErrorWrapper,
},
#[error("failed to read response body from {url}: {source}")]
ResponseReadFailure {
url: reqwest::Url,
headers: Box<HeaderMap>,
status: StatusCode,
#[source]
source: ReqwestErrorWrapper,
},
#[error("failed to deserialize received response: {source}")]
ResponseDeserialisationFailure { source: serde_json::Error },
#[error("provided url is malformed: {source}")]
UrlParseFailure {
#[from]
source: url::ParseError,
},
#[error("the requested resource could not be found at {url}")]
NotFound { url: reqwest::Url },
#[error("attempted to use domain fronting and clone a request containing stream data")]
AttemptedToCloneStreamRequest,
#[error(
"the request for {url} failed with status '{status}'. no additional error message provided. response headers: {headers:?}"
)]
RequestFailure {
url: reqwest::Url,
status: StatusCode,
headers: Box<HeaderMap>,
},
#[error(
"the returned response from {url} was empty. status: '{status}'. response headers: {headers:?}"
)]
EmptyResponse {
url: reqwest::Url,
status: StatusCode,
headers: Box<HeaderMap>,
},
#[error(
"failed to resolve request for {url}. status: '{status}'. response headers: {headers:?}. additional error message: {error}"
)]
EndpointFailure {
url: reqwest::Url,
status: StatusCode,
headers: Box<HeaderMap>,
error: String,
},
#[error("failed to decode response body: {message} from {content}")]
ResponseDecodeFailure { message: String, content: String },
#[error("failed to resolve request to {url} due to data inconsistency: {details}")]
InternalResponseInconsistency { url: ::url::Url, details: String },
#[cfg(not(target_arch = "wasm32"))]
#[error("encountered dns failure: {inner}")]
DnsLookupFailure {
#[from]
inner: ResolveError,
},
#[error("Failed to encode bincode: {0}")]
Bincode(#[from] bincode::Error),
#[error("Failed to json: {0}")]
Json(#[from] serde_json::Error),
#[error("Failed to yaml: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("Failed to plain: {0}")]
Plain(#[from] serde_plain::Error),
#[cfg(target_arch = "wasm32")]
#[error("the request has timed out")]
RequestTimeout,
}
#[allow(missing_docs)]
#[allow(deprecated)]
impl HttpClientError {
pub fn is_timeout(&self) -> bool {
match self {
HttpClientError::ReqwestClientError { source } => source.is_timeout(),
HttpClientError::RequestSendFailure { source, .. } => source.0.is_timeout(),
HttpClientError::ResponseReadFailure { source, .. } => source.0.is_timeout(),
#[cfg(not(target_arch = "wasm32"))]
HttpClientError::DnsLookupFailure { inner } => inner.is_timeout(),
#[cfg(target_arch = "wasm32")]
HttpClientError::RequestTimeout => true,
_ => false,
}
}
pub fn status_code(&self) -> Option<StatusCode> {
match self {
HttpClientError::ResponseReadFailure { status, .. } => Some(*status),
HttpClientError::RequestFailure { status, .. } => Some(*status),
HttpClientError::EmptyResponse { status, .. } => Some(*status),
HttpClientError::EndpointFailure { status, .. } => Some(*status),
_ => None,
}
}
pub fn reqwest_client_build_error(source: reqwest::Error) -> Self {
HttpClientError::ReqwestBuildError { source }
}
pub fn request_send_error(url: reqwest::Url, source: reqwest::Error) -> Self {
HttpClientError::RequestSendFailure {
url,
source: ReqwestErrorWrapper(source),
}
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait ApiClientCore {
fn create_request<P, B, K, V>(
&self,
method: reqwest::Method,
path: P,
params: Params<'_, K, V>,
body: Option<&B>,
) -> Result<RequestBuilder, HttpClientError>
where
P: RequestPath,
B: Serialize + ?Sized,
K: AsRef<str>,
V: AsRef<str>;
fn create_request_endpoint<B, S>(
&self,
method: reqwest::Method,
endpoint: S,
body: Option<&B>,
) -> Result<RequestBuilder, HttpClientError>
where
B: Serialize + ?Sized,
S: AsRef<str>,
{
let mut standin_url: Url = "http://example.com".parse().unwrap();
match endpoint.as_ref().split_once("?") {
Some((path, query)) => {
standin_url.set_path(path);
standin_url.set_query(Some(query));
}
None => standin_url.set_path(endpoint.as_ref()),
}
let path: Vec<&str> = match standin_url.path_segments() {
Some(segments) => segments.collect(),
None => Vec::new(),
};
let params: Vec<(String, String)> = standin_url.query_pairs().into_owned().collect();
self.create_request(method, path.as_slice(), ¶ms, body)
}
async fn send(&self, request: RequestBuilder) -> Result<Response, HttpClientError>;
async fn send_request<P, B, K, V>(
&self,
method: reqwest::Method,
path: P,
params: Params<'_, K, V>,
json_body: Option<&B>,
) -> Result<Response, HttpClientError>
where
P: RequestPath + Send + Sync,
B: Serialize + ?Sized + Sync,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
let req = self.create_request(method, path, params, json_body)?;
self.send(req).await
}
}
pub struct ClientBuilder {
urls: Vec<Url>,
timeout: Option<Duration>,
custom_user_agent: bool,
reqwest_client_builder: reqwest::ClientBuilder,
#[allow(dead_code)] use_secure_dns: bool,
#[cfg(feature = "tunneling")]
front: Option<fronted::Front>,
retry_limit: usize,
serialization: SerializationFormat,
}
impl ClientBuilder {
pub fn new<U>(url: U) -> Result<Self, HttpClientError>
where
U: IntoUrl,
{
let str_url = url.as_str();
if !str_url.starts_with("http") {
let alt = format!("http://{str_url}");
warn!(
"the provided url ('{str_url}') does not contain scheme information. Changing it to '{alt}' ..."
);
Self::new(alt)
} else {
let url = url.to_url()?;
Self::new_with_urls(vec![url])
}
}
#[cfg(feature = "network-defaults")]
#[deprecated(note = "use explicit Self::new_with_fronted_urls instead")]
pub fn from_network(
network: &nym_network_defaults::NymNetworkDetails,
) -> Result<Self, HttpClientError> {
let urls = network.nym_api_urls.as_ref().cloned().unwrap_or_default();
Self::new_with_fronted_urls(urls.clone())
}
#[cfg(feature = "network-defaults")]
pub fn new_with_fronted_urls(
urls: Vec<nym_network_defaults::ApiUrl>,
) -> Result<Self, HttpClientError> {
let urls = urls
.into_iter()
.map(|api_url| {
let mut url = Url::parse(&api_url.url)?;
#[cfg(feature = "tunneling")]
if let Some(ref front_hosts) = api_url.front_hosts {
let fronts: Vec<String> = front_hosts
.iter()
.map(|host| format!("https://{}", host))
.collect();
url = Url::new(api_url.url.clone(), Some(fronts)).map_err(|source| {
HttpClientError::MalformedUrl {
raw: api_url.url.clone(),
source,
}
})?;
}
Ok(url)
})
.collect::<Result<Vec<_>, HttpClientError>>()?;
let mut builder = Self::new_with_urls(urls)?;
#[cfg(feature = "tunneling")]
{
builder = builder.with_fronting(FrontPolicy::OnRetry);
}
Ok(builder)
}
pub fn new_with_urls(urls: Vec<Url>) -> Result<Self, HttpClientError> {
if urls.is_empty() {
return Err(HttpClientError::NoUrlsProvided);
}
let urls = Self::check_urls(urls);
#[cfg(target_arch = "wasm32")]
let reqwest_client_builder = reqwest::ClientBuilder::new();
#[cfg(not(target_arch = "wasm32"))]
let reqwest_client_builder = default_builder();
Ok(ClientBuilder {
urls,
timeout: None,
custom_user_agent: false,
reqwest_client_builder,
use_secure_dns: true,
#[cfg(feature = "tunneling")]
front: None,
retry_limit: 0,
serialization: SerializationFormat::Json,
})
}
pub fn add_url(mut self, url: Url) -> Self {
self.urls.push(url);
self
}
fn check_urls(mut urls: Vec<Url>) -> Vec<Url> {
urls = urls.into_iter().unique().collect();
urls.iter()
.filter(|url| !url.scheme().contains("http") && !url.scheme().contains("https"))
.for_each(|url| {
warn!("the provided url ('{url}') does not use HTTP / HTTPS scheme");
});
urls
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn with_retries(mut self, retry_limit: usize) -> Self {
self.retry_limit = retry_limit;
self
}
pub fn with_reqwest_builder(mut self, reqwest_builder: reqwest::ClientBuilder) -> Self {
self.reqwest_client_builder = reqwest_builder;
self
}
pub fn with_user_agent<V>(mut self, value: V) -> Self
where
V: TryInto<HeaderValue>,
V::Error: Into<http::Error>,
{
self.custom_user_agent = true;
self.reqwest_client_builder = self.reqwest_client_builder.user_agent(value);
self
}
#[cfg(not(target_arch = "wasm32"))]
pub fn resolve_to_addrs(mut self, domain: &str, addrs: &[SocketAddr]) -> ClientBuilder {
self.reqwest_client_builder = self.reqwest_client_builder.resolve_to_addrs(domain, addrs);
self
}
pub fn with_serialization(mut self, format: SerializationFormat) -> Self {
self.serialization = format;
self
}
pub fn with_bincode(self) -> Self {
self.with_serialization(SerializationFormat::Bincode)
}
pub fn build(self) -> Result<Client, HttpClientError> {
#[cfg(target_arch = "wasm32")]
let reqwest_client = self.reqwest_client_builder.build()?;
#[cfg(not(target_arch = "wasm32"))]
let reqwest_client = {
let mut builder = self.reqwest_client_builder;
if self.use_secure_dns {
builder = builder.dns_resolver(Arc::new(HickoryDnsResolver::default()));
}
builder
.build()
.map_err(HttpClientError::reqwest_client_build_error)?
};
let client = Client {
base_urls: self.urls,
current_idx: Arc::new(AtomicUsize::new(0)),
reqwest_client,
using_secure_dns: self.use_secure_dns,
#[cfg(feature = "tunneling")]
front: self.front,
#[cfg(target_arch = "wasm32")]
request_timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
retry_limit: self.retry_limit,
serialization: self.serialization,
};
Ok(client)
}
}
#[derive(Debug, Clone)]
pub struct Client {
base_urls: Vec<Url>,
current_idx: Arc<AtomicUsize>,
reqwest_client: reqwest::Client,
using_secure_dns: bool,
#[cfg(feature = "tunneling")]
front: Option<fronted::Front>,
#[cfg(target_arch = "wasm32")]
request_timeout: Duration,
retry_limit: usize,
serialization: SerializationFormat,
}
impl Client {
pub fn new(base_url: ::url::Url, timeout: Option<Duration>) -> Self {
Self::new_url(base_url, timeout).expect(
"we provided valid url and we were unwrapping previous construction errors anyway",
)
}
pub fn new_url<U>(url: U, timeout: Option<Duration>) -> Result<Self, HttpClientError>
where
U: IntoUrl,
{
let builder = Self::builder(url)?;
match timeout {
Some(timeout) => builder.with_timeout(timeout).build(),
None => builder.build(),
}
}
pub fn builder<U>(url: U) -> Result<ClientBuilder, HttpClientError>
where
U: IntoUrl,
{
ClientBuilder::new(url)
}
pub fn change_base_urls(&mut self, new_urls: Vec<Url>) {
self.current_idx.store(0, Ordering::Relaxed);
self.base_urls = new_urls
}
pub fn clone_with_new_url(&self, new_url: Url) -> Self {
Client {
base_urls: vec![new_url],
current_idx: Arc::new(Default::default()),
reqwest_client: self.reqwest_client.clone(),
using_secure_dns: self.using_secure_dns,
#[cfg(feature = "tunneling")]
front: self.front.clone(),
retry_limit: self.retry_limit,
#[cfg(target_arch = "wasm32")]
request_timeout: self.request_timeout,
serialization: self.serialization,
}
}
pub fn current_url(&self) -> &Url {
&self.base_urls[self.current_idx.load(std::sync::atomic::Ordering::Relaxed)]
}
pub fn base_urls(&self) -> &[Url] {
&self.base_urls
}
pub fn base_urls_mut(&mut self) -> &mut [Url] {
&mut self.base_urls
}
pub fn change_retry_limit(&mut self, limit: usize) {
self.retry_limit = limit;
}
#[cfg(feature = "tunneling")]
fn matches_current_host(&self, url: &Url) -> bool {
if let Some(ref front) = self.front
&& front.is_enabled()
{
url.host_str() == self.current_url().front_str()
} else {
url.host_str() == self.current_url().host_str()
}
}
#[cfg(not(feature = "tunneling"))]
fn matches_current_host(&self, url: &Url) -> bool {
url.host_str() == self.current_url().host_str()
}
fn update_host(&self, maybe_url: Option<Url>) {
if let Some(err_url) = maybe_url
&& !self.matches_current_host(&err_url)
{
return;
}
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front
&& front.is_enabled()
{
let url = self.current_url();
if url.has_front() && !url.update() {
return;
}
}
if self.base_urls.len() > 1 {
let orig = self.current_idx.load(Ordering::Relaxed);
#[allow(unused_mut)]
let mut next = (orig + 1) % self.base_urls.len();
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front
&& front.is_enabled()
{
while next != orig {
if self.base_urls[next].has_front() {
break;
}
next = (next + 1) % self.base_urls.len();
}
}
self.current_idx.store(next, Ordering::Relaxed);
debug!(
"http client rotating host {} -> {}",
self.base_urls[orig], self.base_urls[next]
);
}
}
fn apply_hosts_to_req(&self, r: &mut reqwest::Request) -> (&str, Option<&str>) {
let url = self.current_url();
r.url_mut().set_host(url.host_str()).unwrap();
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front
&& front.is_enabled()
{
if let Some(front_host) = url.front_str() {
if let Some(actual_host) = url.host_str() {
tracing::debug!(
"Domain fronting enabled: routing via CDN {} to actual host {}",
front_host,
actual_host
);
r.url_mut().set_host(Some(front_host)).unwrap();
let actual_host_header: HeaderValue =
actual_host.parse().unwrap_or(HeaderValue::from_static(""));
_ = r
.headers_mut()
.insert(reqwest::header::HOST, actual_host_header);
return (url.as_str(), url.front_str());
} else {
tracing::debug!(
"Domain fronting is enabled, but no host_url is defined for current URL"
)
}
} else {
tracing::debug!(
"Domain fronting is enabled, but current URL has no front_hosts configured"
)
}
}
(url.as_str(), None)
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl ApiClientCore for Client {
#[instrument(level = "debug", skip_all, fields(path=?path))]
fn create_request<P, B, K, V>(
&self,
method: reqwest::Method,
path: P,
params: Params<'_, K, V>,
body: Option<&B>,
) -> Result<RequestBuilder, HttpClientError>
where
P: RequestPath,
B: Serialize + ?Sized,
K: AsRef<str>,
V: AsRef<str>,
{
let url = self.current_url();
let url = sanitize_url(url, path, params);
let mut req = reqwest::Request::new(method, url.into());
self.apply_hosts_to_req(&mut req);
let mut rb = RequestBuilder::from_parts(self.reqwest_client.clone(), req);
rb = rb
.header(ACCEPT, self.serialization.content_type())
.header(CONTENT_TYPE, self.serialization.content_type());
if let Some(body) = body {
match self.serialization {
SerializationFormat::Json => {
rb = rb.json(body);
}
SerializationFormat::Bincode => {
let body = bincode::serialize(body)?;
rb = rb.body(body);
}
SerializationFormat::Yaml => {
let mut body_bytes = Vec::new();
serde_yaml::to_writer(&mut body_bytes, &body)?;
rb = rb.body(body_bytes);
}
SerializationFormat::Text => {
let body = serde_plain::to_string(&body)?.as_bytes().to_vec();
rb = rb.body(body);
}
}
}
Ok(rb)
}
async fn send(&self, request: RequestBuilder) -> Result<Response, HttpClientError> {
let mut attempts = 0;
loop {
let r = request
.try_clone()
.ok_or(HttpClientError::AttemptedToCloneStreamRequest)?;
let mut req = r
.build()
.map_err(HttpClientError::reqwest_client_build_error)?;
self.apply_hosts_to_req(&mut req);
let url: Url = req.url().clone().into();
#[cfg(target_arch = "wasm32")]
let response: Result<Response, HttpClientError> = {
Ok(wasmtimer::tokio::timeout(
self.request_timeout,
self.reqwest_client.execute(req),
)
.await
.map_err(|_timeout| HttpClientError::RequestTimeout)??)
};
#[cfg(not(target_arch = "wasm32"))]
let response = self.reqwest_client.execute(req).await;
match response {
Ok(resp) => return Ok(resp),
Err(err) => {
#[cfg(target_arch = "wasm32")]
let is_network_err = err.is_timeout();
#[cfg(not(target_arch = "wasm32"))]
let is_network_err = err.is_timeout() || err.is_connect();
if is_network_err {
self.update_host(Some(url.clone()));
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front {
let was_enabled = front.is_enabled();
front.retry_enable();
if !was_enabled && front.is_enabled() {
tracing::info!(
"Domain fronting activated after connection failure: {err}",
);
}
}
}
if attempts < self.retry_limit {
attempts += 1;
warn!(
"Retrying request due to http error on attempt ({attempts}/{}): {err}",
self.retry_limit
);
continue;
}
cfg_if::cfg_if! {
if #[cfg(target_arch = "wasm32")] {
return Err(err);
} else {
return Err(HttpClientError::request_send_error(url.into(), err));
}
}
}
}
}
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait ApiClient: ApiClientCore {
fn create_get_request<P, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
) -> Result<RequestBuilder, HttpClientError>
where
P: RequestPath,
K: AsRef<str>,
V: AsRef<str>,
{
self.create_request(reqwest::Method::GET, path, params, None::<&()>)
}
fn create_post_request<P, B, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
json_body: &B,
) -> Result<RequestBuilder, HttpClientError>
where
P: RequestPath,
B: Serialize + ?Sized,
K: AsRef<str>,
V: AsRef<str>,
{
self.create_request(reqwest::Method::POST, path, params, Some(json_body))
}
fn create_delete_request<P, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
) -> Result<RequestBuilder, HttpClientError>
where
P: RequestPath,
K: AsRef<str>,
V: AsRef<str>,
{
self.create_request(reqwest::Method::DELETE, path, params, None::<&()>)
}
fn create_patch_request<P, B, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
json_body: &B,
) -> Result<RequestBuilder, HttpClientError>
where
P: RequestPath,
B: Serialize + ?Sized,
K: AsRef<str>,
V: AsRef<str>,
{
self.create_request(reqwest::Method::PATCH, path, params, Some(json_body))
}
#[instrument(level = "debug", skip_all, fields(path=?path))]
async fn send_get_request<P, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
) -> Result<Response, HttpClientError>
where
P: RequestPath + Send + Sync,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
self.send_request(reqwest::Method::GET, path, params, None::<&()>)
.await
}
async fn send_post_request<P, B, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
json_body: &B,
) -> Result<Response, HttpClientError>
where
P: RequestPath + Send + Sync,
B: Serialize + ?Sized + Sync,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
self.send_request(reqwest::Method::POST, path, params, Some(json_body))
.await
}
async fn send_delete_request<P, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
) -> Result<Response, HttpClientError>
where
P: RequestPath + Send + Sync,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
self.send_request(reqwest::Method::DELETE, path, params, None::<&()>)
.await
}
async fn send_patch_request<P, B, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
json_body: &B,
) -> Result<Response, HttpClientError>
where
P: RequestPath + Send + Sync,
B: Serialize + ?Sized + Sync,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
self.send_request(reqwest::Method::PATCH, path, params, Some(json_body))
.await
}
#[instrument(level = "debug", skip_all, fields(path=?path))]
async fn get_json<P, T, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
) -> Result<T, HttpClientError>
where
P: RequestPath + Send + Sync,
for<'a> T: Deserialize<'a>,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
self.get_response(path, params).await
}
async fn get_response<P, T, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
) -> Result<T, HttpClientError>
where
P: RequestPath + Send + Sync,
for<'a> T: Deserialize<'a>,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
let res = self
.send_request(reqwest::Method::GET, path, params, None::<&()>)
.await?;
parse_response(res, false).await
}
async fn post_json<P, B, T, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
json_body: &B,
) -> Result<T, HttpClientError>
where
P: RequestPath + Send + Sync,
B: Serialize + ?Sized + Sync,
for<'a> T: Deserialize<'a>,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
let res = self
.send_request(reqwest::Method::POST, path, params, Some(json_body))
.await?;
parse_response(res, false).await
}
async fn delete_json<P, T, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
) -> Result<T, HttpClientError>
where
P: RequestPath + Send + Sync,
for<'a> T: Deserialize<'a>,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
let res = self
.send_request(reqwest::Method::DELETE, path, params, None::<&()>)
.await?;
parse_response(res, false).await
}
async fn patch_json<P, B, T, K, V>(
&self,
path: P,
params: Params<'_, K, V>,
json_body: &B,
) -> Result<T, HttpClientError>
where
P: RequestPath + Send + Sync,
B: Serialize + ?Sized + Sync,
for<'a> T: Deserialize<'a>,
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
{
let res = self
.send_request(reqwest::Method::PATCH, path, params, Some(json_body))
.await?;
parse_response(res, false).await
}
async fn get_json_from<T, S>(&self, endpoint: S) -> Result<T, HttpClientError>
where
for<'a> T: Deserialize<'a>,
S: AsRef<str> + Sync + Send,
{
let req = self.create_request_endpoint(reqwest::Method::GET, endpoint, None::<&()>)?;
let res = self.send(req).await?;
parse_response(res, false).await
}
async fn post_json_data_to<B, T, S>(
&self,
endpoint: S,
json_body: &B,
) -> Result<T, HttpClientError>
where
B: Serialize + ?Sized + Sync,
for<'a> T: Deserialize<'a>,
S: AsRef<str> + Sync + Send,
{
let req = self.create_request_endpoint(reqwest::Method::POST, endpoint, Some(json_body))?;
let res = self.send(req).await?;
parse_response(res, false).await
}
async fn delete_json_from<T, S>(&self, endpoint: S) -> Result<T, HttpClientError>
where
for<'a> T: Deserialize<'a>,
S: AsRef<str> + Sync + Send,
{
let req = self.create_request_endpoint(reqwest::Method::DELETE, endpoint, None::<&()>)?;
let res = self.send(req).await?;
parse_response(res, false).await
}
async fn patch_json_data_at<B, T, S>(
&self,
endpoint: S,
json_body: &B,
) -> Result<T, HttpClientError>
where
B: Serialize + ?Sized + Sync,
for<'a> T: Deserialize<'a>,
S: AsRef<str> + Sync + Send,
{
let req =
self.create_request_endpoint(reqwest::Method::PATCH, endpoint, Some(json_body))?;
let res = self.send(req).await?;
parse_response(res, false).await
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl<C> ApiClient for C where C: ApiClientCore + Sync {}
fn sanitize_url<K: AsRef<str>, V: AsRef<str>>(
base: &Url,
request_path: impl RequestPath,
params: Params<'_, K, V>,
) -> Url {
let mut url = base.clone();
let mut path_segments = url
.path_segments_mut()
.expect("provided validator url does not have a base!");
path_segments.pop_if_empty();
for segment in request_path.to_sanitized_segments() {
path_segments.push(segment);
}
drop(path_segments);
if !params.is_empty() {
url.query_pairs_mut().extend_pairs(params);
}
url
}
fn decode_as_text(bytes: &bytes::Bytes, headers: &HeaderMap) -> String {
use encoding_rs::{Encoding, UTF_8};
let content_type = try_get_mime_type(headers);
let encoding_name = content_type
.as_ref()
.and_then(|mime| mime.get_param("charset").map(|charset| charset.as_str()))
.unwrap_or("utf-8");
let encoding = Encoding::for_label(encoding_name.as_bytes()).unwrap_or(UTF_8);
let (text, _, _) = encoding.decode(bytes);
text.into_owned()
}
#[instrument(level = "debug", skip_all)]
pub async fn parse_response<T>(res: Response, allow_empty: bool) -> Result<T, HttpClientError>
where
T: DeserializeOwned,
{
let status = res.status();
let headers = res.headers().clone();
let url = res.url().clone();
tracing::trace!("status: {status} (success: {})", status.is_success());
tracing::trace!("headers: {headers:?}");
if !allow_empty && let Some(0) = res.content_length() {
return Err(HttpClientError::EmptyResponse {
url,
status,
headers: Box::new(headers),
});
}
if res.status().is_success() {
let full = res
.bytes()
.await
.map_err(|source| HttpClientError::ResponseReadFailure {
url,
headers: Box::new(headers.clone()),
status,
source: ReqwestErrorWrapper(source),
})?;
decode_raw_response(&headers, full)
} else if res.status() == StatusCode::NOT_FOUND {
Err(HttpClientError::NotFound { url })
} else {
let Ok(plaintext) = res.text().await else {
return Err(HttpClientError::RequestFailure {
url,
status,
headers: Box::new(headers),
});
};
Err(HttpClientError::EndpointFailure {
url,
status,
headers: Box::new(headers),
error: plaintext,
})
}
}
fn decode_as_json<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
where
T: DeserializeOwned,
{
match serde_json::from_slice(&content) {
Ok(data) => Ok(data),
Err(err) => {
let content = decode_as_text(&content, headers);
Err(HttpClientError::ResponseDecodeFailure {
message: err.to_string(),
content,
})
}
}
}
fn decode_as_bincode<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
where
T: DeserializeOwned,
{
use bincode::Options;
let opts = nym_http_api_common::make_bincode_serializer();
match opts.deserialize(&content) {
Ok(data) => Ok(data),
Err(err) => {
let content = decode_as_text(&content, headers);
Err(HttpClientError::ResponseDecodeFailure {
message: err.to_string(),
content,
})
}
}
}
fn decode_raw_response<T>(headers: &HeaderMap, content: Bytes) -> Result<T, HttpClientError>
where
T: DeserializeOwned,
{
let mime = try_get_mime_type(headers).unwrap_or(mime::APPLICATION_JSON);
debug!("attempting to parse response as {mime}");
match (mime.type_(), mime.subtype().as_str()) {
(mime::APPLICATION, "json") => decode_as_json(headers, content),
(mime::APPLICATION, "bincode") => decode_as_bincode(headers, content),
(_, _) => {
debug!("unrecognised mime type {mime}. falling back to json decoding...");
decode_as_json(headers, content)
}
}
}
fn try_get_mime_type(headers: &HeaderMap) -> Option<Mime> {
headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<Mime>().ok())
}
#[cfg(test)]
mod tests;