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