Skip to main content

meritocrab_api/
error.rs

1use axum::{
2    Json,
3    http::StatusCode,
4    response::{IntoResponse, Response},
5};
6use meritocrab_core::CoreError;
7use meritocrab_db::DbError;
8use meritocrab_github::GithubError;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12/// API error type
13#[derive(Debug)]
14pub enum ApiError {
15    /// Database error
16    Database(DbError),
17
18    /// GitHub API error
19    Github(GithubError),
20
21    /// Core logic error
22    Core(CoreError),
23
24    /// Invalid request payload
25    InvalidPayload(String),
26
27    /// Invalid webhook signature (HMAC verification failed)
28    InvalidSignature(String),
29
30    /// Internal server error
31    Internal(String),
32
33    /// Unauthorized (401)
34    Unauthorized(String),
35
36    /// Not found (404)
37    NotFound(String),
38
39    /// Bad request (400)
40    BadRequest(String),
41
42    /// Forbidden (403)
43    Forbidden(String),
44
45    /// Internal error (alias for backward compatibility)
46    InternalError(String),
47}
48
49impl fmt::Display for ApiError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            ApiError::Database(e) => write!(f, "Database error: {}", e),
53            ApiError::Github(e) => write!(f, "GitHub error: {}", e),
54            ApiError::Core(e) => write!(f, "Core error: {}", e),
55            ApiError::InvalidPayload(msg) => write!(f, "Invalid payload: {}", msg),
56            ApiError::InvalidSignature(msg) => write!(f, "Invalid signature: {}", msg),
57            ApiError::Internal(msg) => write!(f, "Internal error: {}", msg),
58            ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
59            ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
60            ApiError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
61            ApiError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
62            ApiError::InternalError(msg) => write!(f, "Internal error: {}", msg),
63        }
64    }
65}
66
67impl std::error::Error for ApiError {}
68
69/// Error response JSON structure
70#[derive(Debug, Serialize, Deserialize)]
71pub struct ErrorResponse {
72    pub error: String,
73    pub message: String,
74}
75
76impl IntoResponse for ApiError {
77    fn into_response(self) -> Response {
78        let (status, error_type, message) = match &self {
79            ApiError::Database(e) => (
80                StatusCode::INTERNAL_SERVER_ERROR,
81                "database_error",
82                e.to_string(),
83            ),
84            ApiError::Github(e) => (
85                StatusCode::INTERNAL_SERVER_ERROR,
86                "github_error",
87                e.to_string(),
88            ),
89            ApiError::Core(e) => (
90                StatusCode::INTERNAL_SERVER_ERROR,
91                "core_error",
92                e.to_string(),
93            ),
94            ApiError::InvalidPayload(msg) => {
95                (StatusCode::BAD_REQUEST, "invalid_payload", msg.clone())
96            }
97            ApiError::InvalidSignature(msg) => {
98                (StatusCode::UNAUTHORIZED, "invalid_signature", msg.clone())
99            }
100            ApiError::Internal(msg) | ApiError::InternalError(msg) => (
101                StatusCode::INTERNAL_SERVER_ERROR,
102                "internal_error",
103                msg.clone(),
104            ),
105            ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "unauthorized", msg.clone()),
106            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
107            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
108            ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
109        };
110
111        let error_response = ErrorResponse {
112            error: error_type.to_string(),
113            message,
114        };
115
116        (status, Json(error_response)).into_response()
117    }
118}
119
120// Conversions from domain errors to ApiError
121impl From<DbError> for ApiError {
122    fn from(e: DbError) -> Self {
123        ApiError::Database(e)
124    }
125}
126
127impl From<GithubError> for ApiError {
128    fn from(e: GithubError) -> Self {
129        ApiError::Github(e)
130    }
131}
132
133impl From<CoreError> for ApiError {
134    fn from(e: CoreError) -> Self {
135        ApiError::Core(e)
136    }
137}
138
139impl From<serde_json::Error> for ApiError {
140    fn from(e: serde_json::Error) -> Self {
141        ApiError::InvalidPayload(format!("JSON parsing error: {}", e))
142    }
143}
144
145pub type ApiResult<T> = Result<T, ApiError>;
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_error_display() {
153        let err = ApiError::InvalidPayload("test error".to_string());
154        assert_eq!(err.to_string(), "Invalid payload: test error");
155    }
156
157    #[test]
158    fn test_error_response_invalid_payload() {
159        let err = ApiError::InvalidPayload("bad json".to_string());
160        let response = err.into_response();
161        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
162    }
163
164    #[test]
165    fn test_error_response_invalid_signature() {
166        let err = ApiError::InvalidSignature("signature mismatch".to_string());
167        let response = err.into_response();
168        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
169    }
170
171    #[test]
172    fn test_error_response_internal() {
173        let err = ApiError::Internal("something went wrong".to_string());
174        let response = err.into_response();
175        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
176    }
177
178    #[test]
179    fn test_from_serde_json_error() {
180        let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
181        let api_err: ApiError = json_err.into();
182        match api_err {
183            ApiError::InvalidPayload(msg) => assert!(msg.contains("JSON parsing error")),
184            _ => panic!("Expected InvalidPayload error"),
185        }
186    }
187}