aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/mod.rs')
-rw-r--r--src/api/mod.rs170
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()
}