1use actix_web::http::StatusCode;
2use actix_web::HttpResponse;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct FieldError {
8 pub field: String,
10 pub message: String,
12 pub code: String,
14}
15
16#[derive(Debug, thiserror::Error)]
31pub enum ShaperailError {
32 #[error("Resource not found")]
34 NotFound,
35
36 #[error("Unauthorized")]
38 Unauthorized,
39
40 #[error("Forbidden")]
42 Forbidden,
43
44 #[error("Validation failed")]
46 Validation(Vec<FieldError>),
47
48 #[error("Conflict: {0}")]
50 Conflict(String),
51
52 #[error("Rate limit exceeded")]
54 RateLimited,
55
56 #[error("Internal server error: {0}")]
58 Internal(String),
59}
60
61impl ShaperailError {
62 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 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 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 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}