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