1use std::error::Error;
17
18use aro_core::error::RepoError;
19use axum::http::StatusCode;
20use axum::response::{IntoResponse, Response};
21
22use crate::fallback::json_error_response;
23
24#[derive(Debug)]
29pub enum AroError {
30 NotFound,
32 Conflict,
34 ValidationError(String),
36 BadRequest(String),
38 Unauthorized(String),
40 Forbidden(String),
42 Internal(Box<dyn Error + Send + Sync>),
44}
45
46impl AroError {
47 pub fn status_code(&self) -> StatusCode {
49 match self {
50 Self::NotFound => StatusCode::NOT_FOUND,
51 Self::Conflict => StatusCode::CONFLICT,
52 Self::ValidationError(_) => StatusCode::UNPROCESSABLE_ENTITY,
53 Self::BadRequest(_) => StatusCode::BAD_REQUEST,
54 Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
55 Self::Forbidden(_) => StatusCode::FORBIDDEN,
56 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
57 }
58 }
59
60 pub fn message(&self) -> String {
62 match self {
63 Self::NotFound => "not found".to_string(),
64 Self::Conflict => "conflict".to_string(),
65 Self::ValidationError(msg)
66 | Self::BadRequest(msg)
67 | Self::Unauthorized(msg)
68 | Self::Forbidden(msg) => msg.clone(),
69 Self::Internal(_) => "internal server error".to_string(),
70 }
71 }
72}
73
74impl IntoResponse for AroError {
75 fn into_response(self) -> Response {
76 if let Self::Internal(ref err) = self {
77 tracing::error!(error = %err, "internal server error");
78 }
79 json_error_response(self.status_code(), self.message())
80 }
81}
82
83impl From<RepoError> for AroError {
84 fn from(err: RepoError) -> Self {
85 match err {
86 RepoError::NotFound => Self::NotFound,
87 RepoError::Conflict => Self::Conflict,
88 RepoError::ValidationError(msg) => Self::ValidationError(msg),
89 RepoError::Unknown(err) => Self::Internal(err),
90 }
91 }
92}
93
94impl std::fmt::Display for AroError {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 Self::NotFound => write!(f, "not found"),
98 Self::Conflict => write!(f, "conflict"),
99 Self::ValidationError(msg) => write!(f, "validation error: {msg}"),
100 Self::BadRequest(msg) => write!(f, "bad request: {msg}"),
101 Self::Unauthorized(msg) => write!(f, "unauthorized: {msg}"),
102 Self::Forbidden(msg) => write!(f, "forbidden: {msg}"),
103 Self::Internal(err) => write!(f, "internal error: {err}"),
104 }
105 }
106}
107
108impl Error for AroError {
109 fn source(&self) -> Option<&(dyn Error + 'static)> {
110 match self {
111 Self::Internal(err) => Some(err.as_ref()),
112 _ => None,
113 }
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use http_body_util::BodyExt;
121 use tracing_test::traced_test;
122
123 async fn response_json(resp: Response) -> serde_json::Value {
124 let body = resp.into_body();
125 let bytes = body.collect().await.unwrap().to_bytes();
126 serde_json::from_slice(&bytes).unwrap()
127 }
128
129 #[tokio::test]
130 async fn not_found_produces_404_json() {
131 let resp = AroError::NotFound.into_response();
132 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
133 assert_eq!(
134 resp.headers().get("content-type").unwrap(),
135 "application/json"
136 );
137 let json = response_json(resp).await;
138 assert_eq!(json["error"], "not found");
139 assert_eq!(json["status"], 404);
140 }
141
142 #[tokio::test]
143 async fn conflict_produces_409_json() {
144 let resp = AroError::Conflict.into_response();
145 assert_eq!(resp.status(), StatusCode::CONFLICT);
146 let json = response_json(resp).await;
147 assert_eq!(json["error"], "conflict");
148 assert_eq!(json["status"], 409);
149 }
150
151 #[tokio::test]
152 async fn validation_error_produces_422_json() {
153 let resp = AroError::ValidationError("name is required".into()).into_response();
154 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
155 let json = response_json(resp).await;
156 assert_eq!(json["error"], "name is required");
157 assert_eq!(json["status"], 422);
158 }
159
160 #[tokio::test]
161 async fn bad_request_produces_400_json() {
162 let resp = AroError::BadRequest("invalid id".into()).into_response();
163 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
164 let json = response_json(resp).await;
165 assert_eq!(json["error"], "invalid id");
166 assert_eq!(json["status"], 400);
167 }
168
169 #[tokio::test]
170 async fn internal_produces_500_json() {
171 let inner = std::io::Error::other("db crashed");
172 let resp = AroError::Internal(Box::new(inner)).into_response();
173 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
174 let json = response_json(resp).await;
175 assert_eq!(json["error"], "internal server error");
176 assert_eq!(json["status"], 500);
177 }
178
179 #[tokio::test]
180 #[traced_test]
181 async fn internal_logs_error_before_response() {
182 let inner = std::io::Error::other("db crashed");
183 let resp = AroError::Internal(Box::new(inner)).into_response();
184
185 assert!(logs_contain("internal server error"));
186 assert!(logs_contain("db crashed"));
187 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
188 }
189
190 #[test]
191 fn repo_not_found_converts_to_aro_not_found() {
192 let aro_err: AroError = RepoError::NotFound.into();
193 assert!(matches!(aro_err, AroError::NotFound));
194 }
195
196 #[test]
197 fn repo_conflict_converts_to_aro_conflict() {
198 let aro_err: AroError = RepoError::Conflict.into();
199 assert!(matches!(aro_err, AroError::Conflict));
200 }
201
202 #[test]
203 fn repo_validation_converts_to_aro_validation() {
204 let aro_err: AroError = RepoError::ValidationError("bad".into()).into();
205 assert!(matches!(aro_err, AroError::ValidationError(msg) if msg == "bad"));
206 }
207
208 #[test]
209 fn repo_unknown_converts_to_aro_internal() {
210 let inner = std::io::Error::other("oops");
211 let aro_err: AroError = RepoError::Unknown(Box::new(inner)).into();
212 assert!(matches!(aro_err, AroError::Internal(_)));
213 }
214
215 #[tokio::test]
216 async fn unauthorized_produces_401_json() {
217 let resp = AroError::Unauthorized("invalid token".into()).into_response();
218 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
219 let json = response_json(resp).await;
220 assert_eq!(json["error"], "invalid token");
221 assert_eq!(json["status"], 401);
222 }
223
224 #[tokio::test]
225 async fn forbidden_produces_403_json() {
226 let resp = AroError::Forbidden("insufficient permissions".into()).into_response();
227 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
228 let json = response_json(resp).await;
229 assert_eq!(json["error"], "insufficient permissions");
230 assert_eq!(json["status"], 403);
231 }
232
233 #[test]
234 fn unauthorized_display() {
235 let err = AroError::Unauthorized("bad token".into());
236 assert_eq!(err.to_string(), "unauthorized: bad token");
237 }
238
239 #[test]
240 fn forbidden_display() {
241 let err = AroError::Forbidden("not allowed".into());
242 assert_eq!(err.to_string(), "forbidden: not allowed");
243 }
244
245 #[tokio::test]
246 async fn error_response_body_matches_expected_structure() {
247 let resp = AroError::NotFound.into_response();
248 let json = response_json(resp).await;
249 let obj = json.as_object().unwrap();
251 assert_eq!(obj.len(), 2);
252 assert!(obj.contains_key("error"));
253 assert!(obj.contains_key("status"));
254 }
255}