summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-26 05:00:06 +0100
committertv <tv@krebsco.de>2026-10-26 05:00:06 +0100
commit968a28c6e68e35e127ec3dd20ed2a8eeb8b30cc0 (patch)
tree278f93e0a1d008856a7e31e2222e2101e68fdf69 /src
parent28400cef36578e5364e8497c9b77618ca0da084b (diff)
unify API error responses
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs64
-rw-r--r--src/remake/api.rs13
-rw-r--r--src/remake/quarantine.rs2
3 files changed, 62 insertions, 17 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index bec7764..b92f421 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -10,15 +10,17 @@ mod store;
use axum::{
Json, Router,
- extract::State,
+ body::Body,
+ extract::{Request, State},
http::{
HeaderValue, StatusCode,
- header::{CONTENT_TYPE, ETAG},
+ header::{CONTENT_LENGTH, CONTENT_TYPE, ETAG},
},
+ middleware::{self, Next},
response::{IntoResponse, Response},
routing::{delete, get, put},
};
-use serde_json::Value as JsonValue;
+use serde_json::{Value as JsonValue, json};
use sha2::{Digest, Sha256};
use tracing::{error, warn};
use utoipa::OpenApi;
@@ -61,11 +63,65 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
.route("/debug/blacklist", put(debug::put_debug_blacklist));
}
- api.layer(axum::middleware::from_fn_with_state(auth_state, basic_auth))
+ api.layer(middleware::from_fn_with_state(auth_state, basic_auth))
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()))
+ // Outermost, so authentication failures get the error body too.
+ .layer(middleware::from_fn(json_errors))
.with_state(state)
}
+/// An error answer: `{"error": message}` with `details`' fields added (`docs/openapi.yaml`,
+/// `Error`).
+pub(crate) fn error_response(
+ status: StatusCode,
+ message: impl Into<String>,
+ details: Option<JsonValue>,
+) -> Response {
+ let mut body = json!({ "error": message.into() });
+ if let (Some(JsonValue::Object(extra)), Some(object)) = (details, body.as_object_mut()) {
+ object.extend(extra);
+ }
+ (status, Json(body)).into_response()
+}
+
+/// Gives every error answer the same body (`error_response`). Handlers that fail with
+/// a bare status or a text message, and axum's own rejections (a malformed query or
+/// body), get it here: the text becomes the message, or the status's reason if there
+/// is none. Errors that are JSON already pass unchanged, and so do their headers
+/// (`WWW-Authenticate` on a 401).
+async fn json_errors(request: Request, next: Next) -> Response {
+ let response = next.run(request).await;
+ let status = response.status();
+ if !(status.is_client_error() || status.is_server_error()) {
+ return response;
+ }
+ let is_json = response
+ .headers()
+ .get(CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| value.starts_with("application/json"));
+ if is_json {
+ return response;
+ }
+ let (mut parts, body) = response.into_parts();
+ // Error texts are short; a larger one is cut to its reason.
+ let text = axum::body::to_bytes(body, 64 * 1024)
+ .await
+ .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_owned())
+ .unwrap_or_default();
+ let message = if text.is_empty() {
+ status.canonical_reason().unwrap_or("error").to_lowercase()
+ } else {
+ text
+ };
+ let body = json!({ "error": message }).to_string();
+ parts.headers.remove(CONTENT_LENGTH);
+ parts
+ .headers
+ .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
+ Response::from_parts(parts, Body::from(body))
+}
+
/// `GET /admin/config`: the effective configuration with each value's source;
/// imported values are left out.
async fn get_config(State(state): State<AppState>) -> Json<JsonValue> {
diff --git a/src/remake/api.rs b/src/remake/api.rs
index 24fb7e8..515468d 100644
--- a/src/remake/api.rs
+++ b/src/remake/api.rs
@@ -72,6 +72,7 @@ use super::records::{
Retention, Vote,
};
use super::{Host, kind, vote};
+use crate::api::error_response;
use crate::document::store::StoreError;
use crate::force::Force;
@@ -281,18 +282,6 @@ enum StartError {
Store(#[from] StoreError),
}
-pub(super) fn error_response(
- status: StatusCode,
- message: impl Into<String>,
- details: Option<JsonValue>,
-) -> Response {
- let mut body = json!({ "error": message.into() });
- if let (Some(JsonValue::Object(extra)), Some(object)) = (details, body.as_object_mut()) {
- object.extend(extra);
- }
- (status, Json(body)).into_response()
-}
-
fn grace<H: Host>(host: &H) -> u64 {
host.dormant_after().as_millis() as u64
}
diff --git a/src/remake/quarantine.rs b/src/remake/quarantine.rs
index e7ef136..b5166c9 100644
--- a/src/remake/quarantine.rs
+++ b/src/remake/quarantine.rs
@@ -27,10 +27,10 @@ use tracing::{info, warn};
use uuid::Uuid;
use super::Host;
-use super::api::error_response;
use super::delta;
use super::kind::Translator;
use super::local::Quarantined;
+use crate::api::error_response;
use crate::document::replica::Epoch;
use crate::document::store;