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_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>>, // checks
67}
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
180/// Parameters needed to construct the Rest API via [`serve_api`].
181pub 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
190/// Starts the Rest API listener and router.
191pub 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}))]
343/// Standardized error response for the API
344pub(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/// Enumerates all API request errors
353/// Note that `ApiError` should not be instantiated directly, but always rather through the `ApiErrorStatus`.
354#[allow(unused)] // TODO: some errors can no longer be propagated to the REST API
355#[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
400// Errors lead to `UnknownFailure` per default
401impl<T: Error> From<T> for ApiErrorStatus {
402    fn from(value: T) -> Self {
403        Self::UnknownFailure(value.to_string())
404    }
405}
406
407// Errors lead to `UnknownFailure` per default
408impl<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}