f328f3fa9e
* Squashing all the changes initial router started expanding the API initial empty openapi/swagger populated build-info endpoint wip: populating rest of swagger missing swagger data + using closure capture for immutable state running the api as a proper task in gateway 'run' fixing some version/feature clashes refactored routes structures initial host information endpoint expanded on gateway-related endpoints signing host information moved all models to separate crate unified http api client routes unification + node api client new generic cache and refresher nym-api caching node self described information removed old cache type temporarily wired up NymContractCache to NodeDescriptionProvider caching self reported host info clients using self-described gateway information fixed request timeouts for wasm fixed wasm builds post rebase fixes cargo fmt brought in wg routes into nym-node router added ErrorResponse for wireguard routes basic swagger support for wg endpoints turns out swagger can be happy with strongly typed requests output type support for wg routes using concrete error type for nym node request error fixed the registration test landing page configurability increased configurability fixed build and lints of other crates added default user-agent to http-api-client reduced severity of gateway details lookup failure changed default http port from 80 to 8080 nym-api using new default port for queries added health endpoint nym-api trying multiple ports for the client using camelcase for node status corrected health endpoint description restored and revamped 'force_tls' flag to filter all gateways that support the wss protocol fixed 'pub_key' path param in open api schema derived Debug on 'NymNodeDescription' ensuring valid public ips added init and run flags to set hostname and public ips fixed listening address being pushed to public ip fixed the positional local flag logging remote ip address of the request updated helper function to query for described gateways enabled tls in gateway client removed hack-opts from mix fetch additional changes after rebasing against origin/develop * clippy * wasm-related target locking * more clippy, but this time in tests
513 lines
15 KiB
Rust
513 lines
15 KiB
Rust
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
use async_trait::async_trait;
|
|
use reqwest::{IntoUrl, Response, StatusCode};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fmt::Display;
|
|
use std::time::Duration;
|
|
use thiserror::Error;
|
|
use tracing::warn;
|
|
use url::Url;
|
|
|
|
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
|
|
|
|
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, Error)]
|
|
pub enum HttpClientError<E: Display = String> {
|
|
#[error("there was an issue with the REST request: {source}")]
|
|
ReqwestClientError {
|
|
#[from]
|
|
source: reqwest::Error,
|
|
},
|
|
|
|
#[error("provided url is malformed: {source}")]
|
|
MalformedUrl {
|
|
#[from]
|
|
source: url::ParseError,
|
|
},
|
|
|
|
#[error("the requested resource could not be found")]
|
|
NotFound,
|
|
|
|
#[error("request failed with error message: {0}")]
|
|
GenericRequestFailure(String),
|
|
|
|
#[error("the request failed with status '{status}'. no additional error message provided")]
|
|
RequestFailure { status: StatusCode },
|
|
|
|
#[error("the returned response was empty. status: '{status}'")]
|
|
EmptyResponse { status: StatusCode },
|
|
|
|
#[error("failed to resolve request. status: '{status}', additional error message: {error}")]
|
|
EndpointFailure { status: StatusCode, error: E },
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
#[error("the request has timed out")]
|
|
RequestTimeout,
|
|
}
|
|
|
|
/// A simple extendable client wrapper for http request with extra url sanitization.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Client {
|
|
base_url: Url,
|
|
reqwest_client: reqwest::Client,
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
request_timeout: Duration,
|
|
}
|
|
|
|
impl Client {
|
|
// no timeout until https://github.com/seanmonstar/reqwest/issues/1135 is fixed
|
|
pub fn new(base_url: Url, timeout: Option<Duration>) -> Self {
|
|
#[cfg(target_arch = "wasm32")]
|
|
let reqwest_client = reqwest::Client::new();
|
|
|
|
// TODO: we should probably be propagating the error rather than panicking,
|
|
// but that'd break bunch of things due to type changes
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let reqwest_client = reqwest::ClientBuilder::new()
|
|
.timeout(timeout.unwrap_or(DEFAULT_TIMEOUT))
|
|
.user_agent(format!("nym-http-api-client/{}", env!("CARGO_PKG_VERSION")))
|
|
.build()
|
|
.expect("Client::new()");
|
|
|
|
Client {
|
|
base_url,
|
|
reqwest_client,
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
request_timeout: timeout.unwrap_or(DEFAULT_TIMEOUT),
|
|
}
|
|
}
|
|
|
|
pub fn new_url<U, E>(url: U, timeout: Option<Duration>) -> Result<Self, HttpClientError<E>>
|
|
where
|
|
U: IntoUrl,
|
|
E: Display,
|
|
{
|
|
// a naive check: if the provided URL does not start with http(s), add that scheme
|
|
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}' ...");
|
|
// TODO: or should we maybe default to https?
|
|
Self::new_url(alt, timeout)
|
|
} else {
|
|
Ok(Self::new(url.into_url()?, timeout))
|
|
}
|
|
}
|
|
|
|
pub fn change_base_url(&mut self, new_url: Url) {
|
|
self.base_url = new_url
|
|
}
|
|
|
|
pub fn current_url(&self) -> &Url {
|
|
&self.base_url
|
|
}
|
|
|
|
async fn send_get_request<K, V, E>(
|
|
&self,
|
|
path: PathSegments<'_>,
|
|
params: Params<'_, K, V>,
|
|
) -> Result<Response, HttpClientError<E>>
|
|
where
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
E: Display,
|
|
{
|
|
let url = sanitize_url(&self.base_url, path, params);
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
Ok(
|
|
wasmtimer::tokio::timeout(
|
|
self.request_timeout,
|
|
self.reqwest_client.get(url).send(),
|
|
)
|
|
.await
|
|
.map_err(|_timeout| HttpClientError::RequestTimeout)??,
|
|
)
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
Ok(self.reqwest_client.get(url).send().await?)
|
|
}
|
|
}
|
|
|
|
async fn send_post_request<B, K, V, E>(
|
|
&self,
|
|
path: PathSegments<'_>,
|
|
params: Params<'_, K, V>,
|
|
json_body: &B,
|
|
) -> Result<Response, HttpClientError<E>>
|
|
where
|
|
B: Serialize + ?Sized,
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
E: Display,
|
|
{
|
|
let url = sanitize_url(&self.base_url, path, params);
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
Ok(wasmtimer::tokio::timeout(
|
|
self.request_timeout,
|
|
self.reqwest_client.post(url).json(json_body).send(),
|
|
)
|
|
.await
|
|
.map_err(|_timeout| HttpClientError::RequestTimeout)??)
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
Ok(self.reqwest_client.post(url).json(json_body).send().await?)
|
|
}
|
|
}
|
|
|
|
pub async fn get_json<T, K, V, E>(
|
|
&self,
|
|
path: PathSegments<'_>,
|
|
params: Params<'_, K, V>,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
for<'a> T: Deserialize<'a>,
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
E: Display + DeserializeOwned,
|
|
{
|
|
let res = self.send_get_request(path, params).await?;
|
|
parse_response(res, false).await
|
|
}
|
|
|
|
pub async fn post_json<B, T, K, V, E>(
|
|
&self,
|
|
path: PathSegments<'_>,
|
|
params: Params<'_, K, V>,
|
|
json_body: &B,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
B: Serialize + ?Sized,
|
|
for<'a> T: Deserialize<'a>,
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
E: Display + DeserializeOwned,
|
|
{
|
|
let res = self.send_post_request(path, params, json_body).await?;
|
|
parse_response(res, true).await
|
|
}
|
|
|
|
pub async fn get_json_endpoint<T, S, E>(&self, endpoint: S) -> Result<T, HttpClientError<E>>
|
|
where
|
|
for<'a> T: Deserialize<'a>,
|
|
E: Display + DeserializeOwned,
|
|
S: AsRef<str>,
|
|
{
|
|
#[cfg(target_arch = "wasm32")]
|
|
let res = {
|
|
wasmtimer::tokio::timeout(
|
|
self.request_timeout,
|
|
self.reqwest_client
|
|
.get(self.base_url.join(endpoint.as_ref())?)
|
|
.send(),
|
|
)
|
|
.await
|
|
.map_err(|_timeout| HttpClientError::RequestTimeout)??
|
|
};
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let res = {
|
|
self.reqwest_client
|
|
.get(self.base_url.join(endpoint.as_ref())?)
|
|
.send()
|
|
.await?
|
|
};
|
|
|
|
parse_response(res, false).await
|
|
}
|
|
|
|
pub async fn post_json_endpoint<B, T, S, E>(
|
|
&self,
|
|
endpoint: S,
|
|
json_body: &B,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
B: Serialize + ?Sized,
|
|
for<'a> T: Deserialize<'a>,
|
|
E: Display + DeserializeOwned,
|
|
S: AsRef<str>,
|
|
{
|
|
#[cfg(target_arch = "wasm32")]
|
|
let res = {
|
|
wasmtimer::tokio::timeout(
|
|
self.request_timeout,
|
|
self.reqwest_client
|
|
.post(self.base_url.join(endpoint.as_ref())?)
|
|
.json(json_body)
|
|
.send(),
|
|
)
|
|
.await
|
|
.map_err(|_timeout| HttpClientError::RequestTimeout)??
|
|
};
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let res = {
|
|
self.reqwest_client
|
|
.post(self.base_url.join(endpoint.as_ref())?)
|
|
.json(json_body)
|
|
.send()
|
|
.await?
|
|
};
|
|
|
|
parse_response(res, true).await
|
|
}
|
|
}
|
|
|
|
// define those methods on the trait for nicer extensions (and not having to type the thing twice)
|
|
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
|
|
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
|
|
pub trait ApiClient {
|
|
/// 'get' json data from the segment-defined path, i.e. for example `["api", "v1", "mixnodes"]`,
|
|
/// with tuple defined key-value parameters, i.e. for example `[("since", "12345")]`
|
|
async fn get_json<T, K, V, E>(
|
|
&self,
|
|
path: PathSegments<'_>,
|
|
params: Params<'_, K, V>,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
for<'a> T: Deserialize<'a>,
|
|
K: AsRef<str> + Sync,
|
|
V: AsRef<str> + Sync,
|
|
E: Display + DeserializeOwned;
|
|
|
|
async fn post_json<B, T, K, V, E>(
|
|
&self,
|
|
path: PathSegments<'_>,
|
|
params: Params<'_, K, V>,
|
|
json_body: &B,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
B: Serialize + ?Sized + Sync,
|
|
for<'a> T: Deserialize<'a>,
|
|
K: AsRef<str> + Sync,
|
|
V: AsRef<str> + Sync,
|
|
E: Display + DeserializeOwned;
|
|
|
|
/// `get` json data from the provided absolute endpoint, i.e. for example `"/api/v1/mixnodes?since=12345"`
|
|
async fn get_json_from<T, S, E>(&self, endpoint: S) -> Result<T, HttpClientError<E>>
|
|
where
|
|
for<'a> T: Deserialize<'a>,
|
|
E: Display + DeserializeOwned,
|
|
S: AsRef<str> + Sync + Send;
|
|
|
|
async fn post_json_data_to<B, T, S, E>(
|
|
&self,
|
|
endpoint: S,
|
|
json_body: &B,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
B: Serialize + ?Sized + Sync,
|
|
for<'a> T: Deserialize<'a>,
|
|
E: Display + DeserializeOwned,
|
|
S: AsRef<str> + Sync + Send;
|
|
}
|
|
|
|
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
|
|
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
|
|
impl ApiClient for Client {
|
|
async fn get_json<T, K, V, E>(
|
|
&self,
|
|
path: PathSegments<'_>,
|
|
params: Params<'_, K, V>,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
for<'a> T: Deserialize<'a>,
|
|
K: AsRef<str> + Sync,
|
|
V: AsRef<str> + Sync,
|
|
E: Display + DeserializeOwned,
|
|
{
|
|
self.get_json(path, params).await
|
|
}
|
|
|
|
async fn post_json<B, T, K, V, E>(
|
|
&self,
|
|
path: PathSegments<'_>,
|
|
params: Params<'_, K, V>,
|
|
json_body: &B,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
B: Serialize + ?Sized + Sync,
|
|
for<'a> T: Deserialize<'a>,
|
|
K: AsRef<str> + Sync,
|
|
V: AsRef<str> + Sync,
|
|
E: Display + DeserializeOwned,
|
|
{
|
|
self.post_json(path, params, json_body).await
|
|
}
|
|
|
|
async fn get_json_from<T, S, E>(&self, endpoint: S) -> Result<T, HttpClientError<E>>
|
|
where
|
|
for<'a> T: Deserialize<'a>,
|
|
E: Display + DeserializeOwned,
|
|
S: AsRef<str> + Sync + Send,
|
|
{
|
|
self.get_json_endpoint(endpoint).await
|
|
}
|
|
|
|
async fn post_json_data_to<B, T, S, E>(
|
|
&self,
|
|
endpoint: S,
|
|
json_body: &B,
|
|
) -> Result<T, HttpClientError<E>>
|
|
where
|
|
B: Serialize + ?Sized + Sync,
|
|
for<'a> T: Deserialize<'a>,
|
|
E: Display + DeserializeOwned,
|
|
S: AsRef<str> + Sync + Send,
|
|
{
|
|
self.post_json_endpoint(endpoint, json_body).await
|
|
}
|
|
}
|
|
|
|
// utility function that should solve the double slash problem in API urls forever.
|
|
pub fn sanitize_url<K: AsRef<str>, V: AsRef<str>>(
|
|
base: &Url,
|
|
segments: PathSegments<'_>,
|
|
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!");
|
|
for segment in segments {
|
|
let segment = segment.strip_prefix('/').unwrap_or(segment);
|
|
let segment = segment.strip_suffix('/').unwrap_or(segment);
|
|
|
|
path_segments.push(segment);
|
|
}
|
|
// I don't understand why compiler couldn't figure out that it's no longer used
|
|
// and can be dropped
|
|
drop(path_segments);
|
|
|
|
if !params.is_empty() {
|
|
url.query_pairs_mut().extend_pairs(params);
|
|
}
|
|
|
|
url
|
|
}
|
|
|
|
async fn parse_response<T, E>(res: Response, allow_empty: bool) -> Result<T, HttpClientError<E>>
|
|
where
|
|
T: DeserializeOwned,
|
|
E: DeserializeOwned + Display,
|
|
{
|
|
let status = res.status();
|
|
|
|
if !allow_empty {
|
|
if let Some(0) = res.content_length() {
|
|
return Err(HttpClientError::EmptyResponse { status });
|
|
}
|
|
}
|
|
|
|
if res.status().is_success() {
|
|
Ok(res.json().await?)
|
|
} else if res.status() == StatusCode::NOT_FOUND {
|
|
Err(HttpClientError::NotFound)
|
|
} else {
|
|
let Ok(plaintext) = res.text().await else {
|
|
return Err(HttpClientError::RequestFailure { status });
|
|
};
|
|
|
|
if let Ok(request_error) = serde_json::from_str(&plaintext) {
|
|
Err(HttpClientError::EndpointFailure {
|
|
status,
|
|
error: request_error,
|
|
})
|
|
} else {
|
|
Err(HttpClientError::GenericRequestFailure(plaintext))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sanitizing_urls() {
|
|
let base_url: Url = "http://foomp.com".parse().unwrap();
|
|
|
|
// works with 1 segment
|
|
assert_eq!(
|
|
"http://foomp.com/foo",
|
|
sanitize_url(&base_url, &["foo"], NO_PARAMS).as_str()
|
|
);
|
|
|
|
// works with 2 segments
|
|
assert_eq!(
|
|
"http://foomp.com/foo/bar",
|
|
sanitize_url(&base_url, &["foo", "bar"], NO_PARAMS).as_str()
|
|
);
|
|
|
|
// works with leading slash
|
|
assert_eq!(
|
|
"http://foomp.com/foo",
|
|
sanitize_url(&base_url, &["/foo"], NO_PARAMS).as_str()
|
|
);
|
|
assert_eq!(
|
|
"http://foomp.com/foo/bar",
|
|
sanitize_url(&base_url, &["/foo", "bar"], NO_PARAMS).as_str()
|
|
);
|
|
assert_eq!(
|
|
"http://foomp.com/foo/bar",
|
|
sanitize_url(&base_url, &["foo", "/bar"], NO_PARAMS).as_str()
|
|
);
|
|
|
|
// works with trailing slash
|
|
assert_eq!(
|
|
"http://foomp.com/foo",
|
|
sanitize_url(&base_url, &["foo/"], NO_PARAMS).as_str()
|
|
);
|
|
assert_eq!(
|
|
"http://foomp.com/foo/bar",
|
|
sanitize_url(&base_url, &["foo/", "bar"], NO_PARAMS).as_str()
|
|
);
|
|
assert_eq!(
|
|
"http://foomp.com/foo/bar",
|
|
sanitize_url(&base_url, &["foo", "bar/"], NO_PARAMS).as_str()
|
|
);
|
|
|
|
// works with both leading and trailing slash
|
|
assert_eq!(
|
|
"http://foomp.com/foo",
|
|
sanitize_url(&base_url, &["/foo/"], NO_PARAMS).as_str()
|
|
);
|
|
assert_eq!(
|
|
"http://foomp.com/foo/bar",
|
|
sanitize_url(&base_url, &["/foo/", "/bar/"], NO_PARAMS).as_str()
|
|
);
|
|
|
|
// adds params
|
|
assert_eq!(
|
|
"http://foomp.com/foo/bar?foomp=baz",
|
|
sanitize_url(&base_url, &["foo", "bar"], &[("foomp", "baz")]).as_str()
|
|
);
|
|
assert_eq!(
|
|
"http://foomp.com/foo/bar?arg1=val1&arg2=val2",
|
|
sanitize_url(
|
|
&base_url,
|
|
&["/foo/", "/bar/"],
|
|
&[("arg1", "val1"), ("arg2", "val2")]
|
|
)
|
|
.as_str()
|
|
);
|
|
}
|
|
}
|