Skip to main content

stateset_http/
error.rs

1//! HTTP error types mapping domain errors to HTTP status codes.
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use serde::Serialize;
6use stateset_core::CommerceError;
7use utoipa::ToSchema;
8
9/// HTTP error wrapper that maps domain errors to appropriate HTTP responses.
10#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum HttpError {
13    /// Resource not found (HTTP 404).
14    #[error("Not found: {0}")]
15    NotFound(String),
16
17    /// Bad request / validation failure (HTTP 400).
18    #[error("Bad request: {0}")]
19    BadRequest(String),
20
21    /// Conflict (HTTP 409).
22    #[error("Conflict: {0}")]
23    Conflict(String),
24
25    /// Internal server error (HTTP 500).
26    #[error("Internal error: {0}")]
27    InternalError(String),
28
29    /// Unauthorized (HTTP 401).
30    #[error("Unauthorized: {0}")]
31    Unauthorized(String),
32
33    /// Forbidden (HTTP 403).
34    #[error("Forbidden: {0}")]
35    Forbidden(String),
36
37    /// Validation error with field-level detail (HTTP 422).
38    #[error("Validation error: {0}")]
39    ValidationError(String),
40
41    /// Too many requests (HTTP 429).
42    #[error("Too many requests: {0}")]
43    TooManyRequests(String),
44}
45
46/// JSON body for error responses.
47#[derive(Debug, Serialize, ToSchema)]
48pub(crate) struct ErrorBody {
49    error: ErrorDetail,
50}
51
52/// Inner detail of the error response.
53#[derive(Debug, Serialize, ToSchema)]
54pub(crate) struct ErrorDetail {
55    code: String,
56    message: String,
57}
58
59impl HttpError {
60    /// The HTTP status code for this error.
61    #[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    /// The short error code string.
76    #[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}