Skip to main content

hoprd_api/
node.rs

1// HashMap is used inside the utoipa macro attribute on the `configuration` endpoint.
2#[allow(unused_imports)]
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use axum::{
7    extract::{Json, State},
8    http::status::StatusCode,
9    response::IntoResponse,
10};
11use hopr_lib::{
12    Address, IncentiveChannelOperations, Multiaddr,
13    api::{
14        network::{Health, NetworkView},
15        node::{ComponentStatus, HasChainApi, HasNetworkView},
16    },
17};
18use serde::Serialize;
19use serde_with::{DisplayFromStr, serde_as};
20
21use crate::{ApiError, ApiErrorStatus, BASE_PATH, InternalState, checksum_address_serializer};
22
23#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
24#[schema(example = json!({
25        "version": "2.1.0",
26    }))]
27#[serde(rename_all = "camelCase")]
28/// Running node version.
29pub(crate) struct NodeVersionResponse {
30    #[schema(example = "2.1.0")]
31    version: String,
32}
33
34/// Get the release version of the running node.
35#[utoipa::path(
36        get,
37        path = const_format::formatcp!("{BASE_PATH}/node/version"),
38        description = "Get the release version of the running node",
39        responses(
40            (status = 200, description = "Fetched node version", body = NodeVersionResponse),
41            (status = 401, description = "Invalid authorization token.", body = ApiError),
42        ),
43        security(
44            ("api_token" = []),
45            ("bearer_token" = [])
46        ),
47        tag = "Node"
48    )]
49pub(super) async fn version() -> impl IntoResponse {
50    let version = hopr_lib::constants::APP_VERSION.to_string();
51    (StatusCode::OK, Json(NodeVersionResponse { version })).into_response()
52}
53
54/// Get the configuration of the running node.
55#[utoipa::path(
56    get,
57    path = const_format::formatcp!("{BASE_PATH}/node/configuration"),
58    description = "Get the configuration of the running node",
59    responses(
60        (status = 200, description = "Fetched node configuration", body = HashMap<String, String>, example = json!({
61        "network": "anvil-localhost",
62        "provider": "http://127.0.0.1:8545",
63        "hoprToken": "0x9a676e781a523b5d0c0e43731313a708cb607508",
64        "hoprChannels": "0x9a9f2ccfde556a7e9ff0848998aa4a0cfd8863ae",
65        "...": "..."
66        })),
67        (status = 401, description = "Invalid authorization token.", body = ApiError),
68    ),
69    security(
70        ("api_token" = []),
71        ("bearer_token" = [])
72    ),
73    tag = "Configuration"
74    )]
75pub(super) async fn configuration<H: Send + Sync + 'static>(
76    State(state): State<Arc<InternalState<H>>>,
77) -> impl IntoResponse {
78    (StatusCode::OK, Json(state.hoprd_cfg.clone())).into_response()
79}
80
81#[serde_as]
82#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
83#[schema(example = json!({
84        "announcedAddress": [
85            "/ip4/10.0.2.100/tcp/19092"
86        ],
87        "providerUrl": "https://staging.blokli.hoprnet.link",
88        "hoprNetworkName": "rotsee",
89        "channelClosurePeriod": 15,
90        "connectivityStatus": "Green",
91        "chainStatus": "Ready",
92        "hoprNodeSafe": "0x42bc901b1d040f984ed626eff550718498a6798a",
93        "listeningAddress": [
94            "/ip4/10.0.2.100/tcp/19092"
95        ],
96    }))]
97#[serde(rename_all = "camelCase")]
98/// Information about the current node. Covers network, addresses, eligibility, connectivity status, contracts addresses
99/// and indexer state.
100pub(crate) struct NodeInfoResponse {
101    #[serde_as(as = "Vec<DisplayFromStr>")]
102    #[schema(value_type = Vec<String>, example = json!(["/ip4/10.0.2.100/tcp/19092"]))]
103    announced_address: Vec<Multiaddr>,
104    #[serde_as(as = "Vec<DisplayFromStr>")]
105    #[schema(value_type = Vec<String>, example = json!(["/ip4/10.0.2.100/tcp/19092"]))]
106    listening_address: Vec<Multiaddr>,
107    #[schema(value_type = String, example = "https://staging.blokli.hoprnet.link")]
108    provider_url: String,
109    #[schema(value_type = String, example = "rotsee")]
110    hopr_network_name: String,
111    #[serde(serialize_with = "checksum_address_serializer")]
112    #[schema(value_type = String, example = "0x42bc901b1d040f984ed626eff550718498a6798a")]
113    hopr_node_safe: Address,
114    #[serde_as(as = "DisplayFromStr")]
115    #[schema(value_type = String, example = "Green")]
116    connectivity_status: Health,
117    /// Chain/blokli connector status.
118    #[schema(value_type = String, example = "Ready")]
119    chain_status: String,
120    /// Channel closure period in seconds
121    #[schema(example = 15)]
122    channel_closure_period: u64,
123}
124
125/// Get information about this HOPR Node.
126#[utoipa::path(
127        get,
128        path = const_format::formatcp!("{BASE_PATH}/node/info"),
129        description = "Get information about this HOPR Node",
130        responses(
131            (status = 200, description = "Fetched node informations", body = NodeInfoResponse),
132            (status = 422, description = "Unknown failure", body = ApiError)
133        ),
134        security(
135            ("api_token" = []),
136            ("bearer_token" = [])
137        ),
138        tag = "Node"
139    )]
140pub(super) async fn info<
141    H: HasChainApi<ChainError = hopr_lib::errors::HoprLibError> + HasNetworkView + Send + Sync + 'static,
142>(
143    State(state): State<Arc<InternalState<H>>>,
144) -> Result<impl IntoResponse, ApiError> {
145    let hopr = state.hopr.clone();
146
147    let identity = hopr.identity();
148
149    let provider_url = state
150        .hoprd_cfg
151        .as_object()
152        .and_then(|cfg| cfg.get("blokli_url"))
153        .and_then(|v| v.as_str());
154
155    match futures::try_join!(hopr.chain_info(), hopr.get_channel_closure_notice_period()) {
156        Ok((info, channel_closure_notice_period)) => {
157            let listening: Vec<Multiaddr> = hopr.network_view().listening_as().into_iter().collect();
158            let body = NodeInfoResponse {
159                announced_address: listening.clone(),
160                listening_address: listening,
161                provider_url: provider_url.unwrap_or("n/a").to_owned(),
162                hopr_network_name: info.hopr_network_name,
163                hopr_node_safe: identity.safe_address,
164                connectivity_status: hopr.network_view().health(),
165                chain_status: HasChainApi::status(&*hopr).to_string(),
166                channel_closure_period: channel_closure_notice_period.as_secs(),
167            };
168
169            Ok((StatusCode::OK, Json(body)).into_response())
170        }
171        Err(error) => Ok((StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(error)).into_response()),
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Node status endpoint
177// ---------------------------------------------------------------------------
178
179fn component_status_to_info(status: &ComponentStatus) -> ComponentStatusInfo {
180    match status {
181        ComponentStatus::Ready => ComponentStatusInfo {
182            status: "Ready".into(),
183            detail: None,
184        },
185        ComponentStatus::Initializing(d) => ComponentStatusInfo {
186            status: "Initializing".into(),
187            detail: Some(d.to_string()),
188        },
189        ComponentStatus::Degraded(d) => ComponentStatusInfo {
190            status: "Degraded".into(),
191            detail: Some(d.to_string()),
192        },
193        ComponentStatus::Unavailable(d) => ComponentStatusInfo {
194            status: "Unavailable".into(),
195            detail: Some(d.to_string()),
196        },
197    }
198}
199
200#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
201#[schema(example = json!({
202    "overall": "Ready",
203    "nodeState": "Node is running",
204    "components": {
205        "chain": { "status": "Ready" },
206        "network": { "status": "Ready" },
207        "transport": { "status": "Ready" }
208    }
209}))]
210#[serde(rename_all = "camelCase")]
211pub(crate) struct NodeStatusResponse {
212    /// Aggregated status across all components.
213    #[schema(example = "Ready")]
214    overall: String,
215    /// Current node lifecycle state.
216    #[schema(example = "Node is running")]
217    node_state: String,
218    /// Per-component status breakdown.
219    components: ComponentStatusesResponse,
220}
221
222#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
223#[serde(rename_all = "camelCase")]
224pub(crate) struct ComponentStatusesResponse {
225    chain: ComponentStatusInfo,
226    network: ComponentStatusInfo,
227    transport: ComponentStatusInfo,
228}
229
230#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
231#[serde(rename_all = "camelCase")]
232pub(crate) struct ComponentStatusInfo {
233    #[schema(example = "Ready")]
234    status: String,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    detail: Option<String>,
237}
238
239/// Get the aggregated status of this HOPR node and its individual components.
240#[utoipa::path(
241    get,
242    path = const_format::formatcp!("{BASE_PATH}/node/status"),
243    description = "Get the aggregated status of this HOPR node and its individual components",
244    responses(
245        (status = 200, description = "Fetched node status", body = NodeStatusResponse),
246        (status = 401, description = "Invalid authorization token.", body = ApiError),
247    ),
248    security(
249        ("api_token" = []),
250        ("bearer_token" = [])
251    ),
252    tag = "Node"
253)]
254pub(super) async fn status<
255    H: hopr_lib::api::node::HoprNodeOperations
256        + HasChainApi<ChainError = hopr_lib::errors::HoprLibError>
257        + HasNetworkView
258        + hopr_lib::api::node::HasTransportApi
259        + Send
260        + Sync
261        + 'static,
262>(
263    State(state): State<Arc<InternalState<H>>>,
264) -> impl IntoResponse {
265    use hopr_lib::api::node::{HasTransportApi, HoprNodeOperations};
266
267    let hopr = &state.hopr;
268
269    let chain = HasChainApi::status(&**hopr);
270    let network = HasNetworkView::status(&**hopr);
271    let transport = HasTransportApi::status(&**hopr);
272    let node_state = HoprNodeOperations::status(&**hopr);
273
274    let statuses = [&chain, &network, &transport];
275    let overall = if statuses.iter().any(|s| s.is_unavailable()) {
276        ComponentStatus::Unavailable("one or more components unavailable".into())
277    } else if statuses.iter().any(|s| s.is_degraded()) {
278        ComponentStatus::Degraded("one or more components degraded".into())
279    } else if statuses.iter().any(|s| s.is_initializing()) {
280        ComponentStatus::Initializing("one or more components initializing".into())
281    } else {
282        ComponentStatus::Ready
283    };
284
285    let body = NodeStatusResponse {
286        overall: overall.to_string(),
287        node_state: node_state.to_string(),
288        components: ComponentStatusesResponse {
289            chain: component_status_to_info(&chain),
290            network: component_status_to_info(&network),
291            transport: component_status_to_info(&transport),
292        },
293    };
294
295    (StatusCode::OK, Json(body)).into_response()
296}
297
298#[cfg(test)]
299mod tests {
300    use std::borrow::Cow;
301
302    use axum::{Router, body::Body, http::Request, routing::get};
303    use tower::ServiceExt;
304
305    use super::*;
306    use crate::testing::NoopNode;
307
308    fn node_router() -> Router {
309        let state: Arc<InternalState<NoopNode>> = Arc::new(InternalState {
310            hoprd_cfg: serde_json::json!({
311                "network": "test-network",
312                "provider": "http://localhost:8545"
313            }),
314            auth: Arc::new(crate::config::Auth::None),
315            hopr: Arc::new(NoopNode),
316            open_listeners: Arc::new(hopr_utils_session::ListenerJoinHandles::default()),
317            default_listen_host: "127.0.0.1:0".parse().unwrap(),
318        });
319        Router::new()
320            .route(&format!("{BASE_PATH}/node/version"), get(version))
321            .route(
322                &format!("{BASE_PATH}/node/configuration"),
323                get(configuration::<NoopNode>),
324            )
325            .with_state(state)
326    }
327
328    #[tokio::test]
329    async fn version_should_return_app_version() -> anyhow::Result<()> {
330        let app = node_router();
331        let resp = app
332            .oneshot(Request::get(format!("{BASE_PATH}/node/version")).body(Body::empty())?)
333            .await?;
334        assert_eq!(resp.status(), StatusCode::OK);
335
336        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await?;
337        let body: serde_json::Value = serde_json::from_slice(&bytes)?;
338        assert!(body["version"].as_str().is_some());
339        Ok(())
340    }
341
342    #[tokio::test]
343    async fn configuration_should_return_hoprd_config() -> anyhow::Result<()> {
344        let app = node_router();
345        let resp = app
346            .oneshot(Request::get(format!("{BASE_PATH}/node/configuration")).body(Body::empty())?)
347            .await?;
348        assert_eq!(resp.status(), StatusCode::OK);
349
350        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await?;
351        let body: serde_json::Value = serde_json::from_slice(&bytes)?;
352        assert_eq!(body["network"], "test-network");
353        assert_eq!(body["provider"], "http://localhost:8545");
354        Ok(())
355    }
356
357    #[test]
358    fn component_status_to_info_ready() {
359        let info = component_status_to_info(&ComponentStatus::Ready);
360        assert_eq!(info.status, "Ready");
361        assert!(info.detail.is_none());
362    }
363
364    #[test]
365    fn component_status_to_info_degraded() {
366        let info = component_status_to_info(&ComponentStatus::Degraded(Cow::Borrowed("low peers")));
367        assert_eq!(info.status, "Degraded");
368        assert_eq!(info.detail.as_deref(), Some("low peers"));
369    }
370
371    #[test]
372    fn component_status_to_info_unavailable() {
373        let info = component_status_to_info(&ComponentStatus::Unavailable("down".into()));
374        assert_eq!(info.status, "Unavailable");
375        assert_eq!(info.detail.as_deref(), Some("down"));
376    }
377
378    #[test]
379    fn component_status_to_info_initializing() {
380        let info = component_status_to_info(&ComponentStatus::Initializing(Cow::Borrowed("starting")));
381        assert_eq!(info.status, "Initializing");
382        assert_eq!(info.detail.as_deref(), Some("starting"));
383    }
384
385    #[test]
386    fn component_status_to_info_with_owned_cow() {
387        let info = component_status_to_info(&ComponentStatus::Degraded(Cow::Owned("dynamic detail".to_string())));
388        assert_eq!(info.status, "Degraded");
389        assert_eq!(info.detail.as_deref(), Some("dynamic detail"));
390    }
391
392    #[test]
393    fn component_status_to_info_empty_detail() {
394        let info = component_status_to_info(&ComponentStatus::Degraded(Cow::Borrowed("")));
395        assert_eq!(info.detail.as_deref(), Some(""));
396    }
397
398    #[test]
399    fn node_status_response_serializes_correctly() {
400        let body = NodeStatusResponse {
401            overall: "Ready".into(),
402            node_state: "Node is running".into(),
403            components: ComponentStatusesResponse {
404                chain: ComponentStatusInfo {
405                    status: "Ready".into(),
406                    detail: None,
407                },
408                network: ComponentStatusInfo {
409                    status: "Degraded".into(),
410                    detail: Some("low peers".into()),
411                },
412                transport: ComponentStatusInfo {
413                    status: "Ready".into(),
414                    detail: None,
415                },
416            },
417        };
418        let json = serde_json::to_value(&body).unwrap();
419        assert_eq!(json["overall"], "Ready");
420        assert_eq!(json["nodeState"], "Node is running");
421        assert_eq!(json["components"]["chain"]["status"], "Ready");
422        assert!(json["components"]["chain"]["detail"].is_null());
423        assert_eq!(json["components"]["network"]["detail"], "low peers");
424    }
425
426    #[test]
427    fn component_status_info_skips_none_detail_in_json() {
428        let info = ComponentStatusInfo {
429            status: "Ready".into(),
430            detail: None,
431        };
432        let json = serde_json::to_string(&info).unwrap();
433        assert!(!json.contains("detail"), "None detail should be skipped");
434    }
435
436    #[test]
437    fn component_status_info_includes_some_detail_in_json() {
438        let info = ComponentStatusInfo {
439            status: "Degraded".into(),
440            detail: Some("reason".into()),
441        };
442        let json = serde_json::to_string(&info).unwrap();
443        assert!(json.contains("\"detail\":\"reason\""));
444    }
445}