Skip to main content

heliosdb_nano/api/models/
error.rs

1//! API error types and HTTP status code mapping
2
3use axum::{
4    http::StatusCode,
5    response::{IntoResponse, Response},
6    Json,
7};
8use serde::{Deserialize, Serialize};
9
10use crate::Error;
11
12/// API error response
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ApiError {
15    /// HTTP status code
16    #[serde(skip)]
17    pub status: StatusCode,
18
19    /// Error type/category
20    pub error: String,
21
22    /// Human-readable error message
23    pub message: String,
24
25    /// Optional details for debugging
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub details: Option<String>,
28}
29
30impl ApiError {
31    /// Create a new API error
32    pub fn new(status: StatusCode, error: impl Into<String>, message: impl Into<String>) -> Self {
33        Self {
34            status,
35            error: error.into(),
36            message: message.into(),
37            details: None,
38        }
39    }
40
41    /// Create an API error with details
42    pub fn with_details(
43        status: StatusCode,
44        error: impl Into<String>,
45        message: impl Into<String>,
46        details: impl Into<String>,
47    ) -> Self {
48        Self {
49            status,
50            error: error.into(),
51            message: message.into(),
52            details: Some(details.into()),
53        }
54    }
55
56    /// Create a bad request error (400)
57    pub fn bad_request(message: impl Into<String>) -> Self {
58        Self::new(StatusCode::BAD_REQUEST, "BadRequest", message)
59    }
60
61    /// Create a not found error (404)
62    pub fn not_found(message: impl Into<String>) -> Self {
63        Self::new(StatusCode::NOT_FOUND, "NotFound", message)
64    }
65
66    /// Create a conflict error (409)
67    pub fn conflict(message: impl Into<String>) -> Self {
68        Self::new(StatusCode::CONFLICT, "Conflict", message)
69    }
70
71    /// Create an internal server error (500)
72    pub fn internal_server_error(message: impl Into<String>) -> Self {
73        Self::new(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", message)
74    }
75
76    /// Create an internal server error (500) - shorthand alias
77    pub fn internal(message: impl Into<String>) -> Self {
78        Self::internal_server_error(message)
79    }
80
81    /// Create an unprocessable entity error (422)
82    pub fn unprocessable_entity(message: impl Into<String>) -> Self {
83        Self::new(StatusCode::UNPROCESSABLE_ENTITY, "UnprocessableEntity", message)
84    }
85
86    /// Create an unauthorized error (401)
87    pub fn unauthorized(message: impl Into<String>) -> Self {
88        Self::new(StatusCode::UNAUTHORIZED, "Unauthorized", message)
89    }
90}
91
92// Convert our domain Error to ApiError
93impl From<Error> for ApiError {
94    fn from(err: Error) -> Self {
95        match err {
96            Error::Storage(msg) => {
97                if msg.contains("not found") || msg.contains("does not exist") {
98                    ApiError::not_found(msg)
99                } else if msg.contains("already exists") {
100                    ApiError::conflict(msg)
101                } else {
102                    ApiError::internal_server_error(msg)
103                }
104            }
105            Error::SqlParse(msg) => ApiError::bad_request(msg),
106            Error::QueryExecution(msg) => ApiError::unprocessable_entity(msg),
107            Error::QueryTimeout(msg) => {
108                ApiError::new(StatusCode::REQUEST_TIMEOUT, "QueryTimeout", msg)
109            }
110            Error::QueryCancelled(msg) => {
111                ApiError::new(StatusCode::from_u16(499).unwrap_or(StatusCode::BAD_REQUEST), "QueryCancelled", msg)
112            }
113            Error::Transaction(msg) => ApiError::unprocessable_entity(msg),
114            Error::TypeConversion(msg) => ApiError::bad_request(msg),
115            Error::Config(msg) => ApiError::bad_request(msg),
116            Error::Protocol(msg) => ApiError::bad_request(msg),
117            Error::BranchMerge(msg) => ApiError::unprocessable_entity(msg),
118            Error::MergeConflict(msg) => ApiError::conflict(msg),
119            Error::ConstraintViolation(msg) => ApiError::conflict(msg),
120            Error::Encryption(_) | Error::VectorIndex(_) | Error::MultiTenant(_)
121            | Error::Audit(_) | Error::Compression(_) | Error::LockPoisoned(_)
122            | Error::Generic(_) => {
123                ApiError::internal_server_error(format!("{}", err))
124            }
125            Error::Io(e) => ApiError::internal_server_error(format!("I/O error: {}", e)),
126        }
127    }
128}
129
130// Implement IntoResponse so ApiError can be returned from handlers
131impl IntoResponse for ApiError {
132    fn into_response(self) -> Response {
133        let status = self.status;
134        let body = Json(self);
135        (status, body).into_response()
136    }
137}
138
139#[cfg(test)]
140#[allow(clippy::unwrap_used, clippy::expect_used)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_api_error_creation() {
146        let err = ApiError::bad_request("Invalid input");
147        assert_eq!(err.status, StatusCode::BAD_REQUEST);
148        assert_eq!(err.error, "BadRequest");
149        assert_eq!(err.message, "Invalid input");
150        assert!(err.details.is_none());
151    }
152
153    #[test]
154    fn test_api_error_with_details() {
155        let err = ApiError::with_details(
156            StatusCode::BAD_REQUEST,
157            "ValidationError",
158            "Invalid branch name",
159            "Branch name must be alphanumeric",
160        );
161        assert_eq!(err.status, StatusCode::BAD_REQUEST);
162        assert_eq!(err.error, "ValidationError");
163        assert_eq!(err.message, "Invalid branch name");
164        assert_eq!(err.details, Some("Branch name must be alphanumeric".to_string()));
165    }
166
167    #[test]
168    fn test_error_conversion_storage_not_found() {
169        let domain_err = Error::storage("Branch 'dev' not found");
170        let api_err: ApiError = domain_err.into();
171        assert_eq!(api_err.status, StatusCode::NOT_FOUND);
172    }
173
174    #[test]
175    fn test_error_conversion_storage_exists() {
176        let domain_err = Error::storage("Branch already exists");
177        let api_err: ApiError = domain_err.into();
178        assert_eq!(api_err.status, StatusCode::CONFLICT);
179    }
180
181    #[test]
182    fn test_error_conversion_sql_parse() {
183        let domain_err = Error::sql_parse("Invalid SQL syntax");
184        let api_err: ApiError = domain_err.into();
185        assert_eq!(api_err.status, StatusCode::BAD_REQUEST);
186    }
187
188    #[test]
189    fn test_error_conversion_merge_conflict() {
190        let domain_err = Error::merge_conflict("Conflicts detected");
191        let api_err: ApiError = domain_err.into();
192        assert_eq!(api_err.status, StatusCode::CONFLICT);
193    }
194
195    #[test]
196    fn test_error_serialization() {
197        let err = ApiError::bad_request("Test error");
198        let json = serde_json::to_string(&err).unwrap();
199        let deserialized: ApiError = serde_json::from_str(&json).unwrap();
200        assert_eq!(deserialized.error, "BadRequest");
201        assert_eq!(deserialized.message, "Test error");
202    }
203}