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::Map<String, 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::Map<String, 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::Map::new();
78 body.insert("error".into(), self.1.into());
79 if let Some(code) = self.3 {
80 body.insert("code".into(), code.into());
81 }
82 body.extend(self.2.unwrap_or_default());
83 (self.0, axum::Json(body)).into_response()
84 }
85}
86
87impl From<std::io::Error> for ApiError {
88 fn from(e: std::io::Error) -> Self {
89 tracing::error!(error = %e, "io error");
90 ApiError::internal()
91 }
92}
93
94impl From<rusqlite::Error> for ApiError {
95 fn from(e: rusqlite::Error) -> Self {
96 tracing::error!(error = %e, "database error");
97 ApiError::internal()
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use axum::http::StatusCode;
105
106 fn status_and_body(e: ApiError) -> (StatusCode, String) {
107 let resp = e.into_response();
108 let status = resp.status();
109 let rt = tokio::runtime::Builder::new_current_thread()
110 .enable_all()
111 .build()
112 .unwrap();
113 let bytes = rt
114 .block_on(axum::body::to_bytes(resp.into_body(), usize::MAX))
115 .unwrap()
116 .to_vec();
117 (status, String::from_utf8(bytes).unwrap())
118 }
119
120 #[test]
121 fn plain_error_body() {
122 let (status, body) =
123 status_and_body(ApiError::new(StatusCode::NOT_FOUND, "folder not found"));
124 assert_eq!(status, StatusCode::NOT_FOUND);
125 assert_eq!(
126 serde_json::from_str::<serde_json::Value>(&body).unwrap(),
127 serde_json::json!({ "error": "folder not found" })
128 );
129 }
130
131 #[test]
132 fn extra_object_is_merged_into_body() {
133 let e = ApiError::new(StatusCode::CONFLICT, "some files already exist").with_extra(
134 serde_json::Map::from_iter([
135 ("skipped".into(), serde_json::json!(["a.txt"])),
136 ("uploaded".into(), serde_json::json!(2)),
137 ]),
138 );
139 let (status, body) = status_and_body(e);
140 assert_eq!(status, StatusCode::CONFLICT);
141 assert_eq!(
142 serde_json::from_str::<serde_json::Value>(&body).unwrap(),
143 serde_json::json!({
144 "error": "some files already exist",
145 "skipped": ["a.txt"],
146 "uploaded": 2
147 })
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