split nym-nodes http handlers

This commit is contained in:
Jędrzej Stuczyński
2025-05-15 15:27:10 +01:00
parent 0c653cef51
commit f1aecd0f80
26 changed files with 964 additions and 924 deletions
+1 -1
View File
@@ -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
sqlite3 -init settings.sql /Users/jedrzej/workspace/nym/target/debug/build/nym-api-3a0aa50356183aba/out/nym-api-example.sqlite
+1 -1
View File
@@ -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;
-1
View File
@@ -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<AppState> {
Router::new()
@@ -1,54 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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<SkimmedNode, MalformedNodeBond>;
}
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<SkimmedNode, MalformedNodeBond> {
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<SkimmedNode, MalformedNodeBond> {
self.try_to_skimmed_node(role)
}
}
pub(crate) fn refreshed_at(
iter: impl IntoIterator<Item = OffsetDateTime>,
) -> OffsetDateTimeJsonSchemaWrapper {
iter.into_iter()
.min()
.unwrap_or(OffsetDateTime::UNIX_EPOCH)
.into()
}
@@ -1,184 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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/<tier>` => only returns mixnode role data
//! - `/gateway/<tier>` => 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<AppState> {
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<NodeRoleQueryParam>,
#[allow(dead_code)]
semver_compatibility: Option<String>,
no_legacy: Option<bool>,
page: Option<u32>,
per_page: Option<u32>,
// 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<u32>,
output: Option<Output>,
}
#[derive(Debug, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
struct NodesParams {
#[allow(dead_code)]
semver_compatibility: Option<String>,
no_legacy: Option<bool>,
page: Option<u32>,
per_page: Option<u32>,
// 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<u32>,
output: Option<Output>,
}
impl From<NodesParamsWithRole> 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<OutputParams>,
state: State<AppState>,
Json(body): Json<NodesByAddressesRequestBody>,
) -> AxumResult<FormattedResponse<NodesByAddressesResponse>> {
// 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 }))
}
@@ -1,643 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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<FormattedResponse<PaginatedCachedNodesResponse<SkimmedNode>>>;
/// 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<NodeId, NodeAnnotation>,
current_key_rotation: u32,
active_only: bool,
) -> Vec<SkimmedNode>
where
NI: Iterator<Item = &'a NymNodeDescription> + '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<LN>(
nodes: &mut Vec<SkimmedNode>,
rewarded_set: &CachedEpochRewardedSet,
describe_cache: &DescribedNodes,
annotated_legacy_nodes: &HashMap<NodeId, LN>,
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<NodesParams>,
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<Item = &'a NymNodeDescription> + 'a,
// async function that returns cache of appropriate legacy nodes (mixnodes or gateways)
LG: Fn(&'a AppState) -> Fut,
Fut:
Future<Output = Result<RwLockReadGuard<'a, Cache<HashMap<NodeId, LN>>>, 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<SkimmedNode> = "application/json"),
(CachedNodesResponse<SkimmedNode> = "application/yaml"),
(CachedNodesResponse<SkimmedNode> = "application/bincode")
))
),
)]
#[deprecated(note = "use '/v1/unstable/nym-nodes/entry-gateways/skimmed/all' instead")]
pub(super) async fn deprecated_gateways_basic(
state: State<AppState>,
query_params: Query<NodesParams>,
) -> AxumResult<FormattedResponse<CachedNodesResponse<SkimmedNode>>> {
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<SkimmedNode> = "application/json"),
(CachedNodesResponse<SkimmedNode> = "application/yaml"),
(CachedNodesResponse<SkimmedNode> = "application/bincode")
))
),
)]
#[deprecated(note = "use '/v1/unstable/nym-nodes/skimmed/mixnodes/active' instead")]
pub(super) async fn deprecated_mixnodes_basic(
state: State<AppState>,
query_params: Query<NodesParams>,
) -> AxumResult<FormattedResponse<CachedNodesResponse<SkimmedNode>>> {
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<AppState>,
Query(query_params): Query<NodesParams>,
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<SkimmedNode>,
}
/// 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<AppState>,
Query(query_params): Query<NodesParamsWithRole>,
) -> 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<AppState>,
Query(query_params): Query<NodesParamsWithRole>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
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<AppState>,
query_params: Query<NodesParams>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> PaginatedSkimmedNodes {
mixnodes_basic(state, query_params, true).await
}
async fn entry_gateways_basic(
state: State<AppState>,
query_params: Query<NodesParams>,
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<AppState>,
query_params: Query<NodesParams>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> PaginatedSkimmedNodes {
entry_gateways_basic(state, query_params, false).await
}
async fn exit_gateways_basic(
state: State<AppState>,
query_params: Query<NodesParams>,
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<AppState>,
query_params: Query<NodesParams>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> PaginatedSkimmedNodes {
exit_gateways_basic(state, query_params, false).await
}
+1 -1
View File
@@ -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,
+4 -5
View File
@@ -1,17 +1,16 @@
// Copyright 2022-2023 - Nym Technologies SA <contact@nymtech.net>
// 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;
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
+3 -12
View File
@@ -1,15 +1,6 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<AppState> {
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;
@@ -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};
@@ -1,10 +1,10 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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;
@@ -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<AppState> {
Router::new().route("/:address", get(address))
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<AppState> {
Router::new()
.nest("/nym-nodes", nym_nodes::routes())
.nest("/account", account::routes())
}
@@ -1,9 +1,9 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<FullFatNode>)
// (status = 200, body = CachedNodesResponse<FullFatNode>)
(status = 501)
)
)]
pub(super) async fn nodes_detailed(
pub(crate) async fn nodes_detailed(
_state: State<AppState>,
_query_params: Query<NodesParamsWithRole>,
) -> AxumResult<FormattedResponse<CachedNodesResponse<FullFatNode>>> {
@@ -0,0 +1,51 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<OutputParams>,
state: State<AppState>,
Json(body): Json<NodesByAddressesRequestBody>,
) -> AxumResult<FormattedResponse<NodesByAddressesResponse>> {
// 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 }))
}
@@ -0,0 +1,115 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<NodeRoleQueryParam>,
#[allow(dead_code)]
pub(crate) semver_compatibility: Option<String>,
pub(crate) no_legacy: Option<bool>,
pub(crate) page: Option<u32>,
pub(crate) per_page: Option<u32>,
// 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<u32>,
pub(crate) output: Option<Output>,
}
#[derive(Debug, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub(crate) struct NodesParams {
#[allow(dead_code)]
pub(crate) semver_compatibility: Option<String>,
pub(crate) no_legacy: Option<bool>,
pub(crate) page: Option<u32>,
pub(crate) per_page: Option<u32>,
// 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<u32>,
pub(crate) output: Option<Output>,
}
impl From<NodesParamsWithRole> 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<SkimmedNode, MalformedNodeBond>;
}
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<SkimmedNode, MalformedNodeBond> {
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<SkimmedNode, MalformedNodeBond> {
self.try_to_skimmed_node(role)
}
}
pub(crate) fn refreshed_at(
iter: impl IntoIterator<Item = OffsetDateTime>,
) -> OffsetDateTimeJsonSchemaWrapper {
iter.into_iter()
.min()
.unwrap_or(OffsetDateTime::UNIX_EPOCH)
.into()
}
@@ -0,0 +1,78 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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/<tier>` => only returns mixnode role data
//! - `/gateway/<tier>` => 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<AppState> {
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())
}
@@ -1,9 +1,9 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<SemiSkimmedNode>)
// (status = 200, body = CachedNodesResponse<SemiSkimmedNode>)
(status = 501)
)
)]
pub(super) async fn nodes_expanded(
pub(crate) async fn nodes_expanded(
_state: State<AppState>,
_query_params: Query<NodesParamsWithRole>,
) -> AxumResult<FormattedResponse<CachedNodesResponse<SemiSkimmedNode>>> {
@@ -0,0 +1,297 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<SkimmedNode> = "application/json"),
(CachedNodesResponse<SkimmedNode> = "application/yaml"),
(CachedNodesResponse<SkimmedNode> = "application/bincode")
))
),
)]
#[deprecated(note = "use '/v1/unstable/nym-nodes/entry-gateways/skimmed/all' instead")]
pub(crate) async fn deprecated_gateways_basic(
state: State<AppState>,
query_params: Query<NodesParams>,
) -> AxumResult<FormattedResponse<CachedNodesResponse<SkimmedNode>>> {
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<SkimmedNode> = "application/json"),
(CachedNodesResponse<SkimmedNode> = "application/yaml"),
(CachedNodesResponse<SkimmedNode> = "application/bincode")
))
),
)]
#[deprecated(note = "use '/v1/unstable/nym-nodes/skimmed/mixnodes/active' instead")]
pub(crate) async fn deprecated_mixnodes_basic(
state: State<AppState>,
query_params: Query<NodesParams>,
) -> AxumResult<FormattedResponse<CachedNodesResponse<SkimmedNode>>> {
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<AppState>,
Query(query_params): Query<NodesParamsWithRole>,
) -> 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<AppState>,
Query(query_params): Query<NodesParamsWithRole>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> 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<AppState>,
query_params: Query<NodesParams>,
) -> PaginatedSkimmedNodes {
exit_gateways_basic(state, query_params, false).await
}
@@ -0,0 +1,343 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<NodeId, NodeAnnotation>,
current_key_rotation: u32,
active_only: bool,
) -> Vec<SkimmedNode>
where
NI: Iterator<Item = &'a NymNodeDescription> + '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<LN>(
nodes: &mut Vec<SkimmedNode>,
rewarded_set: &CachedEpochRewardedSet,
describe_cache: &DescribedNodes,
annotated_legacy_nodes: &HashMap<NodeId, LN>,
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<NodesParams>,
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<Item = &'a NymNodeDescription> + 'a,
// async function that returns cache of appropriate legacy nodes (mixnodes or gateways)
LG: Fn(&'a AppState) -> Fut,
Fut:
Future<Output = Result<RwLockReadGuard<'a, Cache<HashMap<NodeId, LN>>>, 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<AppState>,
Query(query_params): Query<NodesParams>,
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<AppState>,
query_params: Query<NodesParams>,
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<AppState>,
query_params: Query<NodesParams>,
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<AppState>,
query_params: Query<NodesParams>,
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
}
@@ -0,0 +1,26 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<FormattedResponse<PaginatedCachedNodesResponse<SkimmedNode>>>;
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<SkimmedNode>,
}
+9
View File
@@ -0,0 +1,9 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::support::http::state::AppState;
use axum::Router;
pub(crate) fn unstable_routes_v2() -> Router<AppState> {
Router::new()
}