diff options
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/mod.rs | 64 |
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> { |
