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