error.rs
⎇
Raw
1use std::path::PathBuf;
2use std::sync::Arc;
3
4use axum::http::StatusCode;
5use axum::response::{IntoResponse, Response};
6
7use crate::db::Db;
8
9#[derive(Clone)]
10pub struct AppState {
11 pub db: Db,
12 /// Canonicalized server root folder.
13 pub root: PathBuf,
14 /// What the UI calls the root folder (`--root-name`, else its file name).
15 pub root_name: String,
16 /// Whether we sit behind a TLS-terminating reverse proxy.
17 pub https: bool,
18 /// `--public-url` with any trailing slash removed; `None` when unset.
19 pub public_url: Option<String>,
20 /// Thumbnail cache. `None` when `--cache` is unset, which turns
21 /// thumbnails off everywhere.
22 pub thumbs: Option<Arc<crate::thumb::Thumbs>>,
23}
24
25/// API error. `extra` is optionally merged into the JSON body (e.g. a list of
26/// conflicting file names on a 409). `code`, when present, is a stable
27/// machine-readable identifier the client maps to a localized message (the
28/// English text stays for logs and non-localized clients). Rendered by
29/// `IntoResponse` below; the error string itself is never displayed, so no
30/// `Display` impl is needed.
31pub struct ApiError(
32 pub StatusCode,
33 pub String,
34 pub Option<serde_json::Value>,
35 pub Option<&'static str>,
36);
37
38impl ApiError {
39 pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
40 Self(status, msg.into(), None, None)
41 }
42
43 /// A user-facing error with a stable `code` for client-side
44 /// localization (the code doubles as the i18n key).
45 pub fn localized(status: StatusCode, msg: impl Into<String>, code: &'static str) -> Self {
46 Self(status, msg.into(), None, Some(code))
47 }
48
49 pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
50 self.2 = Some(extra);
51 self
52 }
53}
54
55impl IntoResponse for ApiError {
56 fn into_response(self) -> Response {
57 let mut body = serde_json::json!({ "error": self.1 });
58 if let Some(code) = self.3 {
59 body["code"] = serde_json::json!(code);
60 }
61 if let Some(extra) = self.2
62 && let (Some(obj), Some(extra)) = (body.as_object_mut(), extra.as_object())
63 {
64 for (k, v) in extra {
65 obj.insert(k.clone(), v.clone());
66 }
67 }
68 (self.0, axum::Json(body)).into_response()
69 }
70}
71
72impl From<std::io::Error> for ApiError {
73 fn from(e: std::io::Error) -> Self {
74 tracing::error!(error = %e, "io error");
75 ApiError::localized(
76 StatusCode::INTERNAL_SERVER_ERROR,
77 "internal error",
78 "err_internal",
79 )
80 }
81}
82
83impl From<rusqlite::Error> for ApiError {
84 fn from(e: rusqlite::Error) -> Self {
85 tracing::error!(error = %e, "database error");
86 ApiError::localized(
87 StatusCode::INTERNAL_SERVER_ERROR,
88 "internal error",
89 "err_internal",
90 )
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use axum::http::StatusCode;
98 use http_body_util::BodyExt;
99
100 fn status_and_body(e: ApiError) -> (StatusCode, String) {
101 let resp = e.into_response();
102 let status = resp.status();
103 let rt = tokio::runtime::Builder::new_current_thread()
104 .enable_all()
105 .build()
106 .unwrap();
107 let bytes = rt
108 .block_on(async { resp.into_body().collect().await.unwrap().to_bytes() })
109 .to_vec();
110 (status, String::from_utf8(bytes).unwrap())
111 }
112
113 #[test]
114 fn plain_error_body() {
115 let (status, body) =
116 status_and_body(ApiError::new(StatusCode::NOT_FOUND, "folder not found"));
117 assert_eq!(status, StatusCode::NOT_FOUND);
118 assert_eq!(
119 serde_json::from_str::<serde_json::Value>(&body).unwrap(),
120 serde_json::json!({ "error": "folder not found" })
121 );
122 }
123
124 #[test]
125 fn extra_object_is_merged_into_body() {
126 let e = ApiError::new(StatusCode::CONFLICT, "some files already exist")
127 .with_extra(serde_json::json!({ "skipped": ["a.txt"], "uploaded": 2 }));
128 let (status, body) = status_and_body(e);
129 assert_eq!(status, StatusCode::CONFLICT);
130 assert_eq!(
131 serde_json::from_str::<serde_json::Value>(&body).unwrap(),
132 serde_json::json!({
133 "error": "some files already exist",
134 "skipped": ["a.txt"],
135 "uploaded": 2
136 })
137 );
138 }
139
140 #[test]
141 fn non_object_extra_is_ignored() {
142 let e = ApiError::new(StatusCode::CONFLICT, "conflict")
143 .with_extra(serde_json::json!(["not", "an", "object"]));
144 let (_status, body) = status_and_body(e);
145 assert_eq!(
146 serde_json::from_str::<serde_json::Value>(&body).unwrap(),
147 serde_json::json!({ "error": "conflict" })
148 );
149 }
150
151 #[test]
152 fn io_error_maps_to_500() {
153 let e = ApiError::from(std::io::Error::new(
154 std::io::ErrorKind::NotFound,
155 "disk vanished",
156 ));
157 let (status, body) = status_and_body(e);
158 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
159 assert!(body.contains("internal error"));
160 }
161}
162