Skip to main content

logdive_api/
error.rs

1//! HTTP-facing error type for the `logdive-api` server.
2//!
3//! Classifies errors into three HTTP-shaped buckets:
4//!   - [`AppError::BadRequest`] — the client sent something malformed
5//!     (missing `q`, unparseable query expression, bad datetime).
6//!   - [`AppError::NotFound`] — an explicit miss by an endpoint, reserved
7//!     for future endpoints that look up specific records. Not used for
8//!     route-level misses; Axum handles unknown-route 404s on its own.
9//!   - [`AppError::Internal`] — any failure below the request boundary
10//!     (SQLite error, corrupt JSON in the index, I/O failure). These
11//!     are logged in full to tracing but shown to the client only as a
12//!     generic `"internal server error"` message.
13//!
14//! The status-code mapping lives in exactly one place: the `From<LogdiveError>`
15//! impl. Handlers can therefore use `?` on any `LogdiveError`-returning
16//! operation and get correct classification for free.
17//!
18//! For source types that are *not* `LogdiveError` but whose `LogdiveError`
19//! conversion is already defined in core (e.g. [`QueryParseError`] via its
20//! `#[from]` variant on `LogdiveError::QueryParse`), this module provides
21//! an explicit shim `From` impl. Rust's `?` operator only performs a single
22//! `From` conversion, so `Result<T, QueryParseError> -> Result<T, AppError>`
23//! needs its own direct impl rather than going through `LogdiveError`
24//! implicitly.
25
26use axum::{
27    Json,
28    http::StatusCode,
29    response::{IntoResponse, Response},
30};
31use serde::Serialize;
32
33use logdive_core::{LogdiveError, QueryParseError};
34
35/// HTTP error surface used across all handlers.
36#[derive(Debug)]
37pub enum AppError {
38    /// Client sent a malformed request. Message is user-facing.
39    BadRequest(String),
40
41    /// A specific resource was not present. Message is user-facing.
42    ///
43    /// Reserved for future endpoints that look up by id. The current
44    /// `GET /query` and `GET /stats` never emit this; returning zero
45    /// matches from a query is a `200 OK` with an empty body, not a 404.
46    #[allow(dead_code)]
47    NotFound(String),
48
49    /// Unexpected internal failure. The underlying `LogdiveError` is
50    /// kept for operator-side logging and is never exposed to the
51    /// client.
52    Internal(LogdiveError),
53}
54
55impl AppError {
56    /// Convenience constructor for 400 responses with a `Display` source.
57    pub fn bad_request<M: std::fmt::Display>(msg: M) -> Self {
58        Self::BadRequest(msg.to_string())
59    }
60}
61
62/// Map `LogdiveError` variants to appropriate HTTP error classes.
63///
64/// This is the single source of truth for classification — handlers simply
65/// use `?` and rely on this impl to do the right thing. Anything that
66/// looks like "the user sent something bad" becomes `BadRequest`;
67/// anything else becomes `Internal`.
68impl 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
79/// Explicit bridge from `QueryParseError` to `AppError`.
80///
81/// `parse_query` in core returns `Result<_, QueryParseError>` directly,
82/// not wrapped in `LogdiveError`. Rust's `?` only performs a single
83/// conversion via `From`, so even though `LogdiveError: From<QueryParseError>`
84/// is defined in core, callers using `?` on `parse_query(...)?` from an
85/// `AppError`-returning function need a direct impl. We delegate to the
86/// `LogdiveError` path so classification stays in one place.
87impl From<QueryParseError> for AppError {
88    fn from(err: QueryParseError) -> Self {
89        AppError::from(LogdiveError::from(err))
90    }
91}
92
93/// JSON body shape returned for every error response.
94///
95/// Private to this module — handlers never construct one directly.
96#[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                // Log the full underlying error for operators, but return
114                // a sanitized message to the client. Users should never
115                // see a SQLite error string or a filesystem path.
116                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// ---------------------------------------------------------------------------
129// Tests
130// ---------------------------------------------------------------------------
131
132#[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    /// Collect the response body into a UTF-8 string for assertion.
140    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        // Construct a real Sqlite error by trying to open a non-existent
179        // read-only database — this gives us a genuine `LogdiveError::Sqlite`
180        // without having to build rusqlite internals by hand.
181        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        // Client sees the sanitized message, never the raw sqlite error.
191        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        // Parse a clearly malformed query to get a real QueryParse error.
197        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        // 400s surface the real message to the client.
203        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        // Exercise the direct `From<QueryParseError> for AppError` bridge
210        // — this is the shim that makes `parse_query(...)?` work inside
211        // AppError-returning handlers.
212        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        // Sqlite error from a missing read-only DB — this path classifies
248        // as internal because it's not a user-fault variant.
249        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        // An Io error carrying a sensitive path must be swallowed by
264        // AppError::Internal — the filesystem path must never reach the
265        // HTTP client.
266        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        // SQLite's error for a missing read-only file is "unable to open
282        // database file". That string must be swallowed and never forwarded
283        // to the HTTP client.
284        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        // Compile-time check that the helper works with &str, String, and
304        // formatted strings uniformly.
305        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}