Skip to main content

convergio_types/
api_error.rs

1//! Standardized API error type with proper HTTP status mapping.
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use serde::Serialize;
6
7/// Unified error type for all daemon HTTP endpoints.
8#[derive(Debug, Clone, Serialize)]
9#[serde(tag = "error_kind")]
10pub enum ApiError {
11    /// 400 — malformed request, missing fields, invalid input.
12    BadRequest { message: String },
13    /// 404 — resource not found.
14    NotFound { message: String },
15    /// 401 — missing or invalid credentials.
16    Unauthorized,
17    /// 500 — unexpected internal failure.
18    InternalError { message: String },
19    /// 422 — a gate (evidence, test, PR, Thor) blocked the operation.
20    GateBlocked { gate: String, reason: String },
21}
22
23impl ApiError {
24    pub fn bad_request(msg: impl Into<String>) -> Self {
25        Self::BadRequest {
26            message: msg.into(),
27        }
28    }
29
30    pub fn not_found(msg: impl Into<String>) -> Self {
31        Self::NotFound {
32            message: msg.into(),
33        }
34    }
35
36    pub fn internal(msg: impl Into<String>) -> Self {
37        Self::InternalError {
38            message: msg.into(),
39        }
40    }
41
42    pub fn gate_blocked(gate: impl Into<String>, reason: impl Into<String>) -> Self {
43        Self::GateBlocked {
44            gate: gate.into(),
45            reason: reason.into(),
46        }
47    }
48
49    /// HTTP status code for this error variant.
50    pub fn status_code(&self) -> StatusCode {
51        match self {
52            Self::BadRequest { .. } => StatusCode::BAD_REQUEST,
53            Self::NotFound { .. } => StatusCode::NOT_FOUND,
54            Self::Unauthorized => StatusCode::UNAUTHORIZED,
55            Self::InternalError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
56            Self::GateBlocked { .. } => StatusCode::UNPROCESSABLE_ENTITY,
57        }
58    }
59}
60
61impl std::fmt::Display for ApiError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::BadRequest { message } => write!(f, "bad request: {message}"),
65            Self::NotFound { message } => write!(f, "not found: {message}"),
66            Self::Unauthorized => write!(f, "unauthorized"),
67            Self::InternalError { message } => write!(f, "internal error: {message}"),
68            Self::GateBlocked { gate, reason } => {
69                write!(f, "gate blocked: {gate} — {reason}")
70            }
71        }
72    }
73}
74
75impl IntoResponse for ApiError {
76    fn into_response(self) -> Response {
77        let status = self.status_code();
78        let body = serde_json::json!({
79            "error": self.to_string(),
80            "status": status.as_u16(),
81        });
82        (status, axum::Json(body)).into_response()
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn bad_request_status() {
92        let err = ApiError::bad_request("missing field");
93        assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
94    }
95
96    #[test]
97    fn not_found_status() {
98        let err = ApiError::not_found("plan 99");
99        assert_eq!(err.status_code(), StatusCode::NOT_FOUND);
100    }
101
102    #[test]
103    fn unauthorized_status() {
104        assert_eq!(
105            ApiError::Unauthorized.status_code(),
106            StatusCode::UNAUTHORIZED
107        );
108    }
109
110    #[test]
111    fn internal_error_status() {
112        let err = ApiError::internal("db crash");
113        assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
114    }
115
116    #[test]
117    fn gate_blocked_status() {
118        let err = ApiError::gate_blocked("EvidenceGate", "no test_pass");
119        assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
120    }
121
122    #[test]
123    fn display_formats_correctly() {
124        let err = ApiError::gate_blocked("Thor", "not validated");
125        let s = err.to_string();
126        assert!(s.contains("Thor"));
127        assert!(s.contains("not validated"));
128    }
129
130    #[test]
131    fn serializes_to_json() {
132        let err = ApiError::bad_request("oops");
133        let json = serde_json::to_value(&err).expect("serialize");
134        assert_eq!(json["error_kind"], "BadRequest");
135        assert_eq!(json["message"], "oops");
136    }
137}