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