1use axum::{
27 Json,
28 http::StatusCode,
29 response::{IntoResponse, Response},
30};
31use serde::Serialize;
32
33use logdive_core::{LogdiveError, QueryParseError};
34
35#[derive(Debug)]
37pub enum AppError {
38 BadRequest(String),
40
41 #[allow(dead_code)]
47 NotFound(String),
48
49 Internal(LogdiveError),
53}
54
55impl AppError {
56 pub fn bad_request<M: std::fmt::Display>(msg: M) -> Self {
58 Self::BadRequest(msg.to_string())
59 }
60}
61
62impl From<LogdiveError> for AppError {
69 fn from(err: LogdiveError) -> Self {
70 match &err {
71 LogdiveError::QueryParse(_)
72 | LogdiveError::InvalidDatetime { .. }
73 | LogdiveError::UnsafeFieldName(_) => AppError::BadRequest(err.to_string()),
74 _ => AppError::Internal(err),
75 }
76 }
77}
78
79impl From<QueryParseError> for AppError {
88 fn from(err: QueryParseError) -> Self {
89 AppError::from(LogdiveError::from(err))
90 }
91}
92
93#[derive(Debug, Serialize)]
97struct ErrorBody<'a> {
98 error: &'a str,
99}
100
101impl IntoResponse for AppError {
102 fn into_response(self) -> Response {
103 let (status, message) = match self {
104 AppError::BadRequest(msg) => {
105 tracing::debug!(%msg, "400 bad request");
106 (StatusCode::BAD_REQUEST, msg)
107 }
108 AppError::NotFound(msg) => {
109 tracing::debug!(%msg, "404 not found");
110 (StatusCode::NOT_FOUND, msg)
111 }
112 AppError::Internal(err) => {
113 tracing::warn!(error = %err, "500 internal server error");
117 (
118 StatusCode::INTERNAL_SERVER_ERROR,
119 "internal server error".to_string(),
120 )
121 }
122 };
123
124 (status, Json(ErrorBody { error: &message })).into_response()
125 }
126}
127
128#[cfg(test)]
133mod tests {
134 use super::*;
135 use http_body_util::BodyExt;
136 use logdive_core::parse_query;
137 use serde_json::Value;
138
139 async fn read_body(resp: Response) -> (StatusCode, String) {
141 let status = resp.status();
142 let body = resp
143 .into_body()
144 .collect()
145 .await
146 .expect("collect body")
147 .to_bytes();
148 let text = String::from_utf8(body.to_vec()).expect("utf-8 body");
149 (status, text)
150 }
151
152 fn parse_error_body(text: &str) -> String {
153 let v: Value = serde_json::from_str(text).expect("response body is JSON");
154 v.get("error")
155 .and_then(|e| e.as_str())
156 .expect("body has `error` string field")
157 .to_string()
158 }
159
160 #[tokio::test]
161 async fn bad_request_renders_400_with_user_message() {
162 let err = AppError::BadRequest("missing `q` parameter".to_string());
163 let (status, text) = read_body(err.into_response()).await;
164 assert_eq!(status, StatusCode::BAD_REQUEST);
165 assert_eq!(parse_error_body(&text), "missing `q` parameter");
166 }
167
168 #[tokio::test]
169 async fn not_found_renders_404_with_user_message() {
170 let err = AppError::NotFound("no such entry".to_string());
171 let (status, text) = read_body(err.into_response()).await;
172 assert_eq!(status, StatusCode::NOT_FOUND);
173 assert_eq!(parse_error_body(&text), "no such entry");
174 }
175
176 #[tokio::test]
177 async fn internal_renders_500_with_generic_message() {
178 let dir = tempfile::tempdir().unwrap();
182 let missing = dir.path().join("missing.db");
183 let inner =
184 logdive_core::Indexer::open_read_only(&missing).expect_err("should fail on missing db");
185
186 let err = AppError::Internal(inner);
187 let (status, text) = read_body(err.into_response()).await;
188 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
189
190 assert_eq!(parse_error_body(&text), "internal server error");
192 }
193
194 #[tokio::test]
195 async fn from_logdive_error_maps_query_parse_to_bad_request() {
196 let query_err = parse_query("level =").expect_err("should not parse");
198 let app_err: AppError = LogdiveError::from(query_err).into();
199 let (status, text) = read_body(app_err.into_response()).await;
200
201 assert_eq!(status, StatusCode::BAD_REQUEST);
202 assert_ne!(parse_error_body(&text), "internal server error");
204 assert!(!parse_error_body(&text).is_empty());
205 }
206
207 #[tokio::test]
208 async fn from_query_parse_error_directly_maps_to_bad_request() {
209 let query_err = parse_query("level =").expect_err("should not parse");
213 let app_err: AppError = query_err.into();
214 let (status, text) = read_body(app_err.into_response()).await;
215
216 assert_eq!(status, StatusCode::BAD_REQUEST);
217 assert_ne!(parse_error_body(&text), "internal server error");
218 }
219
220 #[tokio::test]
221 async fn from_logdive_error_maps_invalid_datetime_to_bad_request() {
222 let err = LogdiveError::InvalidDatetime {
223 input: "not-a-date".to_string(),
224 reason: "bad format".to_string(),
225 };
226 let app_err: AppError = err.into();
227 let (status, text) = read_body(app_err.into_response()).await;
228
229 assert_eq!(status, StatusCode::BAD_REQUEST);
230 assert!(
231 parse_error_body(&text)
232 .to_lowercase()
233 .contains("not-a-date")
234 );
235 }
236
237 #[tokio::test]
238 async fn from_logdive_error_maps_unsafe_field_name_to_bad_request() {
239 let err = LogdiveError::UnsafeFieldName("service; DROP TABLE--".to_string());
240 let app_err: AppError = err.into();
241 let (status, _) = read_body(app_err.into_response()).await;
242 assert_eq!(status, StatusCode::BAD_REQUEST);
243 }
244
245 #[tokio::test]
246 async fn from_logdive_error_maps_other_to_internal() {
247 let dir = tempfile::tempdir().unwrap();
250 let missing = dir.path().join("also-missing.db");
251 let inner =
252 logdive_core::Indexer::open_read_only(&missing).expect_err("should fail on missing db");
253
254 let app_err: AppError = inner.into();
255 let (status, text) = read_body(app_err.into_response()).await;
256 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
257 assert_eq!(parse_error_body(&text), "internal server error");
258 }
259
260 #[tokio::test]
261 async fn internal_error_body_never_contains_db_path() {
262 use std::path::PathBuf;
263 let inner = logdive_core::LogdiveError::io_at(
267 PathBuf::from("/sensitive/path/to/index.db"),
268 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
269 );
270 let err = AppError::Internal(inner);
271 let (status, text) = read_body(err.into_response()).await;
272 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
273 assert!(
274 !text.contains("/sensitive/path"),
275 "filesystem path must not appear in HTTP response body",
276 );
277 }
278
279 #[tokio::test]
280 async fn internal_error_body_never_contains_sqlite_error_text() {
281 let dir = tempfile::tempdir().unwrap();
285 let missing = dir.path().join("missing2.db");
286 let inner =
287 logdive_core::Indexer::open_read_only(&missing).expect_err("should fail on missing db");
288
289 let err = AppError::Internal(inner);
290 let (_, text) = read_body(err.into_response()).await;
291 assert!(
292 !text.contains("unable to open"),
293 "SQLite error text must not appear in HTTP body",
294 );
295 assert!(
296 !text.contains("database file"),
297 "SQLite error text must not appear in HTTP body",
298 );
299 }
300
301 #[test]
302 fn bad_request_constructor_accepts_anything_displayable() {
303 let _a: AppError = AppError::bad_request("literal");
306 let _b: AppError = AppError::bad_request(String::from("owned"));
307 let _c: AppError = AppError::bad_request(format_args!("formatted {}", 1));
308 }
309}