summaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/mod.rs64
1 files changed, 60 insertions, 4 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> {