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