From d3a9d5d3ebff98e342dff1df0ebad86f346719f5 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 19 Oct 2026 23:44:20 +0200 Subject: initial commit --- src/api/mod.rs | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/api/mod.rs (limited to 'src/api/mod.rs') diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..8038f16 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,160 @@ +//! 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)); + + 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, + ) + ), + 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; -- cgit v1.3.1