//! HTTP API: router, the API description and shared response helpers (the store's //! own are in `store`). pub(crate) mod auth; mod changes; mod config; #[cfg(feature = "dashboard")] mod dashboard; mod debug; mod document; mod health; mod log; mod peers; mod stats; mod store; use axum::{ Json, Router, body::Body, extract::Request, http::{ HeaderValue, StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE}, }, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get}, }; use serde_json::{Value as JsonValue, json}; use tracing::{error, warn}; use utoipa_swagger_ui::SwaggerUi; use crate::api::auth::{AuthState, basic_auth}; use crate::document::store::StoreError; use crate::state::AppState; /// Builds the complete HTTP router. Everything except the API description (`/swagger-ui`, /// `/openapi.yaml`), `/health` and `/ready` is authenticated. pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> Router { let mut api: Router = Router::new() .route("/store", get(store::get_store_root)) .route( "/store/{*key}", get(store::get_store_entry) .put(store::put_store_entry) .post(store::post_store_entry) .delete(store::delete_store_entry), ) .route("/stream", get(store::stream_store_root)) .route("/stream/{*key}", get(store::stream_store_entry)) .route("/peers", get(peers::list_peers)) .route("/peers/{peer}", delete(peers::delete_peer)) .route( "/admin/log", get(log::get_log_filter).put(log::put_log_filter), ) .route("/admin/stats", get(stats::get_stats)) .route("/admin/document", get(document::get_document)) .route("/admin/changes", get(changes::get_changes)) .route("/admin/changes/{*key}", get(changes::get_changes_at)) .route("/admin/config", get(config::get_config)) .merge(crate::remake::router::()); #[cfg(feature = "dashboard")] { api = api.merge(dashboard::router(state.clone())); } if debug_api { warn!("Debug API enabled"); api = api.route( "/debug/firewall", get(debug::get_debug_firewall).put(debug::put_debug_firewall), ); } api.layer(middleware::from_fn_with_state(auth_state, basic_auth)) // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it. .merge( SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("/openapi.yaml")), ) .route("/openapi.yaml", get(openapi_spec)) .route("/health", get(health::node_health)) .route("/ready", get(health::ready)) // Outermost, so authentication failures get the error body too. .layer(middleware::from_fn(json_errors)) .with_state(state) } /// The HTTP router of a node that only relays (`--relay-only`): its figures and the log /// filter (authenticated), and `/health`. #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] pub(crate) fn relay_only_router( transport: std::sync::Arc, auth_state: AuthState, ) -> Router { let relay = stats::RelayOnly { transport, started: tokio::time::Instant::now(), }; Router::new() .route( "/admin/log", get(log::get_log_filter).put(log::put_log_filter), ) .route("/admin/stats", get(stats::get_relay_only_stats)) .layer(middleware::from_fn_with_state(auth_state, basic_auth)) .route("/health", get(health::health)) .layer(middleware::from_fn(json_errors)) .with_state(relay) } /// An error answer: `{"error": message}` with `details`' fields added (`docs/openapi.yaml`, /// `Error`). pub(crate) fn error_response( status: StatusCode, message: impl Into, details: Option, ) -> Response { let mut body = json!({ "error": message.into() }); if let (Some(JsonValue::Object(extra)), Some(object)) = (details, body.as_object_mut()) { object.extend(extra); } (status, Json(body)).into_response() } /// Gives every error answer the same body (`error_response`). Handlers that fail with /// a bare status or a text message, and axum's own rejections (a malformed query or /// body), get it here: the text becomes the message, or the status's reason if there /// is none. Errors that are JSON already pass unchanged, and so do their headers /// (`WWW-Authenticate` on a 401). async fn json_errors(request: Request, next: Next) -> Response { let response = next.run(request).await; let status = response.status(); if !(status.is_client_error() || status.is_server_error()) { return response; } let is_json = response .headers() .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.starts_with("application/json")); if is_json { return response; } let (mut parts, body) = response.into_parts(); // Error texts are short; a larger one is cut to its reason. let text = axum::body::to_bytes(body, 64 * 1024) .await .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_owned()) .unwrap_or_default(); let message = if text.is_empty() { status.canonical_reason().unwrap_or("error").to_lowercase() } else { text }; let body = json!({ "error": message }).to_string(); parts.headers.remove(CONTENT_LENGTH); parts .headers .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); Response::from_parts(parts, Body::from(body)) } /// `GET /openapi.yaml`: the API description (`docs/openapi.yaml`, built in). async fn openapi_spec() -> impl IntoResponse { ( [(CONTENT_TYPE, HeaderValue::from_static("application/yaml"))], include_str!("../../docs/openapi.yaml"), ) } // --- shared helpers ---------------------------------------------------------- /// An error answer: its status, and why where it is known (`error_response`); a bare /// status otherwise (`json_errors` gives it the body). pub(crate) struct ApiError(StatusCode, Option); impl ApiError { pub(crate) fn status(&self) -> StatusCode { self.0 } } impl From for ApiError { fn from(status: StatusCode) -> Self { Self(status, None) } } impl IntoResponse for ApiError { fn into_response(self) -> Response { match self.1 { Some(message) => error_response(self.0, message, None), None => self.0.into_response(), } } } /// A store error as an answer, saying why (`StoreError`'s text) where it is the /// client's; others are logged with `context`, and are a bare `500`. /// A read's (`store_status`). impl From for ApiError { fn from(error: StoreError) -> Self { store_status(error, "Failed to read store path") } } pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError { let status = match error { StoreError::Conflict | StoreError::NotAList | StoreError::PastTheEnd { .. } | StoreError::NotACounter => StatusCode::CONFLICT, StoreError::NotFound | StoreError::UnknownHeads => StatusCode::NOT_FOUND, StoreError::Rejoining => StatusCode::SERVICE_UNAVAILABLE, other => { error!(error = %format!("{other:#}"), "{context}"); return StatusCode::INTERNAL_SERVER_ERROR.into(); } }; ApiError(status, Some(error.to_string())) } /// A media type without its parameters: `text/plain; charset=utf-8` is `text/plain`. /// For `Content-Type`, and each item of an `Accept` (split at `,`). pub(crate) fn media_type(value: &str) -> &str { value.split(';').next().unwrap_or("").trim() }