1use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde_json::json;
8
9pub enum ApiError {
11 Storage(tuitbot_core::error::StorageError),
13 NotFound(String),
15 BadRequest(String),
17 Conflict(String),
19 Internal(String),
21 Forbidden(String),
23}
24
25impl From<tuitbot_core::error::StorageError> for ApiError {
26 fn from(err: tuitbot_core::error::StorageError) -> Self {
27 match err {
28 tuitbot_core::error::StorageError::AlreadyReviewed { id, current_status } => {
29 Self::Conflict(format!(
30 "item {id} has already been reviewed (current status: {current_status})"
31 ))
32 }
33 other => Self::Storage(other),
34 }
35 }
36}
37
38impl From<crate::account::AccountError> for ApiError {
39 fn from(err: crate::account::AccountError) -> Self {
40 match err.status {
41 StatusCode::FORBIDDEN => Self::Forbidden(err.message),
42 StatusCode::NOT_FOUND => Self::NotFound(err.message),
43 _ => Self::Internal(err.message),
44 }
45 }
46}
47
48impl IntoResponse for ApiError {
49 fn into_response(self) -> Response {
50 let (status, message) = match self {
51 Self::Storage(e) => {
52 tracing::error!("storage error: {e}");
53 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
54 }
55 Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
56 Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
57 Self::Conflict(msg) => (StatusCode::CONFLICT, msg),
58 Self::Internal(msg) => {
59 tracing::error!("internal error: {msg}");
60 (StatusCode::INTERNAL_SERVER_ERROR, msg)
61 }
62 Self::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
63 };
64
65 let body = axum::Json(json!({ "error": message }));
66 (status, body).into_response()
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use axum::response::IntoResponse;
74 use http_body_util::BodyExt;
75
76 async fn error_response(err: ApiError) -> (StatusCode, serde_json::Value) {
78 let resp = err.into_response();
79 let status = resp.status();
80 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
81 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
82 (status, json)
83 }
84
85 #[tokio::test]
86 async fn not_found_returns_404() {
87 let (status, body) = error_response(ApiError::NotFound("missing item".into())).await;
88 assert_eq!(status, StatusCode::NOT_FOUND);
89 assert_eq!(body["error"], "missing item");
90 }
91
92 #[tokio::test]
93 async fn bad_request_returns_400() {
94 let (status, body) = error_response(ApiError::BadRequest("invalid field".into())).await;
95 assert_eq!(status, StatusCode::BAD_REQUEST);
96 assert_eq!(body["error"], "invalid field");
97 }
98
99 #[tokio::test]
100 async fn conflict_returns_409() {
101 let (status, body) = error_response(ApiError::Conflict("already exists".into())).await;
102 assert_eq!(status, StatusCode::CONFLICT);
103 assert_eq!(body["error"], "already exists");
104 }
105
106 #[tokio::test]
107 async fn internal_returns_500() {
108 let (status, body) = error_response(ApiError::Internal("crash".into())).await;
109 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
110 assert_eq!(body["error"], "crash");
111 }
112
113 #[tokio::test]
114 async fn forbidden_returns_403() {
115 let (status, body) = error_response(ApiError::Forbidden("no access".into())).await;
116 assert_eq!(status, StatusCode::FORBIDDEN);
117 assert_eq!(body["error"], "no access");
118 }
119
120 #[tokio::test]
121 async fn storage_error_returns_500() {
122 let storage_err = tuitbot_core::error::StorageError::Query {
123 source: sqlx::Error::RowNotFound,
124 };
125 let (status, body) = error_response(ApiError::Storage(storage_err)).await;
126 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
127 assert!(body["error"].as_str().unwrap().len() > 0);
128 }
129
130 #[tokio::test]
131 async fn already_reviewed_storage_converts_to_conflict() {
132 let storage_err = tuitbot_core::error::StorageError::AlreadyReviewed {
133 id: 42,
134 current_status: "approved".into(),
135 };
136 let api_err: ApiError = storage_err.into();
137 let (status, body) = error_response(api_err).await;
138 assert_eq!(status, StatusCode::CONFLICT);
139 assert!(body["error"]
140 .as_str()
141 .unwrap()
142 .contains("already been reviewed"));
143 }
144
145 #[test]
146 fn account_error_forbidden_converts() {
147 let account_err = crate::account::AccountError {
148 status: StatusCode::FORBIDDEN,
149 message: "no perms".into(),
150 };
151 let api_err: ApiError = account_err.into();
152 match api_err {
153 ApiError::Forbidden(msg) => assert_eq!(msg, "no perms"),
154 _ => panic!("expected Forbidden"),
155 }
156 }
157
158 #[test]
159 fn account_error_not_found_converts() {
160 let account_err = crate::account::AccountError {
161 status: StatusCode::NOT_FOUND,
162 message: "gone".into(),
163 };
164 let api_err: ApiError = account_err.into();
165 match api_err {
166 ApiError::NotFound(msg) => assert_eq!(msg, "gone"),
167 _ => panic!("expected NotFound"),
168 }
169 }
170
171 #[test]
172 fn account_error_other_converts_to_internal() {
173 let account_err = crate::account::AccountError {
174 status: StatusCode::INTERNAL_SERVER_ERROR,
175 message: "db down".into(),
176 };
177 let api_err: ApiError = account_err.into();
178 match api_err {
179 ApiError::Internal(msg) => assert_eq!(msg, "db down"),
180 _ => panic!("expected Internal"),
181 }
182 }
183}