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, 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")]
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(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")]
96pub(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 #[schema(value_type = String, example = "Ready")]
117 chain_status: String,
118 #[schema(example = 15)]
120 channel_closure_period: u64,
121}
122
123#[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
169fn 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 #[schema(example = "Ready")]
208 overall: String,
209 #[schema(example = "Node is running")]
211 node_state: String,
212 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#[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}