1use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use serde::Serialize;
6use stateset_core::CommerceError;
7use utoipa::ToSchema;
8
9#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum HttpError {
13 #[error("Not found: {0}")]
15 NotFound(String),
16
17 #[error("Bad request: {0}")]
19 BadRequest(String),
20
21 #[error("Conflict: {0}")]
23 Conflict(String),
24
25 #[error("Internal error: {0}")]
27 InternalError(String),
28
29 #[error("Unauthorized: {0}")]
31 Unauthorized(String),
32
33 #[error("Forbidden: {0}")]
35 Forbidden(String),
36
37 #[error("Validation error: {0}")]
39 ValidationError(String),
40
41 #[error("Too many requests: {0}")]
43 TooManyRequests(String),
44}
45
46#[derive(Debug, Serialize, ToSchema)]
48pub(crate) struct ErrorBody {
49 error: ErrorDetail,
50}
51
52#[derive(Debug, Serialize, ToSchema)]
54pub(crate) struct ErrorDetail {
55 code: String,
56 message: String,
57}
58
59impl HttpError {
60 #[must_use]
62 pub const fn status_code(&self) -> StatusCode {
63 match self {
64 Self::NotFound(_) => StatusCode::NOT_FOUND,
65 Self::BadRequest(_) => StatusCode::BAD_REQUEST,
66 Self::Conflict(_) => StatusCode::CONFLICT,
67 Self::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
68 Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
69 Self::Forbidden(_) => StatusCode::FORBIDDEN,
70 Self::ValidationError(_) => StatusCode::UNPROCESSABLE_ENTITY,
71 Self::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
72 }
73 }
74
75 #[must_use]
77 pub const fn code(&self) -> &'static str {
78 match self {
79 Self::NotFound(_) => "not_found",
80 Self::BadRequest(_) => "bad_request",
81 Self::Conflict(_) => "conflict",
82 Self::InternalError(_) => "internal_error",
83 Self::Unauthorized(_) => "unauthorized",
84 Self::Forbidden(_) => "forbidden",
85 Self::ValidationError(_) => "validation_error",
86 Self::TooManyRequests(_) => "too_many_requests",
87 }
88 }
89}
90
91impl IntoResponse for HttpError {
92 fn into_response(self) -> Response {
93 let status = self.status_code();
94 let body = ErrorBody {
95 error: ErrorDetail { code: self.code().to_string(), message: self.to_string() },
96 };
97 (status, axum::Json(body)).into_response()
98 }
99}
100
101impl From<CommerceError> for HttpError {
102 fn from(err: CommerceError) -> Self {
103 if err.is_not_found() {
104 return Self::NotFound(err.to_string());
105 }
106 if err.is_conflict() {
107 return Self::Conflict(err.to_string());
108 }
109 if err.is_validation() {
110 return Self::ValidationError(err.to_string());
111 }
112 match err {
113 CommerceError::NotPermitted(msg) => Self::Forbidden(msg),
114 CommerceError::OrderCannotBeCancelled(msg) => Self::BadRequest(msg),
115 CommerceError::OrderCannotBeRefunded(msg) => Self::BadRequest(msg),
116 CommerceError::InvalidOrderStatusTransition { from, to } => {
117 Self::BadRequest(format!("Invalid status transition from {from} to {to}"))
118 }
119 CommerceError::ReturnCannotBeApproved(msg) => Self::BadRequest(msg),
120 CommerceError::ReturnPeriodExpired => {
121 Self::BadRequest("Return period expired".to_string())
122 }
123 CommerceError::ItemNotEligibleForReturn => {
124 Self::BadRequest("Item not eligible for return".to_string())
125 }
126 CommerceError::InsufficientStock { sku, requested, available } => {
127 Self::BadRequest(format!(
128 "Insufficient stock for SKU {sku}: requested {requested}, available {available}"
129 ))
130 }
131 CommerceError::CustomerNotActive => {
132 Self::BadRequest("Customer is not active".to_string())
133 }
134 CommerceError::ProductNotPurchasable => {
135 Self::BadRequest("Product is not purchasable".to_string())
136 }
137 _ => Self::InternalError(err.to_string()),
138 }
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn not_found_status() {
148 let err = HttpError::NotFound("order 123".into());
149 assert_eq!(err.status_code(), StatusCode::NOT_FOUND);
150 assert_eq!(err.code(), "not_found");
151 }
152
153 #[test]
154 fn bad_request_status() {
155 let err = HttpError::BadRequest("invalid input".into());
156 assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
157 assert_eq!(err.code(), "bad_request");
158 }
159
160 #[test]
161 fn conflict_status() {
162 let err = HttpError::Conflict("duplicate".into());
163 assert_eq!(err.status_code(), StatusCode::CONFLICT);
164 assert_eq!(err.code(), "conflict");
165 }
166
167 #[test]
168 fn internal_error_status() {
169 let err = HttpError::InternalError("boom".into());
170 assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
171 assert_eq!(err.code(), "internal_error");
172 }
173
174 #[test]
175 fn unauthorized_status() {
176 let err = HttpError::Unauthorized("no token".into());
177 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
178 assert_eq!(err.code(), "unauthorized");
179 }
180
181 #[test]
182 fn forbidden_status() {
183 let err = HttpError::Forbidden("denied".into());
184 assert_eq!(err.status_code(), StatusCode::FORBIDDEN);
185 assert_eq!(err.code(), "forbidden");
186 }
187
188 #[test]
189 fn validation_error_status() {
190 let err = HttpError::ValidationError("email invalid".into());
191 assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
192 assert_eq!(err.code(), "validation_error");
193 }
194
195 #[test]
196 fn too_many_requests_status() {
197 let err = HttpError::TooManyRequests("rate limit exceeded".into());
198 assert_eq!(err.status_code(), StatusCode::TOO_MANY_REQUESTS);
199 assert_eq!(err.code(), "too_many_requests");
200 }
201
202 #[test]
203 fn commerce_not_found_maps_to_not_found() {
204 let ce = CommerceError::OrderNotFound(uuid::Uuid::nil());
205 let he: HttpError = ce.into();
206 assert_eq!(he.status_code(), StatusCode::NOT_FOUND);
207 }
208
209 #[test]
210 fn commerce_conflict_maps_to_conflict() {
211 let ce = CommerceError::DuplicateSku("SKU-1".into());
212 let he: HttpError = ce.into();
213 assert_eq!(he.status_code(), StatusCode::CONFLICT);
214 }
215
216 #[test]
217 fn commerce_validation_maps_to_validation() {
218 let ce = CommerceError::ValidationError("bad field".into());
219 let he: HttpError = ce.into();
220 assert_eq!(he.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
221 }
222
223 #[test]
224 fn commerce_not_permitted_maps_to_forbidden() {
225 let ce = CommerceError::NotPermitted("no access".into());
226 let he: HttpError = ce.into();
227 assert_eq!(he.status_code(), StatusCode::FORBIDDEN);
228 }
229
230 #[test]
231 fn commerce_cancel_error_maps_to_bad_request() {
232 let ce = CommerceError::OrderCannotBeCancelled("shipped".into());
233 let he: HttpError = ce.into();
234 assert_eq!(he.status_code(), StatusCode::BAD_REQUEST);
235 }
236
237 #[test]
238 fn commerce_insufficient_stock_maps_to_bad_request() {
239 let ce = CommerceError::InsufficientStock {
240 sku: "ABC".into(),
241 requested: "10".into(),
242 available: "2".into(),
243 };
244 let he: HttpError = ce.into();
245 assert_eq!(he.status_code(), StatusCode::BAD_REQUEST);
246 }
247
248 #[test]
249 fn commerce_internal_maps_to_internal() {
250 let ce = CommerceError::Internal("panic".into());
251 let he: HttpError = ce.into();
252 assert_eq!(he.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
253 }
254
255 #[test]
256 fn error_into_response_is_json() {
257 let err = HttpError::NotFound("widget".into());
258 let response = err.into_response();
259 assert_eq!(response.status(), StatusCode::NOT_FOUND);
260 let content_type =
261 response.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("");
262 assert!(content_type.contains("application/json"));
263 }
264
265 #[test]
266 fn error_body_json_structure() {
267 let body = super::ErrorBody {
268 error: super::ErrorDetail {
269 code: "not_found".into(),
270 message: "Order not found".into(),
271 },
272 };
273 let json = serde_json::to_value(&body).unwrap();
274 assert_eq!(json["error"]["code"], "not_found");
275 assert_eq!(json["error"]["message"], "Order not found");
276 }
277
278 #[test]
279 fn debug_impl_exists() {
280 let err = HttpError::NotFound("x".into());
281 let dbg = format!("{err:?}");
282 assert!(dbg.contains("NotFound"));
283 }
284}