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::{
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")]
28pub(crate) struct NodeVersionResponse {
30 #[schema(example = "2.1.0")]
31 version: String,
32}
33
34#[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#[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")]
98pub(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 #[schema(value_type = String, example = "Ready")]
119 chain_status: String,
120 #[schema(example = 15)]
122 channel_closure_period: u64,
123}
124
125#[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
175fn 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 #[schema(example = "Ready")]
214 overall: String,
215 #[schema(example = "Node is running")]
217 node_state: String,
218 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#[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}