Skip to main content

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::{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;
34// pub use hopr_builder::config::{HOPR_TCP_BUFFER_SIZE, HOPR_UDP_BUFFER_SIZE, HOPR_UDP_QUEUE_SIZE};
35use 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
178/// Parameters needed to construct the Rest API via [`serve_api`].
179pub 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
188/// Starts the Rest API listener and router.
189pub 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}))]
341/// Standardized error response for the API
342pub(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/// Enumerates all API request errors
351/// Note that `ApiError` should not be instantiated directly, but always rather through the `ApiErrorStatus`.
352#[allow(unused)] // TODO: some errors can no longer be propagated to the REST API
353#[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
398// Errors lead to `UnknownFailure` per default
399impl<T: Error> From<T> for ApiErrorStatus {
400    fn from(value: T) -> Self {
401        Self::UnknownFailure(value.to_string())
402    }
403}
404
405// Errors lead to `UnknownFailure` per default
406impl<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}