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