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, Multiaddr,
13    api::{
14        network::{Health, NetworkView},
15        node::{ComponentStatus, HasChainApi, HasNetworkView, IncentiveChannelOperations},
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(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
76    (StatusCode::OK, Json(state.hoprd_cfg.clone())).into_response()
77}
78
79#[serde_as]
80#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
81#[schema(example = json!({
82        "announcedAddress": [
83            "/ip4/10.0.2.100/tcp/19092"
84        ],
85        "providerUrl": "https://staging.blokli.hoprnet.link",
86        "hoprNetworkName": "rotsee",
87        "channelClosurePeriod": 15,
88        "connectivityStatus": "Green",
89        "chainStatus": "Ready",
90        "hoprNodeSafe": "0x42bc901b1d040f984ed626eff550718498a6798a",
91        "listeningAddress": [
92            "/ip4/10.0.2.100/tcp/19092"
93        ],
94    }))]
95#[serde(rename_all = "camelCase")]
96/// Information about the current node. Covers network, addresses, eligibility, connectivity status, contracts addresses
97/// and indexer state.
98pub(crate) struct NodeInfoResponse {
99    #[serde_as(as = "Vec<DisplayFromStr>")]
100    #[schema(value_type = Vec<String>, example = json!(["/ip4/10.0.2.100/tcp/19092"]))]
101    announced_address: Vec<Multiaddr>,
102    #[serde_as(as = "Vec<DisplayFromStr>")]
103    #[schema(value_type = Vec<String>, example = json!(["/ip4/10.0.2.100/tcp/19092"]))]
104    listening_address: Vec<Multiaddr>,
105    #[schema(value_type = String, example = "https://staging.blokli.hoprnet.link")]
106    provider_url: String,
107    #[schema(value_type = String, example = "rotsee")]
108    hopr_network_name: String,
109    #[serde(serialize_with = "checksum_address_serializer")]
110    #[schema(value_type = String, example = "0x42bc901b1d040f984ed626eff550718498a6798a")]
111    hopr_node_safe: Address,
112    #[serde_as(as = "DisplayFromStr")]
113    #[schema(value_type = String, example = "Green")]
114    connectivity_status: Health,
115    /// Chain/blokli connector status.
116    #[schema(value_type = String, example = "Ready")]
117    chain_status: String,
118    /// Channel closure period in seconds
119    #[schema(example = 15)]
120    channel_closure_period: u64,
121}
122
123/// Get information about this HOPR Node.
124#[utoipa::path(
125        get,
126        path = const_format::formatcp!("{BASE_PATH}/node/info"),
127        description = "Get information about this HOPR Node",
128        responses(
129            (status = 200, description = "Fetched node informations", body = NodeInfoResponse),
130            (status = 422, description = "Unknown failure", body = ApiError)
131        ),
132        security(
133            ("api_token" = []),
134            ("bearer_token" = [])
135        ),
136        tag = "Node"
137    )]
138pub(super) async fn info(State(state): State<Arc<InternalState>>) -> Result<impl IntoResponse, ApiError> {
139    let hopr = state.hopr.clone();
140
141    let identity = hopr.identity();
142
143    let provider_url = state
144        .hoprd_cfg
145        .as_object()
146        .and_then(|cfg| cfg.get("blokli_url"))
147        .and_then(|v| v.as_str());
148
149    match futures::try_join!(hopr.chain_info(), hopr.get_channel_closure_notice_period()) {
150        Ok((info, channel_closure_notice_period)) => {
151            let listening: Vec<Multiaddr> = hopr.network_view().listening_as().into_iter().collect();
152            let body = NodeInfoResponse {
153                announced_address: listening.clone(),
154                listening_address: listening,
155                provider_url: provider_url.unwrap_or("n/a").to_owned(),
156                hopr_network_name: info.hopr_network_name,
157                hopr_node_safe: identity.safe_address,
158                connectivity_status: hopr.network_view().health(),
159                chain_status: HasChainApi::status(&*hopr).to_string(),
160                channel_closure_period: channel_closure_notice_period.as_secs(),
161            };
162
163            Ok((StatusCode::OK, Json(body)).into_response())
164        }
165        Err(error) => Ok((StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(error)).into_response()),
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Node status endpoint
171// ---------------------------------------------------------------------------
172
173fn component_status_to_info(status: &ComponentStatus) -> ComponentStatusInfo {
174    match status {
175        ComponentStatus::Ready => ComponentStatusInfo {
176            status: "Ready".into(),
177            detail: None,
178        },
179        ComponentStatus::Initializing(d) => ComponentStatusInfo {
180            status: "Initializing".into(),
181            detail: Some(d.to_string()),
182        },
183        ComponentStatus::Degraded(d) => ComponentStatusInfo {
184            status: "Degraded".into(),
185            detail: Some(d.to_string()),
186        },
187        ComponentStatus::Unavailable(d) => ComponentStatusInfo {
188            status: "Unavailable".into(),
189            detail: Some(d.to_string()),
190        },
191    }
192}
193
194#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
195#[schema(example = json!({
196    "overall": "Ready",
197    "nodeState": "Node is running",
198    "components": {
199        "chain": { "status": "Ready" },
200        "network": { "status": "Ready" },
201        "transport": { "status": "Ready" }
202    }
203}))]
204#[serde(rename_all = "camelCase")]
205pub(crate) struct NodeStatusResponse {
206    /// Aggregated status across all components.
207    #[schema(example = "Ready")]
208    overall: String,
209    /// Current node lifecycle state.
210    #[schema(example = "Node is running")]
211    node_state: String,
212    /// Per-component status breakdown.
213    components: ComponentStatusesResponse,
214}
215
216#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
217#[serde(rename_all = "camelCase")]
218pub(crate) struct ComponentStatusesResponse {
219    chain: ComponentStatusInfo,
220    network: ComponentStatusInfo,
221    transport: ComponentStatusInfo,
222}
223
224#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
225#[serde(rename_all = "camelCase")]
226pub(crate) struct ComponentStatusInfo {
227    #[schema(example = "Ready")]
228    status: String,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    detail: Option<String>,
231}
232
233/// Get the aggregated status of this HOPR node and its individual components.
234#[utoipa::path(
235    get,
236    path = const_format::formatcp!("{BASE_PATH}/node/status"),
237    description = "Get the aggregated status of this HOPR node and its individual components",
238    responses(
239        (status = 200, description = "Fetched node status", body = NodeStatusResponse),
240        (status = 401, description = "Invalid authorization token.", body = ApiError),
241    ),
242    security(
243        ("api_token" = []),
244        ("bearer_token" = [])
245    ),
246    tag = "Node"
247)]
248pub(super) async fn status(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
249    let hopr = &state.hopr;
250    let statuses = hopr.component_statuses();
251    let overall = statuses.aggregate();
252
253    let body = NodeStatusResponse {
254        overall: overall.to_string(),
255        node_state: statuses.node_state.to_string(),
256        components: ComponentStatusesResponse {
257            chain: component_status_to_info(&statuses.chain),
258            network: component_status_to_info(&statuses.network),
259            transport: component_status_to_info(&statuses.transport),
260        },
261    };
262
263    (StatusCode::OK, Json(body)).into_response()
264}
265
266#[cfg(test)]
267mod tests {
268    use std::borrow::Cow;
269
270    use super::*;
271
272    #[test]
273    fn component_status_to_info_ready() {
274        let info = component_status_to_info(&ComponentStatus::Ready);
275        assert_eq!(info.status, "Ready");
276        assert!(info.detail.is_none());
277    }
278
279    #[test]
280    fn component_status_to_info_degraded() {
281        let info = component_status_to_info(&ComponentStatus::Degraded(Cow::Borrowed("low peers")));
282        assert_eq!(info.status, "Degraded");
283        assert_eq!(info.detail.as_deref(), Some("low peers"));
284    }
285
286    #[test]
287    fn component_status_to_info_unavailable() {
288        let info = component_status_to_info(&ComponentStatus::Unavailable("down".into()));
289        assert_eq!(info.status, "Unavailable");
290        assert_eq!(info.detail.as_deref(), Some("down"));
291    }
292
293    #[test]
294    fn component_status_to_info_initializing() {
295        let info = component_status_to_info(&ComponentStatus::Initializing(Cow::Borrowed("starting")));
296        assert_eq!(info.status, "Initializing");
297        assert_eq!(info.detail.as_deref(), Some("starting"));
298    }
299
300    #[test]
301    fn component_status_to_info_with_owned_cow() {
302        let info = component_status_to_info(&ComponentStatus::Degraded(Cow::Owned("dynamic detail".to_string())));
303        assert_eq!(info.status, "Degraded");
304        assert_eq!(info.detail.as_deref(), Some("dynamic detail"));
305    }
306
307    #[test]
308    fn component_status_to_info_empty_detail() {
309        let info = component_status_to_info(&ComponentStatus::Degraded(Cow::Borrowed("")));
310        assert_eq!(info.detail.as_deref(), Some(""));
311    }
312
313    #[test]
314    fn node_status_response_serializes_correctly() {
315        let body = NodeStatusResponse {
316            overall: "Ready".into(),
317            node_state: "Node is running".into(),
318            components: ComponentStatusesResponse {
319                chain: ComponentStatusInfo {
320                    status: "Ready".into(),
321                    detail: None,
322                },
323                network: ComponentStatusInfo {
324                    status: "Degraded".into(),
325                    detail: Some("low peers".into()),
326                },
327                transport: ComponentStatusInfo {
328                    status: "Ready".into(),
329                    detail: None,
330                },
331            },
332        };
333        let json = serde_json::to_value(&body).unwrap();
334        assert_eq!(json["overall"], "Ready");
335        assert_eq!(json["nodeState"], "Node is running");
336        assert_eq!(json["components"]["chain"]["status"], "Ready");
337        assert!(json["components"]["chain"]["detail"].is_null());
338        assert_eq!(json["components"]["network"]["detail"], "low peers");
339    }
340
341    #[test]
342    fn component_status_info_skips_none_detail_in_json() {
343        let info = ComponentStatusInfo {
344            status: "Ready".into(),
345            detail: None,
346        };
347        let json = serde_json::to_string(&info).unwrap();
348        assert!(!json.contains("detail"), "None detail should be skipped");
349    }
350
351    #[test]
352    fn component_status_info_includes_some_detail_in_json() {
353        let info = ComponentStatusInfo {
354            status: "Degraded".into(),
355            detail: Some("reason".into()),
356        };
357        let json = serde_json::to_string(&info).unwrap();
358        assert!(json.contains("\"detail\":\"reason\""));
359    }
360}