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