//! Uniform HTTP error mapping for the API layer (v0.5.4). //! //! Before this, ~40 handlers in `app.rs` hand-wrote //! `match … { Err(e) => (StatusCode::…, format!("{e}")).into_response() }`, //! and the `openpxe_core::Error` → status mapping drifted between them //! (e.g. `Invalid` → 400 in most places, 404 in one). [`AppError`] wraps //! `openpxe_core::Error` so a handler can return `Result` and //! `?` its way out, getting one consistent status + body. The body stays //! plain-text (matching the previous `(StatusCode, String)` responses) so //! existing clients and tests see no shape change; 5xx detail is logged //! and returned verbatim exactly as before. //! //! Handlers with *intentional* domain-specific statuses (e.g. a duplicate //! share → 409, a still-open chunked upload → 409) keep their explicit //! returns — `AppError` is for the common case, not a straitjacket. use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use openpxe_core::Error as CoreError; /// Newtype over [`openpxe_core::Error`] with a uniform [`IntoResponse`]. #[derive(Debug)] pub struct AppError(pub CoreError); impl From for AppError { fn from(e: CoreError) -> Self { AppError(e) } } impl From for AppError { fn from(e: std::io::Error) -> Self { AppError(CoreError::Io(e)) } } impl AppError { /// The HTTP status this error maps to. Public so handlers (and tests) /// can reason about the mapping in one place. #[must_use] pub fn status(&self) -> StatusCode { match self.0 { CoreError::NotFound(_) => StatusCode::NOT_FOUND, CoreError::Invalid(_) => StatusCode::BAD_REQUEST, CoreError::Config(_) | CoreError::Io(_) | CoreError::Other(_) => { StatusCode::INTERNAL_SERVER_ERROR } } } } impl IntoResponse for AppError { fn into_response(self) -> Response { let status = self.status(); // Match the prior hand-written responses: the 4xx arms returned the // bare inner message (not the `Display` prefix), so a UI showing // `await r.text()` reads "metadata too long", not "invalid input: // metadata too long". 5xx keeps the full `Display` string. let body = match &self.0 { CoreError::Invalid(m) | CoreError::NotFound(m) => m.clone(), other => other.to_string(), }; if status.is_server_error() { // Log the full detail server-side; the body still carries it // (unchanged from the prior `format!("{e}")` behaviour), but the // log line is what an operator greps for. tracing::error!(target: "openpxe::http", error = %self.0, "request failed"); } (status, body).into_response() } } #[cfg(test)] mod tests { use super::*; #[test] fn status_mapping_is_consistent() { assert_eq!( AppError(CoreError::NotFound("x".into())).status(), StatusCode::NOT_FOUND ); assert_eq!( AppError(CoreError::Invalid("x".into())).status(), StatusCode::BAD_REQUEST ); assert_eq!( AppError(CoreError::Config("x".into())).status(), StatusCode::INTERNAL_SERVER_ERROR ); } }