Skip to main content

tuitbot_server/
error.rs

1//! API error types for the tuitbot server.
2//!
3//! Maps core domain errors to HTTP status codes and JSON error responses.
4
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde_json::json;
8
9/// API error type for route handlers.
10pub enum ApiError {
11    /// Internal storage/database error.
12    Storage(tuitbot_core::error::StorageError),
13    /// Requested resource not found.
14    NotFound(String),
15    /// Bad request (invalid query parameters, etc.).
16    BadRequest(String),
17    /// Conflict (resource already exists, runtime already running, etc.).
18    Conflict(String),
19    /// Internal server error (non-storage).
20    Internal(String),
21    /// Forbidden — insufficient role/permissions.
22    Forbidden(String),
23}
24
25impl From<tuitbot_core::error::StorageError> for ApiError {
26    fn from(err: tuitbot_core::error::StorageError) -> Self {
27        match err {
28            tuitbot_core::error::StorageError::AlreadyReviewed { id, current_status } => {
29                Self::Conflict(format!(
30                    "item {id} has already been reviewed (current status: {current_status})"
31                ))
32            }
33            other => Self::Storage(other),
34        }
35    }
36}
37
38impl From<crate::account::AccountError> for ApiError {
39    fn from(err: crate::account::AccountError) -> Self {
40        match err.status {
41            StatusCode::FORBIDDEN => Self::Forbidden(err.message),
42            StatusCode::NOT_FOUND => Self::NotFound(err.message),
43            _ => Self::Internal(err.message),
44        }
45    }
46}
47
48impl IntoResponse for ApiError {
49    fn into_response(self) -> Response {
50        let (status, message) = match self {
51            Self::Storage(e) => {
52                tracing::error!("storage error: {e}");
53                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
54            }
55            Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
56            Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
57            Self::Conflict(msg) => (StatusCode::CONFLICT, msg),
58            Self::Internal(msg) => {
59                tracing::error!("internal error: {msg}");
60                (StatusCode::INTERNAL_SERVER_ERROR, msg)
61            }
62            Self::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
63        };
64
65        let body = axum::Json(json!({ "error": message }));
66        (status, body).into_response()
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use axum::response::IntoResponse;
74    use http_body_util::BodyExt;
75
76    /// Helper to extract status code and body JSON from an ApiError response.
77    async fn error_response(err: ApiError) -> (StatusCode, serde_json::Value) {
78        let resp = err.into_response();
79        let status = resp.status();
80        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
81        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
82        (status, json)
83    }
84
85    #[tokio::test]
86    async fn not_found_returns_404() {
87        let (status, body) = error_response(ApiError::NotFound("missing item".into())).await;
88        assert_eq!(status, StatusCode::NOT_FOUND);
89        assert_eq!(body["error"], "missing item");
90    }
91
92    #[tokio::test]
93    async fn bad_request_returns_400() {
94        let (status, body) = error_response(ApiError::BadRequest("invalid field".into())).await;
95        assert_eq!(status, StatusCode::BAD_REQUEST);
96        assert_eq!(body["error"], "invalid field");
97    }
98
99    #[tokio::test]
100    async fn conflict_returns_409() {
101        let (status, body) = error_response(ApiError::Conflict("already exists".into())).await;
102        assert_eq!(status, StatusCode::CONFLICT);
103        assert_eq!(body["error"], "already exists");
104    }
105
106    #[tokio::test]
107    async fn internal_returns_500() {
108        let (status, body) = error_response(ApiError::Internal("crash".into())).await;
109        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
110        assert_eq!(body["error"], "crash");
111    }
112
113    #[tokio::test]
114    async fn forbidden_returns_403() {
115        let (status, body) = error_response(ApiError::Forbidden("no access".into())).await;
116        assert_eq!(status, StatusCode::FORBIDDEN);
117        assert_eq!(body["error"], "no access");
118    }
119
120    #[tokio::test]
121    async fn storage_error_returns_500() {
122        let storage_err = tuitbot_core::error::StorageError::Query {
123            source: sqlx::Error::RowNotFound,
124        };
125        let (status, body) = error_response(ApiError::Storage(storage_err)).await;
126        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
127        assert!(body["error"].as_str().unwrap().len() > 0);
128    }
129
130    #[tokio::test]
131    async fn already_reviewed_storage_converts_to_conflict() {
132        let storage_err = tuitbot_core::error::StorageError::AlreadyReviewed {
133            id: 42,
134            current_status: "approved".into(),
135        };
136        let api_err: ApiError = storage_err.into();
137        let (status, body) = error_response(api_err).await;
138        assert_eq!(status, StatusCode::CONFLICT);
139        assert!(body["error"]
140            .as_str()
141            .unwrap()
142            .contains("already been reviewed"));
143    }
144
145    #[test]
146    fn account_error_forbidden_converts() {
147        let account_err = crate::account::AccountError {
148            status: StatusCode::FORBIDDEN,
149            message: "no perms".into(),
150        };
151        let api_err: ApiError = account_err.into();
152        match api_err {
153            ApiError::Forbidden(msg) => assert_eq!(msg, "no perms"),
154            _ => panic!("expected Forbidden"),
155        }
156    }
157
158    #[test]
159    fn account_error_not_found_converts() {
160        let account_err = crate::account::AccountError {
161            status: StatusCode::NOT_FOUND,
162            message: "gone".into(),
163        };
164        let api_err: ApiError = account_err.into();
165        match api_err {
166            ApiError::NotFound(msg) => assert_eq!(msg, "gone"),
167            _ => panic!("expected NotFound"),
168        }
169    }
170
171    #[test]
172    fn account_error_other_converts_to_internal() {
173        let account_err = crate::account::AccountError {
174            status: StatusCode::INTERNAL_SERVER_ERROR,
175            message: "db down".into(),
176        };
177        let api_err: ApiError = account_err.into();
178        match api_err {
179            ApiError::Internal(msg) => assert_eq!(msg, "db down"),
180            _ => panic!("expected Internal"),
181        }
182    }
183}