1use std::{collections::HashMap, sync::Arc};
2
3use axum::{
4 extract::{Json, Query, State},
5 http::status::StatusCode,
6 response::IntoResponse,
7};
8use hopr_lib::{
9 Address, HoprBalance, Multiaddr,
10 api::graph::{
11 EdgeLinkObservable,
12 traits::{EdgeNetworkObservableRead, EdgeObservableRead},
13 },
14};
15use serde_with::{DisplayFromStr, serde_as};
16
17use crate::{ApiError, ApiErrorStatus, BASE_PATH, InternalState, checksum_address_serializer};
18
19#[serde_as]
20#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
21#[schema(example = json!({
22 "price": "0.03 wxHOPR"
23}))]
24#[serde(rename_all = "camelCase")]
25pub(crate) struct TicketPriceResponse {
27 #[serde_as(as = "DisplayFromStr")]
29 #[schema(value_type = String, example = "0.03 wxHOPR")]
30 price: HoprBalance,
31}
32
33#[utoipa::path(
35 get,
36 path = const_format::formatcp!("{BASE_PATH}/network/price"),
37 description = "Get the current ticket price",
38 responses(
39 (status = 200, description = "Current ticket price", body = TicketPriceResponse),
40 (status = 401, description = "Invalid authorization token.", body = ApiError),
41 (status = 422, description = "Unknown failure", body = ApiError)
42 ),
43 security(
44 ("api_token" = []),
45 ("bearer_token" = [])
46 ),
47 tag = "Network"
48 )]
49pub(super) async fn price(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
50 let hopr = state.hopr.clone();
51
52 match hopr.get_ticket_price().await {
53 Ok(price) => (StatusCode::OK, Json(TicketPriceResponse { price })).into_response(),
54 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
55 }
56}
57
58#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
59#[schema(example = json!({
60 "probability": 0.5
61}))]
62#[serde(rename_all = "camelCase")]
63pub(crate) struct TicketProbabilityResponse {
65 #[schema(example = 0.5)]
66 probability: f64,
68}
69
70#[utoipa::path(
72 get,
73 path = const_format::formatcp!("{BASE_PATH}/network/probability"),
74 description = "Get the current minimum incoming ticket winning probability defined by the network",
75 responses(
76 (status = 200, description = "Minimum incoming ticket winning probability defined by the network", body = TicketProbabilityResponse),
77 (status = 401, description = "Invalid authorization token.", body = ApiError),
78 (status = 422, description = "Unknown failure", body = ApiError)
79 ),
80 security(
81 ("api_token" = []),
82 ("bearer_token" = [])
83 ),
84 tag = "Network"
85 )]
86pub(super) async fn probability(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
87 let hopr = state.hopr.clone();
88
89 match hopr.get_minimum_incoming_ticket_win_probability().await {
90 Ok(p) => (
91 StatusCode::OK,
92 Json(TicketProbabilityResponse { probability: p.into() }),
93 )
94 .into_response(),
95 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
96 }
97}
98
99#[serde_as]
102#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
103#[serde(rename_all = "camelCase")]
104#[schema(example = json!({
105 "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
106 "probeRate": 0.476,
107 "lastUpdate": 1690000000000_u128,
108 "averageLatency": 100,
109 "score": 0.7
110}))]
111pub(crate) struct ConnectedPeerResponse {
113 #[serde(serialize_with = "checksum_address_serializer")]
114 #[schema(value_type = String, example = "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe")]
115 address: Address,
116 #[schema(example = 0.476)]
117 probe_rate: f64,
118 #[schema(example = 1690000000000_u128)]
120 last_update: u128,
121 #[schema(example = 100)]
123 average_latency: Option<u128>,
124 #[schema(example = 0.7)]
125 score: f64,
126}
127
128#[utoipa::path(
133 get,
134 path = const_format::formatcp!("{BASE_PATH}/network/connected"),
135 description = "List connected peers with immediate observation data from the network graph",
136 responses(
137 (status = 200, description = "Connected peers with immediate observations", body = Vec<ConnectedPeerResponse>),
138 (status = 401, description = "Invalid authorization token.", body = ApiError),
139 (status = 422, description = "Unknown failure", body = ApiError)
140 ),
141 security(
142 ("api_token" = []),
143 ("bearer_token" = [])
144 ),
145 tag = "Network"
146)]
147pub(super) async fn connected(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
148 let hopr = state.hopr.clone();
149 let graph = hopr.graph();
150 let edges = graph.connected_edges();
151
152 let me_key = graph.me();
153
154 let mut peers = Vec::new();
156 for (src, dst, obs) in &edges {
157 if src != me_key {
158 continue;
159 }
160 let Some(imm) = obs.immediate_qos() else {
161 continue;
162 };
163 if !imm.is_connected() {
164 continue;
165 }
166
167 let address = match hopr.peerid_to_chain_key(&(*dst).into()).await {
168 Ok(Some(addr)) => addr,
169 _ => continue,
170 };
171
172 peers.push(ConnectedPeerResponse {
173 address,
174 probe_rate: imm.average_probe_rate(),
175 last_update: obs.last_update().as_millis(),
176 average_latency: imm.average_latency().map(|l| l.as_millis()),
177 score: obs.score(),
178 });
179 }
180
181 (StatusCode::OK, Json(peers)).into_response()
182}
183
184#[derive(
188 Debug,
189 Clone,
190 Copy,
191 PartialEq,
192 Eq,
193 serde::Serialize,
194 serde::Deserialize,
195 strum::Display,
196 strum::EnumString,
197 utoipa::ToSchema,
198)]
199#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
200#[serde(rename_all = "lowercase")]
201#[schema(example = "chain")]
202pub(crate) enum AnnouncementOriginResponse {
203 Chain,
205 Dht,
207}
208
209impl From<hopr_lib::AnnouncementOrigin> for AnnouncementOriginResponse {
210 fn from(origin: hopr_lib::AnnouncementOrigin) -> Self {
211 match origin {
212 hopr_lib::AnnouncementOrigin::Chain => Self::Chain,
213 hopr_lib::AnnouncementOrigin::DHT => Self::Dht,
214 }
215 }
216}
217
218#[serde_as]
219#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
220#[schema(example = json!({
221 "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
222 "multiaddrs": ["/ip4/178.12.1.9/tcp/19092"],
223 "origin": "chain"
224}))]
225#[serde(rename_all = "camelCase")]
226pub(crate) struct AnnouncedPeerResponse {
228 #[serde(serialize_with = "checksum_address_serializer")]
229 #[schema(value_type = String, example = "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe")]
230 address: Address,
231 #[serde_as(as = "Vec<DisplayFromStr>")]
232 #[schema(value_type = Vec<String>, example = json!(["/ip4/178.12.1.9/tcp/19092"]))]
233 multiaddrs: Vec<Multiaddr>,
234 #[schema(example = "chain")]
235 origin: AnnouncementOriginResponse,
236}
237
238#[utoipa::path(
240 get,
241 path = const_format::formatcp!("{BASE_PATH}/network/announced"),
242 description = "List all announced peers",
243 responses(
244 (status = 200, description = "Announced peers", body = Vec<AnnouncedPeerResponse>),
245 (status = 401, description = "Invalid authorization token.", body = ApiError),
246 (status = 422, description = "Unknown failure", body = ApiError)
247 ),
248 security(
249 ("api_token" = []),
250 ("bearer_token" = [])
251 ),
252 tag = "Network"
253)]
254pub(super) async fn announced(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
255 let hopr = state.hopr.clone();
256
257 match hopr.announced_peers().await {
258 Ok(peers) => {
259 let response: Vec<AnnouncedPeerResponse> = peers
260 .into_iter()
261 .map(|peer| AnnouncedPeerResponse {
262 address: peer.address,
263 multiaddrs: peer.multiaddresses,
264 origin: peer.origin.into(),
265 })
266 .collect();
267 (StatusCode::OK, Json(response)).into_response()
268 }
269 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
270 }
271}
272
273#[derive(Debug, Default, Copy, Clone, serde::Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
276#[into_params(parameter_in = Query)]
277#[serde(default, rename_all = "camelCase")]
278#[schema(example = json!({ "reachableOnly": false }))]
279pub(crate) struct GraphQueryRequest {
281 #[schema(required = false)]
284 #[serde(default)]
285 reachable_only: bool,
286}
287
288#[utoipa::path(
297 get,
298 path = const_format::formatcp!("{BASE_PATH}/network/graph"),
299 description = "Get the network graph in DOT (Graphviz) format",
300 params(GraphQueryRequest),
301 responses(
302 (status = 200, description = "DOT representation of the network graph", body = String, content_type = "text/plain"),
303 (status = 401, description = "Invalid authorization token.", body = ApiError),
304 ),
305 security(
306 ("api_token" = []),
307 ("bearer_token" = [])
308 ),
309 tag = "Network"
310)]
311pub(super) async fn graph(
312 State(state): State<Arc<InternalState>>,
313 Query(query): Query<GraphQueryRequest>,
314) -> impl IntoResponse {
315 let hopr = &state.hopr;
316 let graph = hopr.graph();
317
318 let edges = if query.reachable_only {
319 graph.reachable_edges()
320 } else {
321 graph.connected_edges()
322 };
323
324 let mut unique_keys = std::collections::HashSet::new();
326 for (src, dst, _) in &edges {
327 unique_keys.insert(*src);
328 unique_keys.insert(*dst);
329 }
330
331 let mut key_to_addr: HashMap<hopr_lib::OffchainPublicKey, String> = HashMap::new();
332 for key in &unique_keys {
333 let label = match hopr.peerid_to_chain_key(&(*key).into()).await {
334 Ok(Some(addr)) => addr.to_string(),
335 _ => key.to_string(),
336 };
337 key_to_addr.insert(*key, label);
338 }
339
340 let label_fn = |key: &hopr_lib::OffchainPublicKey| key_to_addr.get(key).cloned().unwrap_or_else(|| key.to_string());
341
342 let dot = hopr_network_graph::render::render_edges_as_dot(&edges, &label_fn);
343
344 (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/plain")], dot).into_response()
345}
346
347#[cfg(test)]
348mod tests {
349 use std::str::FromStr;
350
351 use super::*;
352
353 #[test]
354 fn announcement_origin_response_should_serialize_as_lowercase_string() {
355 assert_eq!(
356 serde_json::to_string(&AnnouncementOriginResponse::Chain).unwrap(),
357 "\"chain\""
358 );
359 assert_eq!(
360 serde_json::to_string(&AnnouncementOriginResponse::Dht).unwrap(),
361 "\"dht\""
362 );
363 }
364
365 #[test]
366 fn announcement_origin_response_should_deserialize_from_lowercase_string() {
367 assert_eq!(
368 serde_json::from_str::<AnnouncementOriginResponse>("\"chain\"").unwrap(),
369 AnnouncementOriginResponse::Chain
370 );
371 assert_eq!(
372 serde_json::from_str::<AnnouncementOriginResponse>("\"dht\"").unwrap(),
373 AnnouncementOriginResponse::Dht
374 );
375 }
376
377 #[test]
378 fn announcement_origin_response_should_deserialize_case_insensitively_via_strum() {
379 assert_eq!(
380 AnnouncementOriginResponse::from_str("Chain").unwrap(),
381 AnnouncementOriginResponse::Chain
382 );
383 assert_eq!(
384 AnnouncementOriginResponse::from_str("CHAIN").unwrap(),
385 AnnouncementOriginResponse::Chain
386 );
387 assert_eq!(
388 AnnouncementOriginResponse::from_str("DHT").unwrap(),
389 AnnouncementOriginResponse::Dht
390 );
391 }
392
393 #[test]
394 fn announcement_origin_response_should_display_as_lowercase() {
395 assert_eq!(AnnouncementOriginResponse::Chain.to_string(), "chain");
396 assert_eq!(AnnouncementOriginResponse::Dht.to_string(), "dht");
397 }
398
399 #[test]
400 fn announcement_origin_response_should_reject_invalid_string() {
401 assert!(AnnouncementOriginResponse::from_str("invalid").is_err());
402 }
403
404 #[test]
405 fn chain_origin_should_convert_from_domain_type() {
406 assert_eq!(
407 AnnouncementOriginResponse::from(hopr_lib::AnnouncementOrigin::Chain),
408 AnnouncementOriginResponse::Chain
409 );
410 }
411
412 #[test]
413 fn dht_origin_should_convert_from_domain_type() {
414 assert_eq!(
415 AnnouncementOriginResponse::from(hopr_lib::AnnouncementOrigin::DHT),
416 AnnouncementOriginResponse::Dht
417 );
418 }
419
420 #[test]
421 fn announced_peer_response_should_serialize_with_origin() -> anyhow::Result<()> {
422 let response = AnnouncedPeerResponse {
423 address: Address::default(),
424 multiaddrs: vec!["/ip4/1.2.3.4/tcp/9091".parse()?],
425 origin: AnnouncementOriginResponse::Chain,
426 };
427
428 let json = serde_json::to_value(&response)?;
429 assert_eq!(json["origin"], "chain");
430 assert!(json["multiaddrs"].is_array());
431 assert!(json["address"].is_string());
432 Ok(())
433 }
434}