use std::path::PathBuf; use std::sync::Arc; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use crate::db::Db; #[derive(Clone)] pub struct AppState { pub db: Db, /// Canonicalized server root folder. pub root: PathBuf, /// What the UI calls the root folder (`--root-name`, else its file name). pub root_name: String, /// Whether we sit behind a TLS-terminating reverse proxy. pub https: bool, /// `--public-url` with any trailing slash removed; `None` when unset. pub public_url: Option, /// Thumbnail cache. `None` when `--cache` is unset, which turns /// thumbnails off everywhere. pub thumbs: Option>, } /// API error. `extra` is optionally merged into the JSON body (e.g. a list of /// conflicting file names on a 409). `code`, when present, is a stable /// machine-readable identifier the client maps to a localized message (the /// English text stays for logs and non-localized clients). Rendered by /// `IntoResponse` below; the error string itself is never displayed, so no /// `Display` impl is needed. pub struct ApiError( pub StatusCode, pub String, pub Option, pub Option<&'static str>, ); impl ApiError { pub fn new(status: StatusCode, msg: impl Into) -> Self { Self(status, msg.into(), None, None) } /// A user-facing error with a stable `code` for client-side /// localization (the code doubles as the i18n key). pub fn localized(status: StatusCode, msg: impl Into, code: &'static str) -> Self { Self(status, msg.into(), None, Some(code)) } pub fn with_extra(mut self, extra: serde_json::Value) -> Self { self.2 = Some(extra); self } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let mut body = serde_json::json!({ "error": self.1 }); if let Some(code) = self.3 { body["code"] = serde_json::json!(code); } if let Some(extra) = self.2 && let (Some(obj), Some(extra)) = (body.as_object_mut(), extra.as_object()) { for (k, v) in extra { obj.insert(k.clone(), v.clone()); } } (self.0, axum::Json(body)).into_response() } } impl From for ApiError { fn from(e: std::io::Error) -> Self { tracing::error!(error = %e, "io error"); ApiError::localized( StatusCode::INTERNAL_SERVER_ERROR, "internal error", "err_internal", ) } } impl From for ApiError { fn from(e: rusqlite::Error) -> Self { tracing::error!(error = %e, "database error"); ApiError::localized( StatusCode::INTERNAL_SERVER_ERROR, "internal error", "err_internal", ) } } #[cfg(test)] mod tests { use super::*; use axum::http::StatusCode; use http_body_util::BodyExt; fn status_and_body(e: ApiError) -> (StatusCode, String) { let resp = e.into_response(); let status = resp.status(); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let bytes = rt .block_on(async { resp.into_body().collect().await.unwrap().to_bytes() }) .to_vec(); (status, String::from_utf8(bytes).unwrap()) } #[test] fn plain_error_body() { let (status, body) = status_and_body(ApiError::new(StatusCode::NOT_FOUND, "folder not found")); assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!( serde_json::from_str::(&body).unwrap(), serde_json::json!({ "error": "folder not found" }) ); } #[test] fn extra_object_is_merged_into_body() { let e = ApiError::new(StatusCode::CONFLICT, "some files already exist") .with_extra(serde_json::json!({ "skipped": ["a.txt"], "uploaded": 2 })); let (status, body) = status_and_body(e); assert_eq!(status, StatusCode::CONFLICT); assert_eq!( serde_json::from_str::(&body).unwrap(), serde_json::json!({ "error": "some files already exist", "skipped": ["a.txt"], "uploaded": 2 }) ); } #[test] fn non_object_extra_is_ignored() { let e = ApiError::new(StatusCode::CONFLICT, "conflict") .with_extra(serde_json::json!(["not", "an", "object"])); let (_status, body) = status_and_body(e); assert_eq!( serde_json::from_str::(&body).unwrap(), serde_json::json!({ "error": "conflict" }) ); } #[test] fn io_error_maps_to_500() { let e = ApiError::from(std::io::Error::new( std::io::ErrorKind::NotFound, "disk vanished", )); let (status, body) = status_and_body(e); assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); assert!(body.contains("internal error")); } }