1#[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")]
27pub(crate) struct NodeVersionResponse {
29 #[schema(example = "2.1.0")]
30 version: String,
31}
32
33#[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#[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")]
97pub(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 #[schema(value_type = String, example = "Ready")]
118 chain_status: String,
119 #[schema(example = 15)]
121 channel_closure_period: u64,
122}
123
124#[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
174fn 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 #[schema(example = "Ready")]
213 overall: String,
214 #[schema(example = "Node is running")]
216 node_state: String,
217 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#[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}