hoprd_api/
lib.rs

1//! REST API for the HOPRd node.
2pub 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    /// Name of the environment variable specifying automatic port range selection for Sessions.
17    /// Expected format: "<start_port>:<end_port>" (e.g., "9091:9099")
18    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>>, // checks
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<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
176/// Parameters needed to construct the Rest API via [`serve_api`].
177pub 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
186/// Starts the Rest API listener and router.
187pub 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}))]
355/// Standardized error response for the API
356pub(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/// Enumerates all API request errors
365/// Note that `ApiError` should not be instantiated directly, but always rather through the `ApiErrorStatus`.
366#[allow(unused)] // TODO: some errors can no longer be propagated to the REST API
367#[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
413// Errors lead to `UnknownFailure` per default
414impl<T: Error> From<T> for ApiErrorStatus {
415    fn from(value: T) -> Self {
416        Self::UnknownFailure(value.to_string())
417    }
418}
419
420// Errors lead to `UnknownFailure` per default
421impl<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}