summaryrefslogtreecommitdiffstats
path: root/src/api/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/mod.rs')
-rw-r--r--src/api/mod.rs160
1 files changed, 160 insertions, 0 deletions
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<AppState> = 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::<String>();
+
+ format!("\"{hex}\"")
+}
+
+fn parse_key_path(key: &str) -> Result<Vec<String>, StatusCode> {
+ let segments: Vec<String> = 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<Response, StatusCode> {
+ 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;