1pub mod config;
3
4mod account;
5mod channels;
6mod checks;
7mod middleware;
8mod network;
9mod node;
10mod peers;
11mod root;
12mod session;
13mod tickets;
14
15pub(crate) mod env {
16 pub const HOPRD_SESSION_PORT_RANGE: &str = "HOPRD_SESSION_PORT_RANGE";
19}
20
21use std::{error::Error, iter::once, sync::Arc};
22
23use axum::{
24 Router,
25 extract::Json,
26 http::{Method, header::AUTHORIZATION, status::StatusCode},
27 response::{IntoResponse, Response},
28 routing::{delete, get, post},
29};
30use hopr_chain_connector::HoprBlockchainSafeConnector;
31use hopr_db_node::HoprNodeDb;
32use hopr_lib::{Address, Hopr, errors::HoprLibError};
33use hopr_network_graph::SharedChannelGraph;
34use hopr_transport_p2p::HoprNetwork;
36use hopr_utils_session::ListenerJoinHandles;
37use serde::Serialize;
38use tokio::net::TcpListener;
39use tower::ServiceBuilder;
40use tower_http::{
41 compression::CompressionLayer,
42 cors::{Any, CorsLayer},
43 sensitive_headers::SetSensitiveRequestHeadersLayer,
44 trace::TraceLayer,
45 validate_request::ValidateRequestHeaderLayer,
46};
47use utoipa::{
48 Modify, OpenApi,
49 openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme},
50};
51use utoipa_scalar::{Scalar, Servable as ScalarServable};
52use utoipa_swagger_ui::SwaggerUi;
53
54use crate::config::Auth;
55
56pub(crate) const BASE_PATH: &str = const_format::formatcp!("/api/v{}", env!("CARGO_PKG_VERSION_MAJOR"));
57
58type HoprBlokliConnector = HoprBlockchainSafeConnector<hopr_chain_connector::blokli_client::BlokliClient>;
59
60pub(crate) type HoprNode = Hopr<Arc<HoprBlokliConnector>, HoprNodeDb, SharedChannelGraph, HoprNetwork>;
61
62#[derive(Clone)]
63pub(crate) struct AppState {
64 pub hopr: Arc<HoprNode>,
65}
66
67pub type MessageEncoder = fn(&[u8]) -> Box<[u8]>;
68
69#[derive(Clone)]
70pub(crate) struct InternalState {
71 pub hoprd_cfg: serde_json::Value,
72 pub auth: Arc<Auth>,
73 pub hopr: Arc<HoprNode>,
74 pub open_listeners: Arc<ListenerJoinHandles>,
75 pub default_listen_host: std::net::SocketAddr,
76}
77
78#[derive(OpenApi)]
79#[openapi(
80 paths(
81 account::addresses,
82 account::balances,
83 account::withdraw,
84 channels::close_channel,
85 channels::fund_channel,
86 channels::list_channels,
87 channels::open_channel,
88 channels::show_channel,
89 checks::eligiblez,
90 checks::healthyz,
91 checks::readyz,
92 checks::startedz,
93 network::price,
94 network::probability,
95 node::configuration,
96 node::entry_nodes,
97 node::info,
98 node::peers,
99 node::version,
100 peers::peer_stats,
101 peers::ping_peer,
102 peers::show_peer_info,
103 root::metrics,
104 session::create_client,
105 session::list_clients,
106 session::adjust_session,
107 session::session_config,
108 session::session_stats,
109 session::close_client,
110 tickets::redeem_all_tickets,
111 tickets::redeem_tickets_in_channel,
112 tickets::show_all_tickets,
113 tickets::show_channel_tickets,
114 tickets::show_ticket_statistics,
115 tickets::reset_ticket_statistics,
116 ),
117 components(
118 schemas(
119 ApiError,
120 account::AccountAddressesResponse, account::AccountBalancesResponse, account::WithdrawBodyRequest, account::WithdrawResponse,
121 channels::ChannelsQueryRequest,channels::CloseChannelResponse, channels::OpenChannelBodyRequest, channels::OpenChannelResponse, channels::FundChannelResponse,
122 channels::NodeChannel, channels::NodeChannelsResponse, channels::ChannelInfoResponse, channels::FundBodyRequest,
123 network::TicketPriceResponse,
124 network::TicketProbabilityResponse,
125 node::EntryNode, node::NodeInfoResponse, node::NodePeersQueryRequest,
126 node::HeartbeatInfo, node::PeerObservations, node::PeerPacketStatsResponse, node::AnnouncedPeer, node::NodePeersResponse, node::NodeVersionResponse,
127 peers::NodePeerInfoResponse, peers::PeerPacketStatsResponse, peers::PingResponse,
128 session::SessionClientRequest, session::SessionCapability, session::RoutingOptions, session::SessionTargetSpec, session::SessionClientResponse, session::IpProtocol, session::SessionConfig,
129 session::SessionStatsResponse, session::SessionStatsLifetime, session::SessionStatsFrameBuffer, session::SessionStatsAck, session::SessionStatsSurb, session::SessionStatsTransport, session::SessionStatsState, session::SessionStatsAckMode,
130 tickets::NodeTicketStatisticsResponse, tickets::ChannelTicket,
131 )
132 ),
133 modifiers(&SecurityAddon),
134 tags(
135 (name = "Account", description = "HOPR node account endpoints"),
136 (name = "Channels", description = "HOPR node chain channels manipulation endpoints"),
137 (name = "Configuration", description = "HOPR node configuration endpoints"),
138 (name = "Checks", description = "HOPR node functionality checks"),
139 (name = "Network", description = "HOPR node network endpoints"),
140 (name = "Node", description = "HOPR node information endpoints"),
141 (name = "Peers", description = "HOPR node peer manipulation endpoints"),
142 (name = "Session", description = "HOPR node session management endpoints"),
143 (name = "Tickets", description = "HOPR node ticket management endpoints"),
144 (name = "Metrics", description = "HOPR node metrics endpoints"),
145 )
146)]
147pub struct ApiDoc;
148
149pub struct SecurityAddon;
150
151impl Modify for SecurityAddon {
152 fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
153 let components = openapi
154 .components
155 .as_mut()
156 .expect("components should be registered at this point");
157
158 components.add_security_scheme(
159 "bearer_token",
160 SecurityScheme::Http(
161 HttpBuilder::new()
162 .scheme(HttpAuthScheme::Bearer)
163 .bearer_format("token")
164 .description(Some("Bearer token authentication".to_string()))
165 .build(),
166 ),
167 );
168 components.add_security_scheme(
169 "api_token",
170 SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::with_description(
171 "X-Auth-Token",
172 "API Token",
173 ))),
174 );
175 }
176}
177
178pub struct RestApiParameters {
180 pub listener: TcpListener,
181 pub hoprd_cfg: serde_json::Value,
182 pub cfg: crate::config::Api,
183 pub hopr: Arc<HoprNode>,
184 pub session_listener_sockets: Arc<ListenerJoinHandles>,
185 pub default_session_listen_host: std::net::SocketAddr,
186}
187
188pub async fn serve_api(params: RestApiParameters) -> Result<(), std::io::Error> {
190 let RestApiParameters {
191 listener,
192 hoprd_cfg,
193 cfg,
194 hopr,
195 session_listener_sockets,
196 default_session_listen_host,
197 } = params;
198
199 let router = build_api(
200 hoprd_cfg,
201 cfg,
202 hopr,
203 session_listener_sockets,
204 default_session_listen_host,
205 )
206 .await;
207 axum::serve(listener, router).await
208}
209
210#[allow(clippy::too_many_arguments)]
211async fn build_api(
212 hoprd_cfg: serde_json::Value,
213 cfg: crate::config::Api,
214 hopr: Arc<HoprNode>,
215 open_listeners: Arc<ListenerJoinHandles>,
216 default_listen_host: std::net::SocketAddr,
217) -> Router {
218 let state = AppState { hopr };
219 let inner_state = InternalState {
220 auth: Arc::new(cfg.auth.clone()),
221 hoprd_cfg,
222 hopr: state.hopr.clone(),
223 open_listeners,
224 default_listen_host,
225 };
226
227 Router::new()
228 .merge(
229 Router::new()
230 .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
231 .merge(Scalar::with_url("/scalar", ApiDoc::openapi())),
232 )
233 .merge(
234 Router::new()
235 .route("/startedz", get(checks::startedz))
236 .route("/readyz", get(checks::readyz))
237 .route("/healthyz", get(checks::healthyz))
238 .route("/eligiblez", get(checks::eligiblez))
239 .layer(
240 ServiceBuilder::new().layer(
241 CorsLayer::new()
242 .allow_methods([Method::GET])
243 .allow_origin(Any)
244 .allow_headers(Any)
245 .max_age(std::time::Duration::from_secs(86400)),
246 ),
247 )
248 .with_state(state.into()),
249 )
250 .merge(
251 Router::new()
252 .route("/metrics", get(root::metrics))
253 .layer(axum::middleware::from_fn_with_state(
254 inner_state.clone(),
255 middleware::preconditions::authenticate,
256 ))
257 .layer(
258 ServiceBuilder::new()
259 .layer(TraceLayer::new_for_http())
260 .layer(
261 CorsLayer::new()
262 .allow_methods([Method::GET])
263 .allow_origin(Any)
264 .allow_headers(Any)
265 .max_age(std::time::Duration::from_secs(86400)),
266 )
267 .layer(axum::middleware::from_fn(middleware::prometheus::record))
268 .layer(CompressionLayer::new())
269 .layer(ValidateRequestHeaderLayer::accept("text/plain"))
270 .layer(SetSensitiveRequestHeadersLayer::new(once(AUTHORIZATION))),
271 ),
272 )
273 .nest(
274 BASE_PATH,
275 Router::new()
276 .route("/account/addresses", get(account::addresses))
277 .route("/account/balances", get(account::balances))
278 .route("/account/withdraw", post(account::withdraw))
279 .route("/peers/{destination}", get(peers::show_peer_info))
280 .route("/channels", get(channels::list_channels))
281 .route("/channels", post(channels::open_channel))
282 .route("/channels/{channelId}", get(channels::show_channel))
283 .route("/channels/{channelId}/tickets", get(tickets::show_channel_tickets))
284 .route("/channels/{channelId}", delete(channels::close_channel))
285 .route("/channels/{channelId}/fund", post(channels::fund_channel))
286 .route(
287 "/channels/{channelId}/tickets/redeem",
288 post(tickets::redeem_tickets_in_channel),
289 )
290 .route("/tickets", get(tickets::show_all_tickets))
291 .route("/tickets/redeem", post(tickets::redeem_all_tickets))
292 .route("/tickets/statistics", get(tickets::show_ticket_statistics))
293 .route("/tickets/statistics", delete(tickets::reset_ticket_statistics))
294 .route("/network/price", get(network::price))
295 .route("/network/probability", get(network::probability))
296 .route("/node/version", get(node::version))
297 .route("/node/configuration", get(node::configuration))
298 .route("/node/info", get(node::info))
299 .route("/node/peers", get(node::peers))
300 .route("/node/entry-nodes", get(node::entry_nodes))
301 .route("/peers/{destination}/ping", post(peers::ping_peer))
302 .route("/peers/{destination}/stats", get(peers::peer_stats))
303 .route("/session/config/{id}", get(session::session_config))
304 .route("/session/config/{id}", post(session::adjust_session))
305 .route("/session/stats/{id}", get(session::session_stats))
306 .route("/session/{protocol}", post(session::create_client))
307 .route("/session/{protocol}", get(session::list_clients))
308 .route("/session/{protocol}/{ip}/{port}", delete(session::close_client))
309 .with_state(inner_state.clone().into())
310 .layer(axum::middleware::from_fn_with_state(
311 inner_state.clone(),
312 middleware::preconditions::authenticate,
313 ))
314 .layer(
315 ServiceBuilder::new()
316 .layer(TraceLayer::new_for_http())
317 .layer(
318 CorsLayer::new()
319 .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
320 .allow_origin(Any)
321 .allow_headers(Any)
322 .max_age(std::time::Duration::from_secs(86400)),
323 )
324 .layer(axum::middleware::from_fn(middleware::prometheus::record))
325 .layer(CompressionLayer::new())
326 .layer(ValidateRequestHeaderLayer::accept("application/json"))
327 .layer(SetSensitiveRequestHeadersLayer::new(once(AUTHORIZATION))),
328 ),
329 )
330}
331
332fn checksum_address_serializer<S: serde::Serializer>(a: &Address, s: S) -> Result<S::Ok, S::Error> {
333 s.serialize_str(&a.to_checksum())
334}
335
336#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
337#[schema(example = json!({
338 "status": "INVALID_INPUT",
339 "error": "Invalid value passed in parameter 'XYZ'"
340}))]
341pub(crate) struct ApiError {
343 #[schema(example = "INVALID_INPUT")]
344 pub status: String,
345 #[serde(skip_serializing_if = "Option::is_none")]
346 #[schema(example = "Invalid value passed in parameter 'XYZ'")]
347 pub error: Option<String>,
348}
349
350#[allow(unused)] #[derive(Debug, Clone, PartialEq, Eq, strum::Display)]
354#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
355enum ApiErrorStatus {
356 InvalidInput,
357 InvalidChannelId,
358 PeerNotFound,
359 ChannelNotFound,
360 TicketsNotFound,
361 Timeout,
362 PingError(String),
363 Unauthorized,
364 InvalidQuality,
365 NotReady,
366 ListenHostAlreadyUsed,
367 SessionNotFound,
368 InvalidSessionId,
369 #[strum(serialize = "UNKNOWN_FAILURE")]
370 UnknownFailure(String),
371}
372
373impl From<ApiErrorStatus> for ApiError {
374 fn from(value: ApiErrorStatus) -> Self {
375 Self {
376 status: value.to_string(),
377 error: if let ApiErrorStatus::UnknownFailure(e) = value {
378 Some(e)
379 } else {
380 None
381 },
382 }
383 }
384}
385
386impl IntoResponse for ApiErrorStatus {
387 fn into_response(self) -> Response {
388 Json(ApiError::from(self)).into_response()
389 }
390}
391
392impl IntoResponse for ApiError {
393 fn into_response(self) -> Response {
394 (StatusCode::INTERNAL_SERVER_ERROR, Json(self)).into_response()
395 }
396}
397
398impl<T: Error> From<T> for ApiErrorStatus {
400 fn from(value: T) -> Self {
401 Self::UnknownFailure(value.to_string())
402 }
403}
404
405impl<T> From<T> for ApiError
407where
408 T: Error + Into<HoprLibError>,
409{
410 fn from(value: T) -> Self {
411 Self {
412 status: ApiErrorStatus::UnknownFailure("unknown error".to_string()).to_string(),
413 error: Some(value.to_string()),
414 }
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use axum::{http::StatusCode, response::IntoResponse};
421
422 use super::ApiError;
423
424 #[test]
425 fn test_api_error_to_response() {
426 let error = ApiError {
427 status: StatusCode::INTERNAL_SERVER_ERROR.to_string(),
428 error: Some("Invalid value passed in parameter 'XYZ'".to_string()),
429 };
430
431 let response = error.into_response();
432 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
433 }
434}