//! HTTP API: router, OpenAPI document and shared response helpers. mod debug; mod peers; mod store; use axum::{ http::{ header::{CONTENT_TYPE, ETAG}, HeaderValue, StatusCode, }, response::{IntoResponse, Response}, routing::{delete, get, put}, Router, }; use serde_json::Value as JsonValue; use sha2::{Digest, Sha256}; use tracing::{error, warn}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use crate::auth::{basic_auth, AuthState}; use crate::state::AppState; use crate::store::StoreError; /// Builds the complete HTTP router. Everything except Swagger 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) .delete(store::delete_store_entry), ) .route("/stream/{*key}", get(store::stream_store_entry)) .route("/peers", get(peers::list_peers)) .route("/peers/{peer}", delete(peers::delete_peer)) .merge(crate::compaction::router::()); if debug_api { warn!("Debug API enabled"); api = api .route( "/debug/firewall", get(debug::get_debug_firewall).put(debug::put_debug_firewall), ) .route("/debug/blacklist", put(debug::put_debug_blacklist)); } api.layer(axum::middleware::from_fn_with_state(auth_state, basic_auth)) .merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi())) .with_state(state) } // --- shared helpers ---------------------------------------------------------- pub(crate) fn store_status(error: StoreError, context: &str) -> StatusCode { match error { StoreError::Conflict => StatusCode::CONFLICT, StoreError::NotFound => StatusCode::NOT_FOUND, other => { error!(error = %format!("{other:#}"), "{context}"); StatusCode::INTERNAL_SERVER_ERROR } } } fn etag_for(bytes: &[u8]) -> String { let digest = Sha256::digest(bytes); let hex = digest .iter() .map(|byte| format!("{byte:02x}")) .collect::(); format!("\"{hex}\"") } fn parse_key_path(key: &str) -> Result, StatusCode> { let segments: Vec = key.split('/').map(str::to_owned).collect(); if segments.is_empty() || segments.iter().any(|segment| segment.is_empty()) { return Err(StatusCode::BAD_REQUEST); } Ok(segments) } fn response_for_value( value: &JsonValue, accept: Option<&HeaderValue>, ) -> Result { let wants_text = accept .and_then(|value| value.to_str().ok()) .is_some_and(|value| { value .split(',') .any(|item| item.trim().starts_with("text/plain")) }); let (content_type, body) = if wants_text { let body = match value { JsonValue::String(value) => value.clone(), other => serde_json::to_string(other).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, }; ("text/plain; charset=utf-8", body) } else { ( "application/json", serde_json::to_string(value).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, ) }; let etag = etag_for(body.as_bytes()); let mut response = body.into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); response.headers_mut().insert( ETAG, HeaderValue::from_str(&etag).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, ); Ok(response) } // --- openapi spec ------------------------------------------------------------ // TODO spec is mostly out of sync #[derive(OpenApi)] #[openapi( paths( store::get_store_root, store::get_store_entry, store::put_store_entry, store::delete_store_entry, peers::list_peers, peers::delete_peer, ), components( schemas( peers::PeerStatus, peers::PeerListResponse, peers::PeerLivenessStatus, peers::EpochInfo, ) ), tags( (name = "Store", description = "Key-value store operations"), (name = "Peers", description = "Peer discovery and retirement") ), info( title = "Automerge Replication API", version = "1.0.0", description = "HTTP API for a replicated Automerge key-value store" ) )] struct ApiDoc;