diff --git a/Cargo.lock b/Cargo.lock index 6a09ca15d0..b60d064a0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5909,7 +5909,7 @@ dependencies = [ "nym-config", "nym-credential-storage", "nym-crypto", - "nym-explorer-api-requests", + "nym-explorer-client", "nym-gateway-client", "nym-gateway-requests", "nym-network-defaults", @@ -6151,6 +6151,19 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "nym-explorer-client" +version = "0.1.0" +dependencies = [ + "log", + "nym-explorer-api-requests", + "reqwest", + "serde", + "thiserror", + "tokio", + "url", +] + [[package]] name = "nym-gateway" version = "1.1.27" diff --git a/Cargo.toml b/Cargo.toml index 2c505c4bfe..a1634e02d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ members = [ "common/wasm-utils", "explorer-api", "explorer-api/explorer-api-requests", + "explorer-api/explorer-client", "gateway", "gateway/gateway-requests", "integrations/bity", diff --git a/clients/webassembly/Cargo.lock b/clients/webassembly/Cargo.lock index 271ea93ea8..e39f1ef98c 100644 --- a/clients/webassembly/Cargo.lock +++ b/clients/webassembly/Cargo.lock @@ -2514,7 +2514,7 @@ dependencies = [ "nym-config", "nym-credential-storage", "nym-crypto", - "nym-explorer-api-requests", + "nym-explorer-client", "nym-gateway-client", "nym-gateway-requests", "nym-network-defaults", @@ -2752,6 +2752,18 @@ dependencies = [ "serde", ] +[[package]] +name = "nym-explorer-client" +version = "0.1.0" +dependencies = [ + "log", + "nym-explorer-api-requests", + "reqwest", + "serde", + "thiserror", + "url", +] + [[package]] name = "nym-gateway-client" version = "0.1.0" diff --git a/common/client-core/Cargo.toml b/common/client-core/Cargo.toml index 56733b5ab8..1c5c40a6e1 100644 --- a/common/client-core/Cargo.toml +++ b/common/client-core/Cargo.toml @@ -33,7 +33,7 @@ zeroize = { workspace = true } nym-bandwidth-controller = { path = "../bandwidth-controller" } nym-config = { path = "../config" } nym-crypto = { path = "../crypto" } -nym-explorer-api-requests = { path = "../../explorer-api/explorer-api-requests" } +nym-explorer-client = { path = "../../explorer-api/explorer-client" } nym-gateway-client = { path = "../client-libs/gateway-client" } #gateway-client = { path = "../../common/client-libs/gateway-client", default-features = false, features = ["wasm", "coconut"] } nym-gateway-requests = { path = "../../gateway/gateway-requests" } diff --git a/common/client-core/src/client/topology_control/geo_aware_provider.rs b/common/client-core/src/client/topology_control/geo_aware_provider.rs index 5239f87411..7a46731d26 100644 --- a/common/client-core/src/client/topology_control/geo_aware_provider.rs +++ b/common/client-core/src/client/topology_control/geo_aware_provider.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, fmt}; use log::{debug, error, info}; -use nym_explorer_api_requests::{PrettyDetailedGatewayBond, PrettyDetailedMixNodeBond}; +use nym_explorer_client::{ExplorerClient, PrettyDetailedGatewayBond, PrettyDetailedMixNodeBond}; use nym_network_defaults::var_names::EXPLORER_API; use nym_topology::{ nym_topology_from_detailed, @@ -18,55 +18,24 @@ use crate::config::GroupBy; const MIN_NODES_PER_LAYER: usize = 1; -#[cfg(target_arch = "wasm32")] -fn reqwest_client() -> Option { - reqwest::Client::builder().build().ok() -} +fn create_explorer_client() -> Option { + let Ok(explorer_api_url) = std::env::var(EXPLORER_API) else { + error!("Missing EXPLORER_API"); + return None; + }; -#[cfg(not(target_arch = "wasm32"))] -fn reqwest_client() -> Option { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .ok() -} + let Ok(explorer_api_url) = explorer_api_url.parse() else { + error!("Failed to parse EXPLORER_API"); + return None; + }; -// TODO: create a explorer-api-client -async fn fetch_mixnodes_from_explorer_api() -> Option> { - let explorer_api_url = std::env::var(EXPLORER_API).ok()?; - let explorer_api_url = Url::parse(&explorer_api_url) - .ok()? - .join("v1/mix-nodes") - .ok()?; + log::debug!("Using explorer-api url: {}", explorer_api_url); + let Ok(client) = nym_explorer_client::ExplorerClient::new(explorer_api_url) else { + error!("Failed to create explorer-api client"); + return None; + }; - debug!("Fetching: {}", explorer_api_url); - reqwest_client()? - .get(explorer_api_url) - .send() - .await - .ok()? - .json::>() - .await - .ok() -} - -// TODO: create a explorer-api-client -async fn fetch_gateways_from_explorer_api() -> Option> { - let explorer_api_url = std::env::var(EXPLORER_API).ok()?; - let explorer_api_url = Url::parse(&explorer_api_url) - .ok()? - .join("v1/gateways") - .ok()?; - - debug!("Fetching: {}", explorer_api_url); - reqwest_client()? - .get(explorer_api_url) - .send() - .await - .ok()? - .json::>() - .await - .ok() + Some(client) } #[derive(Copy, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Debug)] @@ -83,6 +52,8 @@ pub enum CountryGroup { impl CountryGroup { // We map contry codes into group, which initially are continent codes to a first approximation, // but we do it manually to reserve the right to tweak this distribution for our purposes. + // NOTE: I did this quickly and it's not a complete list of all countries, but only those that + // were present in the network at the time. Please add more as needed. fn new(country_code: &str) -> Self { let country_code = country_code.to_uppercase(); use CountryGroup::*; @@ -337,13 +308,14 @@ impl GeoAwareTopologyProvider { // Also fetch mixnodes cached by explorer-api, with the purpose of getting their // geolocation. debug!("Fetching mixnodes from explorer-api..."); - let Some(mixnodes_from_explorer_api) = fetch_mixnodes_from_explorer_api().await else { + let explorer_client = create_explorer_client()?; + let Ok(mixnodes_from_explorer_api) = explorer_client.get_mixnodes().await else { error!("failed to get mixnodes from explorer-api"); return None; }; debug!("Fetching gateways from explorer-api..."); - let Some(gateways_from_explorer_api) = fetch_gateways_from_explorer_api().await else { + let Ok(gateways_from_explorer_api) = explorer_client.get_gateways().await else { error!("failed to get mixnodes from explorer-api"); return None; }; diff --git a/explorer-api/explorer-client/Cargo.toml b/explorer-api/explorer-client/Cargo.toml new file mode 100644 index 0000000000..769813b3b0 --- /dev/null +++ b/explorer-api/explorer-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nym-explorer-client" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +log.workspace = true +nym-explorer-api-requests = { path = "../explorer-api-requests" } +reqwest = { workspace = true, features = ["json"] } +serde.workspace = true +thiserror.workspace = true +url.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } diff --git a/explorer-api/explorer-client/src/lib.rs b/explorer-api/explorer-client/src/lib.rs new file mode 100644 index 0000000000..acb1cc7e67 --- /dev/null +++ b/explorer-api/explorer-client/src/lib.rs @@ -0,0 +1,96 @@ +use std::time::Duration; + +use reqwest::StatusCode; +use thiserror::Error; +use url::Url; + +// Re-export request types +pub use nym_explorer_api_requests::{PrettyDetailedGatewayBond, PrettyDetailedMixNodeBond}; + +// Paths +const API_VERSION: &str = "v1"; +const MIXNODES: &str = "mix-nodes"; +const GATEWAYS: &str = "gateways"; + +#[cfg(not(target_arch = "wasm32"))] +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Error)] +pub enum ExplorerApiError { + #[error("REST request error: {0}")] + ReqwestError(#[from] reqwest::Error), + + #[error("URL parse error: {0}")] + UrlParseError(#[from] url::ParseError), + + #[error("not found")] + NotFound, + + #[error("request failure: {0}")] + RequestFailure(String), +} + +pub struct ExplorerClient { + url: Url, + client: reqwest::Client, +} + +impl ExplorerClient { + #[cfg(not(target_arch = "wasm32"))] + pub fn new(url: url::Url) -> Result { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build()?; + Ok(Self { client, url }) + } + + #[cfg(target_arch = "wasm32")] + pub fn new(url: url::Url) -> Result { + let client = reqwest::Client::builder().build()?; + Ok(Self { client, url }) + } + + async fn send_get_request( + &self, + paths: &[&str], + ) -> Result { + let url = combine_url(self.url.clone(), paths)?; + log::trace!("Sending GET request {url:?}"); + Ok(self.client.get(url).send().await?) + } + + async fn query_explorer_api(&self, paths: &[&str]) -> Result + where + T: std::fmt::Debug, + T: for<'a> serde::Deserialize<'a>, + { + let response = self.send_get_request(paths).await?; + if response.status().is_success() { + let res = response.json::().await?; + log::trace!("Got response: {res:?}"); + Ok(res) + } else if response.status() == StatusCode::NOT_FOUND { + Err(ExplorerApiError::NotFound) + } else { + Err(ExplorerApiError::RequestFailure(response.text().await?)) + } + } + + pub async fn get_mixnodes(&self) -> Result, ExplorerApiError> { + self.query_explorer_api(&[API_VERSION, MIXNODES]).await + } + + pub async fn get_gateways(&self) -> Result, ExplorerApiError> { + self.query_explorer_api(&[API_VERSION, GATEWAYS]).await + } +} + +fn combine_url(mut base_url: Url, paths: &[&str]) -> Result { + { + let mut segments = base_url.path_segments_mut().expect("failed to parse url"); + for path in paths { + segments.push(path); + } + } + Ok(base_url) +}