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