diff --git a/nym-api/enter_db.sh b/nym-api/enter_db.sh index 7d91be72bf..39453ce20b 100755 --- a/nym-api/enter_db.sh +++ b/nym-api/enter_db.sh @@ -1,2 +1,2 @@ #!/bin/sh -sqlite3 -init settings.sql /Users/jedrzej/workspace/nym/target/debug/build/nym-api-6517e259cadeb663/out/nym-api-example.sqlite \ No newline at end of file +sqlite3 -init settings.sql /Users/jedrzej/workspace/nym/target/debug/build/nym-api-3a0aa50356183aba/out/nym-api-example.sqlite \ No newline at end of file diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index c09421f2f6..b4ca18dd76 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -19,7 +19,7 @@ use crate::support::http::state::force_refresh::ForcedRefresh; use crate::support::http::state::AppState; use crate::support::nyxd::Client; use crate::support::storage::NymApiStorage; -use crate::unstable_routes::account::cache::AddressInfoCache; +use crate::unstable_routes::v1::account::cache::AddressInfoCache; use async_trait::async_trait; use axum::Router; use axum_test::http::StatusCode; diff --git a/nym-api/src/nym_nodes/handlers/mod.rs b/nym-api/src/nym_nodes/handlers/mod.rs index c4177bc99d..ed871b096c 100644 --- a/nym-api/src/nym_nodes/handlers/mod.rs +++ b/nym-api/src/nym_nodes/handlers/mod.rs @@ -24,7 +24,6 @@ use tower_http::compression::CompressionLayer; use utoipa::{IntoParams, ToSchema}; pub(crate) mod legacy; -pub(crate) mod unstable; pub(crate) fn nym_node_routes() -> Router { Router::new() diff --git a/nym-api/src/nym_nodes/handlers/unstable/helpers.rs b/nym-api/src/nym_nodes/handlers/unstable/helpers.rs deleted file mode 100644 index 3940d99237..0000000000 --- a/nym-api/src/nym_nodes/handlers/unstable/helpers.rs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2024 - Nym Technologies SA -// SPDX-License-Identifier: GPL-3.0-only - -use nym_api_requests::models::{ - GatewayBondAnnotated, MalformedNodeBond, MixNodeBondAnnotated, OffsetDateTimeJsonSchemaWrapper, -}; -use nym_api_requests::nym_nodes::{NodeRole, SkimmedNode}; -use nym_mixnet_contract_common::reward_params::Performance; -use time::OffsetDateTime; - -pub(crate) trait LegacyAnnotation { - fn performance(&self) -> Performance; - - fn identity(&self) -> &str; - - fn try_to_skimmed_node(&self, role: NodeRole) -> Result; -} - -impl LegacyAnnotation for MixNodeBondAnnotated { - fn performance(&self) -> Performance { - self.node_performance.last_24h - } - - fn identity(&self) -> &str { - self.identity_key() - } - - fn try_to_skimmed_node(&self, role: NodeRole) -> Result { - self.try_to_skimmed_node(role) - } -} - -impl LegacyAnnotation for GatewayBondAnnotated { - fn performance(&self) -> Performance { - self.node_performance.last_24h - } - - fn identity(&self) -> &str { - self.identity() - } - - fn try_to_skimmed_node(&self, role: NodeRole) -> Result { - self.try_to_skimmed_node(role) - } -} - -pub(crate) fn refreshed_at( - iter: impl IntoIterator, -) -> OffsetDateTimeJsonSchemaWrapper { - iter.into_iter() - .min() - .unwrap_or(OffsetDateTime::UNIX_EPOCH) - .into() -} diff --git a/nym-api/src/nym_nodes/handlers/unstable/mod.rs b/nym-api/src/nym_nodes/handlers/unstable/mod.rs deleted file mode 100644 index 92d695ddfc..0000000000 --- a/nym-api/src/nym_nodes/handlers/unstable/mod.rs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright 2024 - Nym Technologies SA -// SPDX-License-Identifier: GPL-3.0-only - -//! All routes/nodes are split into three tiers: -//! -//! `/skimmed` -//! - used by clients -//! - returns the very basic information for routing purposes -//! -//! `/semi-skimmed` -//! - used by other nodes/VPN -//! - returns more additional information such noise keys -//! -//! `/full-fat` -//! - used by explorers, et al. -//! - returns almost everything there is about the nodes -//! -//! There's also additional split based on the role: -//! - `?role` => filters based on the specific role (mixnode/gateway/(in the future: entry/exit)) -//! - `/mixnodes/` => only returns mixnode role data -//! - `/gateway/` => only returns (entry) gateway role data - -use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; -use crate::nym_nodes::handlers::unstable::full_fat::nodes_detailed; -use crate::nym_nodes::handlers::unstable::semi_skimmed::nodes_expanded; -use crate::nym_nodes::handlers::unstable::skimmed::{ - entry_gateways_basic_active, entry_gateways_basic_all, exit_gateways_basic_active, - exit_gateways_basic_all, mixnodes_basic_active, mixnodes_basic_all, nodes_basic_active, - nodes_basic_all, -}; -use crate::support::http::helpers::PaginationRequest; -use crate::support::http::state::AppState; -use axum::extract::{Query, State}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use nym_api_requests::nym_nodes::{ - NodeRoleQueryParam, NodesByAddressesRequestBody, NodesByAddressesResponse, -}; -use nym_http_api_common::{FormattedResponse, Output, OutputParams}; -use serde::Deserialize; -use std::collections::HashMap; -use tower_http::compression::CompressionLayer; - -pub(crate) mod full_fat; -mod helpers; -pub(crate) mod semi_skimmed; -pub(crate) mod skimmed; - -#[allow(deprecated)] -pub(crate) fn routes() -> Router { - Router::new() - .nest( - "/skimmed", - Router::new() - .route("/", get(nodes_basic_all)) - .route("/active", get(nodes_basic_active)) - .nest( - "/mixnodes", - Router::new() - .route("/active", get(mixnodes_basic_active)) - .route("/all", get(mixnodes_basic_all)), - ) - .nest( - "/entry-gateways", - Router::new() - .route("/active", get(entry_gateways_basic_active)) - .route("/all", get(entry_gateways_basic_all)), - ) - .nest( - "/exit-gateways", - Router::new() - .route("/active", get(exit_gateways_basic_active)) - .route("/all", get(exit_gateways_basic_all)), - ), - ) - .nest( - "/semi-skimmed", - Router::new().route("/", get(nodes_expanded)), - ) - .nest("/full-fat", Router::new().route("/", get(nodes_detailed))) - .route("/gateways/skimmed", get(skimmed::deprecated_gateways_basic)) - .route("/mixnodes/skimmed", get(skimmed::deprecated_mixnodes_basic)) - .route("/by-addresses", post(nodes_by_addresses)) - .layer(CompressionLayer::new()) -} - -#[derive(Debug, Deserialize, utoipa::IntoParams)] -struct NodesParamsWithRole { - #[param(inline)] - role: Option, - - #[allow(dead_code)] - semver_compatibility: Option, - no_legacy: Option, - page: Option, - per_page: Option, - - // Identifier for the current epoch of the topology state. When sent by a client we can check if - // the client already knows about the latest topology state, allowing a `no-updates` response - // instead of wasting bandwidth serving an unchanged topology. - epoch_id: Option, - - output: Option, -} - -#[derive(Debug, Deserialize, utoipa::IntoParams)] -#[into_params(parameter_in = Query)] -struct NodesParams { - #[allow(dead_code)] - semver_compatibility: Option, - no_legacy: Option, - page: Option, - per_page: Option, - - // Identifier for the current epoch of the topology state. When sent by a client we can check if - // the client already knows about the latest topology state, allowing a `no-updates` response - // instead of wasting bandwidth serving an unchanged topology. - epoch_id: Option, - output: Option, -} - -impl From for NodesParams { - fn from(params: NodesParamsWithRole) -> Self { - NodesParams { - semver_compatibility: params.semver_compatibility, - no_legacy: params.no_legacy, - page: params.page, - per_page: params.per_page, - epoch_id: params.epoch_id, - output: params.output, - } - } -} - -impl<'a> From<&'a NodesParams> for PaginationRequest { - fn from(params: &'a NodesParams) -> Self { - PaginationRequest { - output: params.output, - page: params.page, - per_page: params.per_page, - } - } -} - -#[utoipa::path( - tag = "Unstable Nym Nodes", - post, - request_body = NodesByAddressesRequestBody, - path = "/by-addresses", - context_path = "/v1/unstable/nym-nodes", - responses( - (status = 200, content( - (NodesByAddressesResponse = "application/json"), - (NodesByAddressesResponse = "application/yaml"), - (NodesByAddressesResponse = "application/bincode") - )) - ), - params(OutputParams) -)] -async fn nodes_by_addresses( - Query(output): Query, - state: State, - Json(body): Json, -) -> AxumResult> { - // if the request is too big, simply reject it - if body.addresses.len() > 100 { - return Err(AxumErrorResponse::bad_request( - "requested too many addresses", - )); - } - - let output = output.output.unwrap_or_default(); - - // TODO: perhaps introduce different cache because realistically nym-api will receive - // request for the same couple addresses from all nodes in quick succession - let describe_cache = state.describe_nodes_cache_data().await?; - - let mut existence = HashMap::new(); - for address in body.addresses { - existence.insert(address, describe_cache.node_with_address(address)); - } - - Ok(output.to_response(NodesByAddressesResponse { existence })) -} diff --git a/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs b/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs deleted file mode 100644 index b467892c70..0000000000 --- a/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs +++ /dev/null @@ -1,643 +0,0 @@ -// Copyright 2024 - Nym Technologies SA -// SPDX-License-Identifier: GPL-3.0-only - -use crate::node_describe_cache::cache::DescribedNodes; -use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; -use crate::nym_nodes::handlers::unstable::helpers::{refreshed_at, LegacyAnnotation}; -use crate::nym_nodes::handlers::unstable::{NodesParams, NodesParamsWithRole}; -use crate::support::caching::Cache; -use crate::support::http::state::AppState; -use axum::extract::{Query, State}; -use nym_api_requests::models::{ - NodeAnnotation, NymNodeDescription, OffsetDateTimeJsonSchemaWrapper, -}; -use nym_api_requests::nym_nodes::{ - CachedNodesResponse, NodeRole, NodeRoleQueryParam, PaginatedCachedNodesResponse, SkimmedNode, -}; -use nym_api_requests::pagination::PaginatedResponse; -use nym_http_api_common::{FormattedResponse, Output}; -use nym_mixnet_contract_common::NodeId; -use nym_topology::CachedEpochRewardedSet; -use std::collections::HashMap; -use std::future::Future; -use std::time::Duration; -use tokio::sync::RwLockReadGuard; -use tracing::trace; -use utoipa::ToSchema; - -pub type PaginatedSkimmedNodes = - AxumResult>>; - -/// Given all relevant caches, build part of response for JUST Nym Nodes -fn build_nym_nodes_response<'a, NI>( - rewarded_set: &CachedEpochRewardedSet, - nym_nodes_subset: NI, - annotations: &HashMap, - current_key_rotation: u32, - active_only: bool, -) -> Vec -where - NI: Iterator + 'a, -{ - let mut nodes = Vec::new(); - for nym_node in nym_nodes_subset { - let node_id = nym_node.node_id; - - let role: NodeRole = rewarded_set.role(node_id).into(); - - // if the role is inactive, see if our filter allows it - if active_only && role.is_inactive() { - continue; - } - - // honestly, not sure under what exact circumstances this value could be missing, - // but in that case just use 0 performance - let annotation = annotations.get(&node_id).copied().unwrap_or_default(); - - nodes.push(nym_node.to_skimmed_node( - current_key_rotation, - role, - annotation.last_24h_performance, - )); - } - nodes -} - -/// Given all relevant caches, add appropriate legacy nodes to the part of the response -fn add_legacy( - nodes: &mut Vec, - rewarded_set: &CachedEpochRewardedSet, - describe_cache: &DescribedNodes, - annotated_legacy_nodes: &HashMap, - current_key_rotation: u32, - active_only: bool, -) where - LN: LegacyAnnotation, -{ - for (node_id, legacy) in annotated_legacy_nodes.iter() { - let role: NodeRole = rewarded_set.role(*node_id).into(); - - // if the role is inactive, see if our filter allows it - if active_only && role.is_inactive() { - continue; - } - - // if we have self-described info, prefer it over contract data - if let Some(described) = describe_cache.get_node(node_id) { - // legacy nodes don't support key rotation - nodes.push(described.to_skimmed_node(current_key_rotation, role, legacy.performance())) - } else { - match legacy.try_to_skimmed_node(role) { - Ok(node) => nodes.push(node), - Err(err) => { - let id = legacy.identity(); - trace!("node {id} is malformed: {err}") - } - } - } - } -} - -// hehe, what an abomination, but it's used in multiple different places and I hate copy-pasting code, -// especially if it has multiple loops, etc -async fn build_skimmed_nodes_response<'a, NI, LG, Fut, LN>( - state: &'a AppState, - Query(query_params): Query, - nym_nodes_subset: NI, - annotated_legacy_nodes_getter: LG, - active_only: bool, - output: Output, -) -> PaginatedSkimmedNodes -where - // iterator returning relevant subset of nym-nodes (like mixing nym-nodes, entries, etc.) - NI: Iterator + 'a, - - // async function that returns cache of appropriate legacy nodes (mixnodes or gateways) - LG: Fn(&'a AppState) -> Fut, - Fut: - Future>>, AxumErrorResponse>>, - - // the legacy node (MixNodeBondAnnotated or GatewayBondAnnotated) - LN: LegacyAnnotation + 'a, -{ - // TODO: implement it - let _ = query_params.per_page; - let _ = query_params.page; - - // 1. get the rewarded set - let rewarded_set = state.rewarded_set().await?; - - // 2. grab all annotations so that we could attach scores to the [nym] nodes - let annotations = state.node_annotations().await?; - - // 3. implicitly grab the relevant described nodes - // (ideally it'd be tied directly to the NI iterator, but I couldn't defeat the compiler) - let describe_cache = state.describe_nodes_cache_data().await?; - - let contract_cache = state.nym_contract_cache(); - - let interval = contract_cache.current_interval().await?; - let current_key_rotation = contract_cache.current_key_rotation_id().await?; - - // 4.0 If the client indicates that they already know about the current topology send empty response - if let Some(client_known_epoch) = query_params.epoch_id { - if client_known_epoch == interval.current_epoch_id() { - return Ok(output.to_response(PaginatedCachedNodesResponse::no_updates( - interval.current_epoch_absolute_id(), - current_key_rotation, - ))); - } - } - - // 4. start building the response - let mut nodes = build_nym_nodes_response( - &rewarded_set, - nym_nodes_subset, - &annotations, - current_key_rotation, - active_only, - ); - - // 5. if we allow legacy nodes, repeat the procedure for them, otherwise return just nym-nodes - if let Some(true) = query_params.no_legacy { - // min of all caches - let refreshed_at = refreshed_at([ - rewarded_set.timestamp(), - annotations.timestamp(), - describe_cache.timestamp(), - ]); - - return Ok(output.to_response( - PaginatedCachedNodesResponse::new_full( - interval.current_epoch_absolute_id(), - current_key_rotation, - refreshed_at, - nodes, - ) - .fresh(interval), - )); - } - - // 6. grab relevant legacy nodes - // (due to the existence of the legacy endpoints, we already have fully annotated data on them) - let annotated_legacy_nodes = annotated_legacy_nodes_getter(state).await?; - add_legacy( - &mut nodes, - &rewarded_set, - &describe_cache, - &annotated_legacy_nodes, - current_key_rotation, - active_only, - ); - - // min of all caches - let refreshed_at = refreshed_at([ - rewarded_set.timestamp(), - annotations.timestamp(), - describe_cache.timestamp(), - annotated_legacy_nodes.timestamp(), - ]); - - let base_response = output.to_response( - PaginatedCachedNodesResponse::new_full( - interval.current_epoch_absolute_id(), - current_key_rotation, - refreshed_at, - nodes, - ) - .fresh(interval), - ); - - if !active_only { - return Ok(base_response); - } - - // if caller requested only active nodes, the response is valid until the epoch changes - // (but add 2 minutes due to epoch transition not being instantaneous - let epoch_end = interval.current_epoch_end(); - let expiration = epoch_end + Duration::from_secs(120); - Ok(base_response.with_expires_header(expiration)) -} - -/// Deprecated query that gets ALL gateways -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/gateways/skimmed", - context_path = "/v1/unstable/nym-nodes", - responses( - (status = 200, content( - (CachedNodesResponse = "application/json"), - (CachedNodesResponse = "application/yaml"), - (CachedNodesResponse = "application/bincode") - )) - ), -)] -#[deprecated(note = "use '/v1/unstable/nym-nodes/entry-gateways/skimmed/all' instead")] -pub(super) async fn deprecated_gateways_basic( - state: State, - query_params: Query, -) -> AxumResult>> { - let output = query_params.output.unwrap_or_default(); - - // 1. call '/v1/unstable/skimmed/entry-gateways/all' - let all_gateways = entry_gateways_basic_all(state, query_params) - .await? - .into_inner(); - - // 3. return result - Ok(output.to_response(CachedNodesResponse { - refreshed_at: all_gateways.metadata.refreshed_at, - // 2. remove pagination - nodes: all_gateways.nodes.data, - })) -} - -/// Deprecated query that gets ACTIVE-ONLY mixnodes -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/mixnodes/skimmed", - context_path = "/v1/unstable/nym-nodes", - responses( - (status = 200, content( - (CachedNodesResponse = "application/json"), - (CachedNodesResponse = "application/yaml"), - (CachedNodesResponse = "application/bincode") - )) - ), -)] -#[deprecated(note = "use '/v1/unstable/nym-nodes/skimmed/mixnodes/active' instead")] -pub(super) async fn deprecated_mixnodes_basic( - state: State, - query_params: Query, -) -> AxumResult>> { - let output = query_params.output.unwrap_or_default(); - - // 1. call '/v1/unstable/nym-nodes/skimmed/mixnodes/active' - let active_mixnodes = mixnodes_basic_active(state, query_params) - .await? - .into_inner(); - - // 3. return result - Ok(output.to_response(CachedNodesResponse { - refreshed_at: active_mixnodes.metadata.refreshed_at, - // 2. remove pagination - nodes: active_mixnodes.nodes.data, - })) -} - -async fn nodes_basic( - state: State, - Query(query_params): Query, - active_only: bool, -) -> PaginatedSkimmedNodes { - let output = query_params.output.unwrap_or_default(); - - // unfortunately we have to build the response semi-manually here as we need to add two sources of legacy nodes - - // 1. grab all relevant described nym-nodes - let rewarded_set = state.rewarded_set().await?; - - let describe_cache = state.describe_nodes_cache_data().await?; - let all_nym_nodes = describe_cache.all_nym_nodes(); - let annotations = state.node_annotations().await?; - let legacy_mixnodes = state.legacy_mixnode_annotations().await?; - let legacy_gateways = state.legacy_gateways_annotations().await?; - - let interval = state.nym_contract_cache().current_interval().await?; - let current_key_rotation = state.nym_contract_cache().current_key_rotation_id().await?; - - let mut nodes = build_nym_nodes_response( - &rewarded_set, - all_nym_nodes, - &annotations, - current_key_rotation, - active_only, - ); - - // add legacy gateways to the response - add_legacy( - &mut nodes, - &rewarded_set, - &describe_cache, - &legacy_gateways, - current_key_rotation, - active_only, - ); - - // add legacy mixnodes to the response - add_legacy( - &mut nodes, - &rewarded_set, - &describe_cache, - &legacy_mixnodes, - current_key_rotation, - active_only, - ); - - // min of all caches - let refreshed_at = refreshed_at([ - rewarded_set.timestamp(), - annotations.timestamp(), - describe_cache.timestamp(), - legacy_mixnodes.timestamp(), - legacy_gateways.timestamp(), - ]); - - Ok(output.to_response(PaginatedCachedNodesResponse::new_full( - current_key_rotation, - interval.current_epoch_absolute_id(), - refreshed_at, - nodes, - ))) -} - -#[allow(dead_code)] // not dead, used in OpenAPI docs -#[derive(ToSchema)] -#[schema(title = "PaginatedCachedNodesResponse")] -pub struct PaginatedCachedNodesResponseSchema { - pub refreshed_at: OffsetDateTimeJsonSchemaWrapper, - #[schema(value_type = SkimmedNode)] - pub nodes: PaginatedResponse, -} - -/// Return all Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used) -/// that are currently bonded. -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParamsWithRole), - path = "", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, content( - (PaginatedCachedNodesResponseSchema = "application/json"), - (PaginatedCachedNodesResponseSchema = "application/yaml"), - (PaginatedCachedNodesResponseSchema = "application/bincode") - )) - ), -)] -pub(super) async fn nodes_basic_all( - state: State, - Query(query_params): Query, -) -> PaginatedSkimmedNodes { - if let Some(role) = query_params.role { - return match role { - NodeRoleQueryParam::ActiveMixnode => { - mixnodes_basic_all(state, Query(query_params.into())).await - } - NodeRoleQueryParam::EntryGateway => { - entry_gateways_basic_all(state, Query(query_params.into())).await - } - NodeRoleQueryParam::ExitGateway => { - exit_gateways_basic_all(state, Query(query_params.into())).await - } - }; - } - - nodes_basic(state, Query(query_params.into()), false).await -} - -/// Return Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used) -/// that are currently bonded and are in the **active set** -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/active", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, content( - (PaginatedCachedNodesResponseSchema = "application/json"), - (PaginatedCachedNodesResponseSchema = "application/yaml"), - (PaginatedCachedNodesResponseSchema = "application/bincode") - )) - ), -)] -pub(super) async fn nodes_basic_active( - state: State, - Query(query_params): Query, -) -> PaginatedSkimmedNodes { - if let Some(role) = query_params.role { - return match role { - NodeRoleQueryParam::ActiveMixnode => { - mixnodes_basic_active(state, Query(query_params.into())).await - } - NodeRoleQueryParam::EntryGateway => { - entry_gateways_basic_active(state, Query(query_params.into())).await - } - NodeRoleQueryParam::ExitGateway => { - exit_gateways_basic_active(state, Query(query_params.into())).await - } - }; - } - - nodes_basic(state, Query(query_params.into()), true).await -} - -async fn mixnodes_basic( - state: State, - query_params: Query, - active_only: bool, -) -> PaginatedSkimmedNodes { - let output = query_params.output.unwrap_or_default(); - - // 1. grab all relevant described nym-nodes - let describe_cache = state.describe_nodes_cache_data().await?; - let mixing_nym_nodes = describe_cache.mixing_nym_nodes(); - - build_skimmed_nodes_response( - &state.0, - query_params, - mixing_nym_nodes, - |state| state.legacy_mixnode_annotations(), - active_only, - output, - ) - .await -} - -/// Returns Nym Nodes and optionally legacy mixnodes (if `no-legacy` flag is not used) -/// that are currently bonded and support mixing role. -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/mixnodes/all", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, content( - (PaginatedCachedNodesResponseSchema = "application/json"), - (PaginatedCachedNodesResponseSchema = "application/yaml"), - (PaginatedCachedNodesResponseSchema = "application/bincode") - )) - ), -)] -pub(super) async fn mixnodes_basic_all( - state: State, - query_params: Query, -) -> PaginatedSkimmedNodes { - mixnodes_basic(state, query_params, false).await -} - -/// Returns Nym Nodes and optionally legacy mixnodes (if `no-legacy` flag is not used) -/// that are currently bonded and are in the active set with one of the mixing roles. -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/mixnodes/active", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, content( - (PaginatedCachedNodesResponseSchema = "application/json"), - (PaginatedCachedNodesResponseSchema = "application/yaml"), - (PaginatedCachedNodesResponseSchema = "application/bincode") - )) - ), -)] -pub(super) async fn mixnodes_basic_active( - state: State, - query_params: Query, -) -> PaginatedSkimmedNodes { - mixnodes_basic(state, query_params, true).await -} - -async fn entry_gateways_basic( - state: State, - query_params: Query, - active_only: bool, -) -> PaginatedSkimmedNodes { - let output = query_params.output.unwrap_or_default(); - - // 1. grab all relevant described nym-nodes - let describe_cache = state.describe_nodes_cache_data().await?; - let mixing_nym_nodes = describe_cache.entry_capable_nym_nodes(); - - build_skimmed_nodes_response( - &state.0, - query_params, - mixing_nym_nodes, - |state| state.legacy_gateways_annotations(), - active_only, - output, - ) - .await -} - -/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) -/// that are currently bonded and are in the active set with the entry role. -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/entry-gateways/active", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, content( - (PaginatedCachedNodesResponseSchema = "application/json"), - (PaginatedCachedNodesResponseSchema = "application/yaml"), - (PaginatedCachedNodesResponseSchema = "application/bincode") - )) - ), -)] -pub(super) async fn entry_gateways_basic_active( - state: State, - query_params: Query, -) -> PaginatedSkimmedNodes { - entry_gateways_basic(state, query_params, true).await -} - -/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) -/// that are currently bonded and support entry gateway role. -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/entry-gateways/all", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, content( - (PaginatedCachedNodesResponseSchema = "application/json"), - (PaginatedCachedNodesResponseSchema = "application/yaml"), - (PaginatedCachedNodesResponseSchema = "application/bincode") - )) - ), -)] -pub(super) async fn entry_gateways_basic_all( - state: State, - query_params: Query, -) -> PaginatedSkimmedNodes { - entry_gateways_basic(state, query_params, false).await -} - -async fn exit_gateways_basic( - state: State, - query_params: Query, - active_only: bool, -) -> PaginatedSkimmedNodes { - let output = query_params.output.unwrap_or_default(); - - // 1. grab all relevant described nym-nodes - let describe_cache = state.describe_nodes_cache_data().await?; - let mixing_nym_nodes = describe_cache.exit_capable_nym_nodes(); - - build_skimmed_nodes_response( - &state.0, - query_params, - mixing_nym_nodes, - |state| state.legacy_gateways_annotations(), - active_only, - output, - ) - .await -} - -/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) -/// that are currently bonded and are in the active set with the exit role. -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/exit-gateways/active", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, content( - (PaginatedCachedNodesResponseSchema = "application/json"), - (PaginatedCachedNodesResponseSchema = "application/yaml"), - (PaginatedCachedNodesResponseSchema = "application/bincode") - )) - ), -)] -pub(super) async fn exit_gateways_basic_active( - state: State, - query_params: Query, -) -> PaginatedSkimmedNodes { - exit_gateways_basic(state, query_params, true).await -} - -/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) -/// that are currently bonded and support exit gateway role. -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParams), - path = "/exit-gateways/all", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, content( - (PaginatedCachedNodesResponseSchema = "application/json"), - (PaginatedCachedNodesResponseSchema = "application/yaml"), - (PaginatedCachedNodesResponseSchema = "application/bincode") - )) - ), -)] -pub(super) async fn exit_gateways_basic_all( - state: State, - query_params: Query, -) -> PaginatedSkimmedNodes { - exit_gateways_basic(state, query_params, false).await -} diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index 061cd5afc8..741b2d3f90 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -28,7 +28,7 @@ use crate::support::http::{RouterBuilder, ShutdownHandles, TASK_MANAGER_TIMEOUT_ use crate::support::nyxd; use crate::support::storage::runtime_migrations::m001_directory_services_v2_1::migrate_to_directory_services_v2_1; use crate::support::storage::NymApiStorage; -use crate::unstable_routes::account::cache::AddressInfoCache; +use crate::unstable_routes::v1::account::cache::AddressInfoCache; use crate::{ circulating_supply_api, ecash, epoch_operations, network_monitor, node_describe_cache, node_status_api, nym_contract_cache, diff --git a/nym-api/src/support/http/mod.rs b/nym-api/src/support/http/mod.rs index 7c6aeebdc2..39049f38d3 100644 --- a/nym-api/src/support/http/mod.rs +++ b/nym-api/src/support/http/mod.rs @@ -1,17 +1,16 @@ // Copyright 2022-2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use nym_task::TaskManager; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + pub(crate) mod helpers; pub(crate) mod openapi; pub(crate) mod router; pub(crate) mod state; -use nym_task::TaskManager; pub(crate) use router::RouterBuilder; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -use crate::unstable_routes; pub(crate) const TASK_MANAGER_TIMEOUT_S: u64 = 10; diff --git a/nym-api/src/support/http/router.rs b/nym-api/src/support/http/router.rs index 588b0e4134..60cb6199ee 100644 --- a/nym-api/src/support/http/router.rs +++ b/nym-api/src/support/http/router.rs @@ -11,7 +11,7 @@ use crate::nym_nodes::handlers::nym_node_routes; use crate::status; use crate::support::http::openapi::ApiDoc; use crate::support::http::state::AppState; -use crate::support::http::unstable_routes::unstable_routes; +use crate::unstable_routes::v1::unstable_routes_v1; use anyhow::anyhow; use axum::response::Redirect; use axum::routing::get; @@ -64,7 +64,7 @@ impl RouterBuilder { .nest("/api-status", status::handlers::api_status_routes()) .nest("/nym-nodes", nym_node_routes()) .nest("/ecash", ecash_routes()) - .nest("/unstable", unstable_routes()), // CORS layer needs to be "outside" of routes + .nest("/unstable", unstable_routes_v1()), // CORS layer needs to be "outside" of routes ); Self { diff --git a/nym-api/src/support/http/state/mod.rs b/nym-api/src/support/http/state/mod.rs index d530b3e7f6..b3bcf49216 100644 --- a/nym-api/src/support/http/state/mod.rs +++ b/nym-api/src/support/http/state/mod.rs @@ -16,8 +16,8 @@ use crate::support::http::state::chain_status::ChainStatusCache; use crate::support::http::state::force_refresh::ForcedRefresh; use crate::support::nyxd::Client; use crate::support::storage; -use crate::unstable_routes::account::cache::AddressInfoCache; -use crate::unstable_routes::models::NyxAccountDetails; +use crate::unstable_routes::v1::account::cache::AddressInfoCache; +use crate::unstable_routes::v1::account::models::NyxAccountDetails; use axum::extract::FromRef; use nym_api_requests::models::{GatewayBondAnnotated, MixNodeBondAnnotated, NodeAnnotation}; use nym_mixnet_contract_common::NodeId; diff --git a/nym-api/src/unstable_routes/helpers.rs b/nym-api/src/unstable_routes/helpers.rs new file mode 100644 index 0000000000..5111033b0b --- /dev/null +++ b/nym-api/src/unstable_routes/helpers.rs @@ -0,0 +1,2 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only diff --git a/nym-api/src/unstable_routes/mod.rs b/nym-api/src/unstable_routes/mod.rs index d48ecb1e1e..d95805ba63 100644 --- a/nym-api/src/unstable_routes/mod.rs +++ b/nym-api/src/unstable_routes/mod.rs @@ -1,15 +1,6 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -pub(crate) mod account; -pub(crate) mod models; - -use crate::support::http::state::AppState; -use axum::Router; - -// as those get stabilised, they should get deprecated and use a redirection instead -pub(crate) fn unstable_routes() -> Router { - Router::new() - .nest("/nym-nodes", crate::nym_nodes::handlers::unstable::routes()) - .nest("/account", account::routes()) -} +pub(crate) mod helpers; +pub(crate) mod v1; +pub(crate) mod v2; diff --git a/nym-api/src/unstable_routes/account/cache.rs b/nym-api/src/unstable_routes/v1/account/cache.rs similarity index 93% rename from nym-api/src/unstable_routes/account/cache.rs rename to nym-api/src/unstable_routes/v1/account/cache.rs index 34f0ddbee1..75fe83a1fb 100644 --- a/nym-api/src/unstable_routes/account/cache.rs +++ b/nym-api/src/unstable_routes/v1/account/cache.rs @@ -1,11 +1,6 @@ -use crate::{ - node_status_api::models::AxumResult, - nym_contract_cache::cache::NymContractCache, - unstable_routes::{ - account::data_collector::AddressDataCollector, - models::{NyxAccountDelegationDetails, NyxAccountDetails}, - }, -}; +use crate::unstable_routes::v1::account::data_collector::AddressDataCollector; +use crate::unstable_routes::v1::account::models::{NyxAccountDelegationDetails, NyxAccountDetails}; +use crate::{node_status_api::models::AxumResult, nym_contract_cache::cache::NymContractCache}; use moka::{future::Cache, Entry}; use nym_validator_client::nyxd::AccountId; use std::{sync::Arc, time::Duration}; diff --git a/nym-api/src/unstable_routes/account/data_collector.rs b/nym-api/src/unstable_routes/v1/account/data_collector.rs similarity index 98% rename from nym-api/src/unstable_routes/account/data_collector.rs rename to nym-api/src/unstable_routes/v1/account/data_collector.rs index d2c780d0fb..7d130e9f94 100644 --- a/nym-api/src/unstable_routes/account/data_collector.rs +++ b/nym-api/src/unstable_routes/v1/account/data_collector.rs @@ -1,10 +1,10 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::unstable_routes::v1::account::models::NyxAccountDelegationRewardDetails; use crate::{ node_status_api::models::{AxumErrorResponse, AxumResult}, nym_contract_cache::cache::NymContractCache, - unstable_routes::models::NyxAccountDelegationRewardDetails, }; use cosmwasm_std::{Coin, Decimal}; use nym_mixnet_contract_common::NodeRewarding; diff --git a/nym-api/src/unstable_routes/account/mod.rs b/nym-api/src/unstable_routes/v1/account/mod.rs similarity index 96% rename from nym-api/src/unstable_routes/account/mod.rs rename to nym-api/src/unstable_routes/v1/account/mod.rs index 255f49b473..8b7b83d11c 100644 --- a/nym-api/src/unstable_routes/account/mod.rs +++ b/nym-api/src/unstable_routes/v1/account/mod.rs @@ -4,13 +4,13 @@ use crate::{ node_status_api::models::{AxumErrorResponse, AxumResult}, support::http::state::AppState, - unstable_routes::models::NyxAccountDetails, }; use axum::{ extract::{Path, State}, routing::get, Json, Router, }; +use models::NyxAccountDetails; use nym_validator_client::nyxd::AccountId; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -19,6 +19,7 @@ use utoipa::ToSchema; pub(crate) mod cache; pub(crate) mod data_collector; +pub(crate) mod models; pub(crate) fn routes() -> Router { Router::new().route("/:address", get(address)) diff --git a/nym-api/src/unstable_routes/models.rs b/nym-api/src/unstable_routes/v1/account/models.rs similarity index 100% rename from nym-api/src/unstable_routes/models.rs rename to nym-api/src/unstable_routes/v1/account/models.rs diff --git a/nym-api/src/unstable_routes/v1/mod.rs b/nym-api/src/unstable_routes/v1/mod.rs new file mode 100644 index 0000000000..321ae6039e --- /dev/null +++ b/nym-api/src/unstable_routes/v1/mod.rs @@ -0,0 +1,15 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::support::http::state::AppState; +use axum::Router; + +pub(crate) mod account; +pub(crate) mod nym_nodes; + +// as those get stabilised, they should get deprecated and use a redirection instead +pub(crate) fn unstable_routes_v1() -> Router { + Router::new() + .nest("/nym-nodes", nym_nodes::routes()) + .nest("/account", account::routes()) +} diff --git a/nym-api/src/nym_nodes/handlers/unstable/full_fat.rs b/nym-api/src/unstable_routes/v1/nym_nodes/full_fat/mod.rs similarity index 74% rename from nym-api/src/nym_nodes/handlers/unstable/full_fat.rs rename to nym-api/src/unstable_routes/v1/nym_nodes/full_fat/mod.rs index 2937b0377d..df76984fcf 100644 --- a/nym-api/src/nym_nodes/handlers/unstable/full_fat.rs +++ b/nym-api/src/unstable_routes/v1/nym_nodes/full_fat/mod.rs @@ -1,9 +1,9 @@ -// Copyright 2024 - Nym Technologies SA +// Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; -use crate::nym_nodes::handlers::unstable::NodesParamsWithRole; use crate::support::http::state::AppState; +use crate::unstable_routes::v1::nym_nodes::helpers::NodesParamsWithRole; use axum::extract::{Query, State}; use nym_api_requests::nym_nodes::{CachedNodesResponse, FullFatNode}; use nym_http_api_common::FormattedResponse; @@ -15,11 +15,11 @@ use nym_http_api_common::FormattedResponse; path = "", context_path = "/v1/unstable/nym-nodes/full-fat", responses( - // (status = 200, body = CachedNodesResponse) + // (status = 200, body = CachedNodesResponse) (status = 501) ) )] -pub(super) async fn nodes_detailed( +pub(crate) async fn nodes_detailed( _state: State, _query_params: Query, ) -> AxumResult>> { diff --git a/nym-api/src/unstable_routes/v1/nym_nodes/handlers.rs b/nym-api/src/unstable_routes/v1/nym_nodes/handlers.rs new file mode 100644 index 0000000000..f2582cec82 --- /dev/null +++ b/nym-api/src/unstable_routes/v1/nym_nodes/handlers.rs @@ -0,0 +1,51 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; +use crate::support::http::state::AppState; +use axum::extract::{Query, State}; +use axum::Json; +use nym_api_requests::nym_nodes::{NodesByAddressesRequestBody, NodesByAddressesResponse}; +use nym_http_api_common::{FormattedResponse, OutputParams}; +use std::collections::HashMap; + +#[utoipa::path( + tag = "Unstable Nym Nodes", + post, + request_body = NodesByAddressesRequestBody, + path = "/by-addresses", + context_path = "/v1/unstable/nym-nodes", + responses( + (status = 200, content( + (NodesByAddressesResponse = "application/json"), + (NodesByAddressesResponse = "application/yaml"), + (NodesByAddressesResponse = "application/bincode") + )) + ), + params(OutputParams) +)] +pub(crate) async fn nodes_by_addresses( + Query(output): Query, + state: State, + Json(body): Json, +) -> AxumResult> { + // if the request is too big, simply reject it + if body.addresses.len() > 100 { + return Err(AxumErrorResponse::bad_request( + "requested too many addresses", + )); + } + + let output = output.output.unwrap_or_default(); + + // TODO: perhaps introduce different cache because realistically nym-api will receive + // request for the same couple addresses from all nodes in quick succession + let describe_cache = state.describe_nodes_cache_data().await?; + + let mut existence = HashMap::new(); + for address in body.addresses { + existence.insert(address, describe_cache.node_with_address(address)); + } + + Ok(output.to_response(NodesByAddressesResponse { existence })) +} diff --git a/nym-api/src/unstable_routes/v1/nym_nodes/helpers.rs b/nym-api/src/unstable_routes/v1/nym_nodes/helpers.rs new file mode 100644 index 0000000000..3b1834b692 --- /dev/null +++ b/nym-api/src/unstable_routes/v1/nym_nodes/helpers.rs @@ -0,0 +1,115 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::support::http::helpers::PaginationRequest; +use nym_api_requests::models::{ + GatewayBondAnnotated, MalformedNodeBond, MixNodeBondAnnotated, OffsetDateTimeJsonSchemaWrapper, +}; +use nym_api_requests::nym_nodes::{NodeRole, NodeRoleQueryParam, SkimmedNode}; +use nym_http_api_common::Output; +use nym_mixnet_contract_common::reward_params::Performance; +use serde::Deserialize; +use time::OffsetDateTime; + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub(crate) struct NodesParamsWithRole { + #[param(inline)] + pub(crate) role: Option, + + #[allow(dead_code)] + pub(crate) semver_compatibility: Option, + pub(crate) no_legacy: Option, + pub(crate) page: Option, + pub(crate) per_page: Option, + + // Identifier for the current epoch of the topology state. When sent by a client we can check if + // the client already knows about the latest topology state, allowing a `no-updates` response + // instead of wasting bandwidth serving an unchanged topology. + pub(crate) epoch_id: Option, + + pub(crate) output: Option, +} + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub(crate) struct NodesParams { + #[allow(dead_code)] + pub(crate) semver_compatibility: Option, + pub(crate) no_legacy: Option, + pub(crate) page: Option, + pub(crate) per_page: Option, + + // Identifier for the current epoch of the topology state. When sent by a client we can check if + // the client already knows about the latest topology state, allowing a `no-updates` response + // instead of wasting bandwidth serving an unchanged topology. + pub(crate) epoch_id: Option, + pub(crate) output: Option, +} + +impl From for NodesParams { + fn from(params: NodesParamsWithRole) -> Self { + NodesParams { + semver_compatibility: params.semver_compatibility, + no_legacy: params.no_legacy, + page: params.page, + per_page: params.per_page, + epoch_id: params.epoch_id, + output: params.output, + } + } +} + +impl<'a> From<&'a NodesParams> for PaginationRequest { + fn from(params: &'a NodesParams) -> Self { + PaginationRequest { + output: params.output, + page: params.page, + per_page: params.per_page, + } + } +} + +pub(crate) trait LegacyAnnotation { + fn performance(&self) -> Performance; + + fn identity(&self) -> &str; + + fn try_to_skimmed_node(&self, role: NodeRole) -> Result; +} + +impl LegacyAnnotation for MixNodeBondAnnotated { + fn performance(&self) -> Performance { + self.node_performance.last_24h + } + + fn identity(&self) -> &str { + self.identity_key() + } + + fn try_to_skimmed_node(&self, role: NodeRole) -> Result { + self.try_to_skimmed_node(role) + } +} + +impl LegacyAnnotation for GatewayBondAnnotated { + fn performance(&self) -> Performance { + self.node_performance.last_24h + } + + fn identity(&self) -> &str { + self.identity() + } + + fn try_to_skimmed_node(&self, role: NodeRole) -> Result { + self.try_to_skimmed_node(role) + } +} + +pub(crate) fn refreshed_at( + iter: impl IntoIterator, +) -> OffsetDateTimeJsonSchemaWrapper { + iter.into_iter() + .min() + .unwrap_or(OffsetDateTime::UNIX_EPOCH) + .into() +} diff --git a/nym-api/src/unstable_routes/v1/nym_nodes/mod.rs b/nym-api/src/unstable_routes/v1/nym_nodes/mod.rs new file mode 100644 index 0000000000..0d49fb22e8 --- /dev/null +++ b/nym-api/src/unstable_routes/v1/nym_nodes/mod.rs @@ -0,0 +1,78 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! All routes/nodes are split into three tiers: +//! +//! `/skimmed` +//! - used by clients +//! - returns the very basic information for routing purposes +//! +//! `/semi-skimmed` +//! - used by other nodes/VPN +//! - returns more additional information such as noise keys +//! +//! `/full-fat` +//! - used by explorers, et al. +//! - returns almost everything there is about the nodes +//! +//! There's also additional split based on the role: +//! - `?role` => filters based on the specific role (mixnode/gateway/(in the future: entry/exit)) +//! - `/mixnodes/` => only returns mixnode role data +//! - `/gateway/` => only returns (entry) gateway role data + +use crate::support::http::state::AppState; +use crate::unstable_routes::v1::nym_nodes::full_fat::nodes_detailed; +use crate::unstable_routes::v1::nym_nodes::handlers::nodes_by_addresses; +use crate::unstable_routes::v1::nym_nodes::semi_skimmed::nodes_expanded; +use crate::unstable_routes::v1::nym_nodes::skimmed::{ + entry_gateways_basic_active, entry_gateways_basic_all, exit_gateways_basic_active, + exit_gateways_basic_all, mixnodes_basic_active, mixnodes_basic_all, nodes_basic_active, + nodes_basic_all, +}; +use axum::routing::{get, post}; +use axum::Router; +use tower_http::compression::CompressionLayer; + +pub(crate) mod full_fat; +pub(crate) mod handlers; +pub(crate) mod helpers; +pub(crate) mod semi_skimmed; +pub(crate) mod skimmed; + +#[allow(deprecated)] +pub(crate) fn routes() -> Router { + Router::new() + .nest( + "/skimmed", + Router::new() + .route("/", get(nodes_basic_all)) + .route("/active", get(nodes_basic_active)) + .nest( + "/mixnodes", + Router::new() + .route("/active", get(mixnodes_basic_active)) + .route("/all", get(mixnodes_basic_all)), + ) + .nest( + "/entry-gateways", + Router::new() + .route("/active", get(entry_gateways_basic_active)) + .route("/all", get(entry_gateways_basic_all)), + ) + .nest( + "/exit-gateways", + Router::new() + .route("/active", get(exit_gateways_basic_active)) + .route("/all", get(exit_gateways_basic_all)), + ), + ) + .nest( + "/semi-skimmed", + Router::new().route("/", get(nodes_expanded)), + ) + .nest("/full-fat", Router::new().route("/", get(nodes_detailed))) + .route("/gateways/skimmed", get(skimmed::deprecated_gateways_basic)) + .route("/mixnodes/skimmed", get(skimmed::deprecated_mixnodes_basic)) + .route("/by-addresses", post(nodes_by_addresses)) + .layer(CompressionLayer::new()) +} diff --git a/nym-api/src/nym_nodes/handlers/unstable/semi_skimmed.rs b/nym-api/src/unstable_routes/v1/nym_nodes/semi_skimmed/mod.rs similarity index 74% rename from nym-api/src/nym_nodes/handlers/unstable/semi_skimmed.rs rename to nym-api/src/unstable_routes/v1/nym_nodes/semi_skimmed/mod.rs index f7dff11e3e..e83c220321 100644 --- a/nym-api/src/nym_nodes/handlers/unstable/semi_skimmed.rs +++ b/nym-api/src/unstable_routes/v1/nym_nodes/semi_skimmed/mod.rs @@ -1,9 +1,9 @@ -// Copyright 2024 - Nym Technologies SA +// Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; -use crate::nym_nodes::handlers::unstable::NodesParamsWithRole; use crate::support::http::state::AppState; +use crate::unstable_routes::v1::nym_nodes::helpers::NodesParamsWithRole; use axum::extract::{Query, State}; use nym_api_requests::nym_nodes::{CachedNodesResponse, SemiSkimmedNode}; use nym_http_api_common::FormattedResponse; @@ -15,11 +15,11 @@ use nym_http_api_common::FormattedResponse; path = "", context_path = "/v1/unstable/nym-nodes/semi-skimmed", responses( - // (status = 200, body = CachedNodesResponse) + // (status = 200, body = CachedNodesResponse) (status = 501) ) )] -pub(super) async fn nodes_expanded( +pub(crate) async fn nodes_expanded( _state: State, _query_params: Query, ) -> AxumResult>> { diff --git a/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/handlers.rs b/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/handlers.rs new file mode 100644 index 0000000000..94ee3c79f2 --- /dev/null +++ b/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/handlers.rs @@ -0,0 +1,297 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node_status_api::models::AxumResult; +use crate::support::http::state::AppState; +use crate::unstable_routes::v1::nym_nodes::helpers::{NodesParams, NodesParamsWithRole}; +use crate::unstable_routes::v1::nym_nodes::skimmed::helpers::{ + entry_gateways_basic, exit_gateways_basic, mixnodes_basic, nodes_basic, +}; +use crate::unstable_routes::v1::nym_nodes::skimmed::{ + PaginatedCachedNodesResponseSchema, PaginatedSkimmedNodes, +}; +use axum::extract::{Query, State}; +use nym_api_requests::nym_nodes::{CachedNodesResponse, NodeRoleQueryParam, SkimmedNode}; +use nym_http_api_common::FormattedResponse; + +/// Deprecated query that gets ALL gateways +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/gateways/skimmed", + context_path = "/v1/unstable/nym-nodes", + responses( + (status = 200, content( + (CachedNodesResponse = "application/json"), + (CachedNodesResponse = "application/yaml"), + (CachedNodesResponse = "application/bincode") + )) + ), +)] +#[deprecated(note = "use '/v1/unstable/nym-nodes/entry-gateways/skimmed/all' instead")] +pub(crate) async fn deprecated_gateways_basic( + state: State, + query_params: Query, +) -> AxumResult>> { + let output = query_params.output.unwrap_or_default(); + + // 1. call '/v1/unstable/skimmed/entry-gateways/all' + let all_gateways = entry_gateways_basic_all(state, query_params) + .await? + .into_inner(); + + // 3. return result + Ok(output.to_response(CachedNodesResponse { + refreshed_at: all_gateways.metadata.refreshed_at, + // 2. remove pagination + nodes: all_gateways.nodes.data, + })) +} + +/// Deprecated query that gets ACTIVE-ONLY mixnodes +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/mixnodes/skimmed", + context_path = "/v1/unstable/nym-nodes", + responses( + (status = 200, content( + (CachedNodesResponse = "application/json"), + (CachedNodesResponse = "application/yaml"), + (CachedNodesResponse = "application/bincode") + )) + ), +)] +#[deprecated(note = "use '/v1/unstable/nym-nodes/skimmed/mixnodes/active' instead")] +pub(crate) async fn deprecated_mixnodes_basic( + state: State, + query_params: Query, +) -> AxumResult>> { + let output = query_params.output.unwrap_or_default(); + + // 1. call '/v1/unstable/nym-nodes/skimmed/mixnodes/active' + let active_mixnodes = mixnodes_basic_active(state, query_params) + .await? + .into_inner(); + + // 3. return result + Ok(output.to_response(CachedNodesResponse { + refreshed_at: active_mixnodes.metadata.refreshed_at, + // 2. remove pagination + nodes: active_mixnodes.nodes.data, + })) +} + +/// Return all Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used) +/// that are currently bonded. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParamsWithRole), + path = "", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesResponseSchema = "application/json"), + (PaginatedCachedNodesResponseSchema = "application/yaml"), + (PaginatedCachedNodesResponseSchema = "application/bincode") + )) + ), +)] +pub(crate) async fn nodes_basic_all( + state: State, + Query(query_params): Query, +) -> PaginatedSkimmedNodes { + if let Some(role) = query_params.role { + return match role { + NodeRoleQueryParam::ActiveMixnode => { + mixnodes_basic_all(state, Query(query_params.into())).await + } + NodeRoleQueryParam::EntryGateway => { + entry_gateways_basic_all(state, Query(query_params.into())).await + } + NodeRoleQueryParam::ExitGateway => { + exit_gateways_basic_all(state, Query(query_params.into())).await + } + }; + } + + nodes_basic(state, Query(query_params.into()), false).await +} + +/// Return Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used) +/// that are currently bonded and are in the **active set** +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/active", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesResponseSchema = "application/json"), + (PaginatedCachedNodesResponseSchema = "application/yaml"), + (PaginatedCachedNodesResponseSchema = "application/bincode") + )) + ), +)] +pub(crate) async fn nodes_basic_active( + state: State, + Query(query_params): Query, +) -> PaginatedSkimmedNodes { + if let Some(role) = query_params.role { + return match role { + NodeRoleQueryParam::ActiveMixnode => { + mixnodes_basic_active(state, Query(query_params.into())).await + } + NodeRoleQueryParam::EntryGateway => { + entry_gateways_basic_active(state, Query(query_params.into())).await + } + NodeRoleQueryParam::ExitGateway => { + exit_gateways_basic_active(state, Query(query_params.into())).await + } + }; + } + + nodes_basic(state, Query(query_params.into()), true).await +} + +/// Returns Nym Nodes and optionally legacy mixnodes (if `no-legacy` flag is not used) +/// that are currently bonded and support mixing role. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/mixnodes/all", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesResponseSchema = "application/json"), + (PaginatedCachedNodesResponseSchema = "application/yaml"), + (PaginatedCachedNodesResponseSchema = "application/bincode") + )) + ), +)] +pub(crate) async fn mixnodes_basic_all( + state: State, + query_params: Query, +) -> PaginatedSkimmedNodes { + mixnodes_basic(state, query_params, false).await +} + +/// Returns Nym Nodes and optionally legacy mixnodes (if `no-legacy` flag is not used) +/// that are currently bonded and are in the active set with one of the mixing roles. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/mixnodes/active", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesResponseSchema = "application/json"), + (PaginatedCachedNodesResponseSchema = "application/yaml"), + (PaginatedCachedNodesResponseSchema = "application/bincode") + )) + ), +)] +pub(crate) async fn mixnodes_basic_active( + state: State, + query_params: Query, +) -> PaginatedSkimmedNodes { + mixnodes_basic(state, query_params, true).await +} + +/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) +/// that are currently bonded and are in the active set with the entry role. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/entry-gateways/active", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesResponseSchema = "application/json"), + (PaginatedCachedNodesResponseSchema = "application/yaml"), + (PaginatedCachedNodesResponseSchema = "application/bincode") + )) + ), +)] +pub(crate) async fn entry_gateways_basic_active( + state: State, + query_params: Query, +) -> PaginatedSkimmedNodes { + entry_gateways_basic(state, query_params, true).await +} + +/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) +/// that are currently bonded and support entry gateway role. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/entry-gateways/all", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesResponseSchema = "application/json"), + (PaginatedCachedNodesResponseSchema = "application/yaml"), + (PaginatedCachedNodesResponseSchema = "application/bincode") + )) + ), +)] +pub(crate) async fn entry_gateways_basic_all( + state: State, + query_params: Query, +) -> PaginatedSkimmedNodes { + entry_gateways_basic(state, query_params, false).await +} + +/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) +/// that are currently bonded and are in the active set with the exit role. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/exit-gateways/active", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesResponseSchema = "application/json"), + (PaginatedCachedNodesResponseSchema = "application/yaml"), + (PaginatedCachedNodesResponseSchema = "application/bincode") + )) + ), +)] +pub(crate) async fn exit_gateways_basic_active( + state: State, + query_params: Query, +) -> PaginatedSkimmedNodes { + exit_gateways_basic(state, query_params, true).await +} + +/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) +/// that are currently bonded and support exit gateway role. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParams), + path = "/exit-gateways/all", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, content( + (PaginatedCachedNodesResponseSchema = "application/json"), + (PaginatedCachedNodesResponseSchema = "application/yaml"), + (PaginatedCachedNodesResponseSchema = "application/bincode") + )) + ), +)] +pub(crate) async fn exit_gateways_basic_all( + state: State, + query_params: Query, +) -> PaginatedSkimmedNodes { + exit_gateways_basic(state, query_params, false).await +} diff --git a/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/helpers.rs b/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/helpers.rs new file mode 100644 index 0000000000..b86490c3f0 --- /dev/null +++ b/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/helpers.rs @@ -0,0 +1,343 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node_describe_cache::cache::DescribedNodes; +use crate::node_status_api::models::AxumErrorResponse; +use crate::support::caching::Cache; +use crate::support::http::state::AppState; +use crate::unstable_routes::v1::nym_nodes::helpers::{refreshed_at, LegacyAnnotation, NodesParams}; +use crate::unstable_routes::v1::nym_nodes::skimmed::PaginatedSkimmedNodes; +use axum::extract::{Query, State}; +use nym_api_requests::models::{NodeAnnotation, NymNodeDescription}; +use nym_api_requests::nym_nodes::{NodeRole, PaginatedCachedNodesResponse, SkimmedNode}; +use nym_http_api_common::Output; +use nym_mixnet_contract_common::NodeId; +use nym_topology::CachedEpochRewardedSet; +use std::collections::HashMap; +use std::future::Future; +use std::time::Duration; +use tokio::sync::RwLockReadGuard; +use tracing::trace; + +/// Given all relevant caches, build part of response for JUST Nym Nodes +fn build_nym_nodes_response<'a, NI>( + rewarded_set: &CachedEpochRewardedSet, + nym_nodes_subset: NI, + annotations: &HashMap, + current_key_rotation: u32, + active_only: bool, +) -> Vec +where + NI: Iterator + 'a, +{ + let mut nodes = Vec::new(); + for nym_node in nym_nodes_subset { + let node_id = nym_node.node_id; + + let role: NodeRole = rewarded_set.role(node_id).into(); + + // if the role is inactive, see if our filter allows it + if active_only && role.is_inactive() { + continue; + } + + // honestly, not sure under what exact circumstances this value could be missing, + // but in that case just use 0 performance + let annotation = annotations.get(&node_id).copied().unwrap_or_default(); + + nodes.push(nym_node.to_skimmed_node( + current_key_rotation, + role, + annotation.last_24h_performance, + )); + } + nodes +} + +/// Given all relevant caches, add appropriate legacy nodes to the part of the response +fn add_legacy( + nodes: &mut Vec, + rewarded_set: &CachedEpochRewardedSet, + describe_cache: &DescribedNodes, + annotated_legacy_nodes: &HashMap, + current_key_rotation: u32, + active_only: bool, +) where + LN: LegacyAnnotation, +{ + for (node_id, legacy) in annotated_legacy_nodes.iter() { + let role: NodeRole = rewarded_set.role(*node_id).into(); + + // if the role is inactive, see if our filter allows it + if active_only && role.is_inactive() { + continue; + } + + // if we have self-described info, prefer it over contract data + if let Some(described) = describe_cache.get_node(node_id) { + // legacy nodes don't support key rotation + nodes.push(described.to_skimmed_node(current_key_rotation, role, legacy.performance())) + } else { + match legacy.try_to_skimmed_node(role) { + Ok(node) => nodes.push(node), + Err(err) => { + let id = legacy.identity(); + trace!("node {id} is malformed: {err}") + } + } + } + } +} + +// hehe, what an abomination, but it's used in multiple different places and I hate copy-pasting code, +// especially if it has multiple loops, etc +pub(crate) async fn build_skimmed_nodes_response<'a, NI, LG, Fut, LN>( + state: &'a AppState, + Query(query_params): Query, + nym_nodes_subset: NI, + annotated_legacy_nodes_getter: LG, + active_only: bool, + output: Output, +) -> PaginatedSkimmedNodes +where + // iterator returning relevant subset of nym-nodes (like mixing nym-nodes, entries, etc.) + NI: Iterator + 'a, + + // async function that returns cache of appropriate legacy nodes (mixnodes or gateways) + LG: Fn(&'a AppState) -> Fut, + Fut: + Future>>, AxumErrorResponse>>, + + // the legacy node (MixNodeBondAnnotated or GatewayBondAnnotated) + LN: LegacyAnnotation + 'a, +{ + // TODO: implement it + let _ = query_params.per_page; + let _ = query_params.page; + + // 1. get the rewarded set + let rewarded_set = state.rewarded_set().await?; + + // 2. grab all annotations so that we could attach scores to the [nym] nodes + let annotations = state.node_annotations().await?; + + // 3. implicitly grab the relevant described nodes + // (ideally it'd be tied directly to the NI iterator, but I couldn't defeat the compiler) + let describe_cache = state.describe_nodes_cache_data().await?; + + let contract_cache = state.nym_contract_cache(); + + let interval = contract_cache.current_interval().await?; + let current_key_rotation = contract_cache.current_key_rotation_id().await?; + + // 4.0 If the client indicates that they already know about the current topology send empty response + if let Some(client_known_epoch) = query_params.epoch_id { + if client_known_epoch == interval.current_epoch_id() { + return Ok(output.to_response(PaginatedCachedNodesResponse::no_updates( + interval.current_epoch_absolute_id(), + current_key_rotation, + ))); + } + } + + // 4. start building the response + let mut nodes = build_nym_nodes_response( + &rewarded_set, + nym_nodes_subset, + &annotations, + current_key_rotation, + active_only, + ); + + // 5. if we allow legacy nodes, repeat the procedure for them, otherwise return just nym-nodes + if let Some(true) = query_params.no_legacy { + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + ]); + + return Ok(output.to_response( + PaginatedCachedNodesResponse::new_full( + interval.current_epoch_absolute_id(), + current_key_rotation, + refreshed_at, + nodes, + ) + .fresh(interval), + )); + } + + // 6. grab relevant legacy nodes + // (due to the existence of the legacy endpoints, we already have fully annotated data on them) + let annotated_legacy_nodes = annotated_legacy_nodes_getter(state).await?; + add_legacy( + &mut nodes, + &rewarded_set, + &describe_cache, + &annotated_legacy_nodes, + current_key_rotation, + active_only, + ); + + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + annotated_legacy_nodes.timestamp(), + ]); + + let base_response = output.to_response( + PaginatedCachedNodesResponse::new_full( + interval.current_epoch_absolute_id(), + current_key_rotation, + refreshed_at, + nodes, + ) + .fresh(interval), + ); + + if !active_only { + return Ok(base_response); + } + + // if caller requested only active nodes, the response is valid until the epoch changes + // (but add 2 minutes due to epoch transition not being instantaneous + let epoch_end = interval.current_epoch_end(); + let expiration = epoch_end + Duration::from_secs(120); + Ok(base_response.with_expires_header(expiration)) +} + +pub(crate) async fn nodes_basic( + state: State, + Query(query_params): Query, + active_only: bool, +) -> PaginatedSkimmedNodes { + let output = query_params.output.unwrap_or_default(); + + // unfortunately we have to build the response semi-manually here as we need to add two sources of legacy nodes + + // 1. grab all relevant described nym-nodes + let rewarded_set = state.rewarded_set().await?; + + let describe_cache = state.describe_nodes_cache_data().await?; + let all_nym_nodes = describe_cache.all_nym_nodes(); + let annotations = state.node_annotations().await?; + let legacy_mixnodes = state.legacy_mixnode_annotations().await?; + let legacy_gateways = state.legacy_gateways_annotations().await?; + + let interval = state.nym_contract_cache().current_interval().await?; + let current_key_rotation = state.nym_contract_cache().current_key_rotation_id().await?; + + let mut nodes = build_nym_nodes_response( + &rewarded_set, + all_nym_nodes, + &annotations, + current_key_rotation, + active_only, + ); + + // add legacy gateways to the response + add_legacy( + &mut nodes, + &rewarded_set, + &describe_cache, + &legacy_gateways, + current_key_rotation, + active_only, + ); + + // add legacy mixnodes to the response + add_legacy( + &mut nodes, + &rewarded_set, + &describe_cache, + &legacy_mixnodes, + current_key_rotation, + active_only, + ); + + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + legacy_mixnodes.timestamp(), + legacy_gateways.timestamp(), + ]); + + Ok(output.to_response(PaginatedCachedNodesResponse::new_full( + current_key_rotation, + interval.current_epoch_absolute_id(), + refreshed_at, + nodes, + ))) +} + +pub(crate) async fn mixnodes_basic( + state: State, + query_params: Query, + active_only: bool, +) -> PaginatedSkimmedNodes { + let output = query_params.output.unwrap_or_default(); + + // 1. grab all relevant described nym-nodes + let describe_cache = state.describe_nodes_cache_data().await?; + let mixing_nym_nodes = describe_cache.mixing_nym_nodes(); + + build_skimmed_nodes_response( + &state.0, + query_params, + mixing_nym_nodes, + |state| state.legacy_mixnode_annotations(), + active_only, + output, + ) + .await +} + +pub(crate) async fn entry_gateways_basic( + state: State, + query_params: Query, + active_only: bool, +) -> PaginatedSkimmedNodes { + let output = query_params.output.unwrap_or_default(); + + // 1. grab all relevant described nym-nodes + let describe_cache = state.describe_nodes_cache_data().await?; + let mixing_nym_nodes = describe_cache.entry_capable_nym_nodes(); + + build_skimmed_nodes_response( + &state.0, + query_params, + mixing_nym_nodes, + |state| state.legacy_gateways_annotations(), + active_only, + output, + ) + .await +} + +pub(crate) async fn exit_gateways_basic( + state: State, + query_params: Query, + active_only: bool, +) -> PaginatedSkimmedNodes { + let output = query_params.output.unwrap_or_default(); + + // 1. grab all relevant described nym-nodes + let describe_cache = state.describe_nodes_cache_data().await?; + let mixing_nym_nodes = describe_cache.exit_capable_nym_nodes(); + + build_skimmed_nodes_response( + &state.0, + query_params, + mixing_nym_nodes, + |state| state.legacy_gateways_annotations(), + active_only, + output, + ) + .await +} diff --git a/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/mod.rs b/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/mod.rs new file mode 100644 index 0000000000..c0a09bf8e8 --- /dev/null +++ b/nym-api/src/unstable_routes/v1/nym_nodes/skimmed/mod.rs @@ -0,0 +1,26 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node_status_api::models::AxumResult; +use nym_api_requests::models::OffsetDateTimeJsonSchemaWrapper; +use nym_api_requests::nym_nodes::{PaginatedCachedNodesResponse, SkimmedNode}; +use nym_api_requests::pagination::PaginatedResponse; +use nym_http_api_common::FormattedResponse; +use utoipa::ToSchema; + +pub(crate) mod handlers; +pub(crate) mod helpers; + +pub type PaginatedSkimmedNodes = + AxumResult>>; + +pub(crate) use handlers::*; + +#[allow(dead_code)] // not dead, used in OpenAPI docs +#[derive(ToSchema)] +#[schema(title = "PaginatedCachedNodesResponse")] +pub struct PaginatedCachedNodesResponseSchema { + pub refreshed_at: OffsetDateTimeJsonSchemaWrapper, + #[schema(value_type = SkimmedNode)] + pub nodes: PaginatedResponse, +} diff --git a/nym-api/src/unstable_routes/v2/mod.rs b/nym-api/src/unstable_routes/v2/mod.rs new file mode 100644 index 0000000000..4c3058b951 --- /dev/null +++ b/nym-api/src/unstable_routes/v2/mod.rs @@ -0,0 +1,9 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::support::http::state::AppState; +use axum::Router; + +pub(crate) fn unstable_routes_v2() -> Router { + Router::new() +}