diff options
Diffstat (limited to 'src/api/mod.rs')
| -rw-r--r-- | src/api/mod.rs | 170 |
1 files changed, 62 insertions, 108 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index 2cb0415..9bb2bed 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,11 @@ -//! HTTP API: router, the API description and shared response helpers. +//! 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; @@ -12,22 +17,20 @@ mod store; use axum::{ Json, Router, body::Body, - extract::{Request, State}, + extract::Request, http::{ HeaderValue, StatusCode, - header::{CONTENT_LENGTH, CONTENT_TYPE, ETAG}, + header::{CONTENT_LENGTH, CONTENT_TYPE}, }, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get}, }; use serde_json::{Value as JsonValue, json}; -use sha2::{Digest, Sha256}; use tracing::{error, warn}; use utoipa_swagger_ui::SwaggerUi; -use crate::api::auth::{AuthState, AuthenticatedUser, basic_auth}; -use crate::document::meta; +use crate::api::auth::{AuthState, basic_auth}; use crate::document::store::StoreError; use crate::state::AppState; @@ -40,6 +43,7 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> "/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)) @@ -52,9 +56,16 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> ) .route("/admin/stats", get(stats::get_stats)) .route("/admin/document", get(document::get_document)) - .route("/admin/config", get(get_config)) + .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::<AppState>()); + #[cfg(feature = "dashboard")] + { + api = api.merge(dashboard::router(state.clone())); + } + if debug_api { warn!("Debug API enabled"); api = api.route( @@ -69,7 +80,7 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("/openapi.yaml")), ) .route("/openapi.yaml", get(openapi_spec)) - .route("/health", get(health::health)) + .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)) @@ -159,119 +170,62 @@ async fn openapi_spec() -> impl IntoResponse { ) } -#[derive(Debug, Default, serde::Deserialize)] -struct ConfigQuery { - /// Text with placeholders as it ends up, or as written. - #[serde(default)] - values: crate::config::Values, -} - -/// `GET /admin/config`: the effective configuration with each value's source; -/// imported values and secrets are left out. -async fn get_config( - State(state): State<AppState>, - axum::extract::Query(query): axum::extract::Query<ConfigQuery>, -) -> Json<JsonValue> { - Json(state.config.get(query.values).clone()) -} - // --- 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 - } +/// 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<String>); + +impl ApiError { + pub(crate) fn status(&self) -> StatusCode { + self.0 } } -fn etag_for(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let hex = digest - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::<String>(); - - format!("\"{hex}\"") +impl From<StatusCode> for ApiError { + fn from(status: StatusCode) -> Self { + Self(status, None) + } } -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); +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(), + } } - - Ok(segments) } -/// A path for a write. Protocol data (`_continuum`: remake records, members, forget -/// records, epoch metadata) is what every node acts on, so a write there acts on the -/// whole swarm: only users listed in `--protocol-writers` may, and each such write is -/// logged. Epoch metadata (and `_continuum` as a whole, which contains it) is never -/// writable: the protocol reads it at the snapshot change, so an edit only misleads -/// whoever reads it later. `403` otherwise. -fn parse_write_path( - state: &AppState, - user: &AuthenticatedUser, - key: &str, - method: &str, -) -> Result<Vec<String>, StatusCode> { - let path = parse_key_path(key)?; - if !meta::is_protocol(&path) { - return Ok(path); - } - // `_continuum` itself, epoch metadata and the swarm ID: nobody. - let fixed = path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM; - if fixed || !state.protocol_writers.contains(&user.0) { - warn!(user = %user.0, method, path = %key, "Refused a write to protocol data"); - return Err(StatusCode::FORBIDDEN); +/// 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<StoreError> for ApiError { + fn from(error: StoreError) -> Self { + store_status(error, "Failed to read store path") } - warn!(user = %user.0, method, path = %key, "Protocol data written through the store API; this acts on the whole swarm"); - Ok(path) } -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)?, - ) +pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError { + let status = match error { + StoreError::Conflict + | StoreError::NotAList + | StoreError::PastTheEnd { .. } + | StoreError::NotACounter + | StoreError::Retyping(_) + | StoreError::NotCastable { .. } => 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())) +} - 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) +/// 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() } |
