Skip to main content

shaperail_core/
error.rs

1use actix_web::http::StatusCode;
2use actix_web::HttpResponse;
3use serde::{Deserialize, Serialize};
4
5/// A single field-level validation error.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct FieldError {
8    /// The field name that failed validation.
9    pub field: String,
10    /// Human-readable error message.
11    pub message: String,
12    /// Machine-readable error code (e.g., "required", "too_short").
13    pub code: String,
14}
15
16/// Unified error type used across all Shaperail crates.
17///
18/// Maps to the PRD error response format:
19/// ```json
20/// {
21///   "error": {
22///     "code": "NOT_FOUND",
23///     "status": 404,
24///     "message": "Resource not found",
25///     "request_id": "abc-123",
26///     "details": null
27///   }
28/// }
29/// ```
30#[derive(Debug, thiserror::Error)]
31pub enum ShaperailError {
32    /// 404 — Resource not found.
33    #[error("Resource not found")]
34    NotFound,
35
36    /// 401 — Missing or invalid authentication.
37    #[error("Unauthorized")]
38    Unauthorized,
39
40    /// 403 — Authenticated but insufficient permissions.
41    #[error("Forbidden")]
42    Forbidden,
43
44    /// 422 — One or more fields failed validation.
45    #[error("Validation failed")]
46    Validation(Vec<FieldError>),
47
48    /// 409 — Conflict (e.g., unique constraint violation).
49    #[error("Conflict: {0}")]
50    Conflict(String),
51
52    /// 429 — Rate limit exceeded.
53    #[error("Rate limit exceeded")]
54    RateLimited,
55
56    /// 500 — Internal server error.
57    #[error("Internal server error: {0}")]
58    Internal(String),
59}
60
61impl ShaperailError {
62    /// Returns the machine-readable error code string.
63    pub fn code(&self) -> &'static str {
64        match self {
65            Self::NotFound => "NOT_FOUND",
66            Self::Unauthorized => "UNAUTHORIZED",
67            Self::Forbidden => "FORBIDDEN",
68            Self::Validation(_) => "VALIDATION_ERROR",
69            Self::Conflict(_) => "CONFLICT",
70            Self::RateLimited => "RATE_LIMITED",
71            Self::Internal(_) => "INTERNAL_ERROR",
72        }
73    }
74
75    /// Returns the HTTP status code for this error.
76    pub fn status(&self) -> StatusCode {
77        match self {
78            Self::NotFound => StatusCode::NOT_FOUND,
79            Self::Unauthorized => StatusCode::UNAUTHORIZED,
80            Self::Forbidden => StatusCode::FORBIDDEN,
81            Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
82            Self::Conflict(_) => StatusCode::CONFLICT,
83            Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
84            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
85        }
86    }
87
88    /// Builds the PRD-mandated JSON error response body.
89    ///
90    /// The `request_id` is passed in by the caller (typically from middleware).
91    pub fn to_error_body(&self, request_id: &str) -> serde_json::Value {
92        let details = match self {
93            Self::Validation(errors) => Some(serde_json::to_value(errors).unwrap_or_default()),
94            _ => None,
95        };
96
97        serde_json::json!({
98            "error": {
99                "code": self.code(),
100                "status": self.status().as_u16(),
101                "message": self.to_string(),
102                "request_id": request_id,
103                "details": details,
104            }
105        })
106    }
107}
108
109impl actix_web::ResponseError for ShaperailError {
110    fn status_code(&self) -> StatusCode {
111        self.status()
112    }
113
114    fn error_response(&self) -> HttpResponse {
115        let body = self.to_error_body("unknown");
116        HttpResponse::build(self.status()).json(body)
117    }
118}
119
120impl From<sqlx::Error> for ShaperailError {
121    fn from(err: sqlx::Error) -> Self {
122        match &err {
123            sqlx::Error::RowNotFound => Self::NotFound,
124            sqlx::Error::Database(db_err) => {
125                // PostgreSQL unique violation code
126                if db_err.code().as_deref() == Some("23505") {
127                    Self::Conflict(db_err.message().to_string())
128                } else {
129                    Self::Internal(err.to_string())
130                }
131            }
132            _ => Self::Internal(err.to_string()),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn error_codes() {
143        assert_eq!(ShaperailError::NotFound.code(), "NOT_FOUND");
144        assert_eq!(ShaperailError::Unauthorized.code(), "UNAUTHORIZED");
145        assert_eq!(ShaperailError::Forbidden.code(), "FORBIDDEN");
146        assert_eq!(
147            ShaperailError::Validation(vec![]).code(),
148            "VALIDATION_ERROR"
149        );
150        assert_eq!(
151            ShaperailError::Conflict("dup".to_string()).code(),
152            "CONFLICT"
153        );
154        assert_eq!(ShaperailError::RateLimited.code(), "RATE_LIMITED");
155        assert_eq!(
156            ShaperailError::Internal("oops".to_string()).code(),
157            "INTERNAL_ERROR"
158        );
159    }
160
161    #[test]
162    fn error_status_codes() {
163        assert_eq!(ShaperailError::NotFound.status(), StatusCode::NOT_FOUND);
164        assert_eq!(
165            ShaperailError::Unauthorized.status(),
166            StatusCode::UNAUTHORIZED
167        );
168        assert_eq!(ShaperailError::Forbidden.status(), StatusCode::FORBIDDEN);
169        assert_eq!(
170            ShaperailError::Validation(vec![]).status(),
171            StatusCode::UNPROCESSABLE_ENTITY
172        );
173        assert_eq!(
174            ShaperailError::Conflict("x".to_string()).status(),
175            StatusCode::CONFLICT
176        );
177        assert_eq!(
178            ShaperailError::RateLimited.status(),
179            StatusCode::TOO_MANY_REQUESTS
180        );
181        assert_eq!(
182            ShaperailError::Internal("x".to_string()).status(),
183            StatusCode::INTERNAL_SERVER_ERROR
184        );
185    }
186
187    #[test]
188    fn error_display() {
189        assert_eq!(ShaperailError::NotFound.to_string(), "Resource not found");
190        assert_eq!(ShaperailError::Unauthorized.to_string(), "Unauthorized");
191        assert_eq!(ShaperailError::Forbidden.to_string(), "Forbidden");
192        assert_eq!(
193            ShaperailError::Validation(vec![]).to_string(),
194            "Validation failed"
195        );
196        assert_eq!(
197            ShaperailError::Conflict("duplicate email".to_string()).to_string(),
198            "Conflict: duplicate email"
199        );
200        assert_eq!(
201            ShaperailError::RateLimited.to_string(),
202            "Rate limit exceeded"
203        );
204        assert_eq!(
205            ShaperailError::Internal("db down".to_string()).to_string(),
206            "Internal server error: db down"
207        );
208    }
209
210    #[test]
211    fn error_body_matches_prd_shape() {
212        let body = ShaperailError::NotFound.to_error_body("req-123");
213        let error = &body["error"];
214        assert_eq!(error["code"], "NOT_FOUND");
215        assert_eq!(error["status"], 404);
216        assert_eq!(error["message"], "Resource not found");
217        assert_eq!(error["request_id"], "req-123");
218        assert!(error["details"].is_null());
219    }
220
221    #[test]
222    fn error_body_validation_includes_details() {
223        let errors = vec![
224            FieldError {
225                field: "email".to_string(),
226                message: "is required".to_string(),
227                code: "required".to_string(),
228            },
229            FieldError {
230                field: "name".to_string(),
231                message: "too short".to_string(),
232                code: "too_short".to_string(),
233            },
234        ];
235        let body = ShaperailError::Validation(errors).to_error_body("req-456");
236        let details = &body["error"]["details"];
237        assert!(details.is_array());
238        assert_eq!(details.as_array().unwrap().len(), 2);
239        assert_eq!(details[0]["field"], "email");
240    }
241
242    #[test]
243    fn field_error_serde() {
244        let fe = FieldError {
245            field: "email".to_string(),
246            message: "is required".to_string(),
247            code: "required".to_string(),
248        };
249        let json = serde_json::to_string(&fe).unwrap();
250        let back: FieldError = serde_json::from_str(&json).unwrap();
251        assert_eq!(fe, back);
252    }
253
254    #[test]
255    fn from_sqlx_row_not_found() {
256        let err: ShaperailError = sqlx::Error::RowNotFound.into();
257        assert!(matches!(err, ShaperailError::NotFound));
258    }
259}