lmrc_http_common/
error.rs

1//! Common error types for HTTP services
2
3use axum::{
4    http::StatusCode,
5    response::{IntoResponse, Response},
6    Json,
7};
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11/// Standard error response format
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ErrorResponse {
14    /// Error code/type
15    pub error: String,
16    /// Human-readable error message
17    pub message: String,
18    /// Optional additional details
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub details: Option<serde_json::Value>,
21}
22
23impl ErrorResponse {
24    pub fn new(error: impl Into<String>, message: impl Into<String>) -> Self {
25        Self {
26            error: error.into(),
27            message: message.into(),
28            details: None,
29        }
30    }
31
32    pub fn with_details(mut self, details: serde_json::Value) -> Self {
33        self.details = Some(details);
34        self
35    }
36}
37
38/// Common HTTP application errors
39#[derive(Debug, Error)]
40pub enum HttpError {
41    /// Bad request (400)
42    #[error("Bad request: {0}")]
43    BadRequest(String),
44
45    /// Unauthorized (401)
46    #[error("Unauthorized: {0}")]
47    Unauthorized(String),
48
49    /// Forbidden (403)
50    #[error("Forbidden: {0}")]
51    Forbidden(String),
52
53    /// Not found (404)
54    #[error("Not found: {0}")]
55    NotFound(String),
56
57    /// Conflict (409)
58    #[error("Conflict: {0}")]
59    Conflict(String),
60
61    /// Unprocessable entity (422)
62    #[error("Validation failed: {0}")]
63    ValidationError(String),
64
65    /// Internal server error (500)
66    #[error("Internal server error: {0}")]
67    InternalServer(String),
68
69    /// Service unavailable (503)
70    #[error("Service unavailable: {0}")]
71    ServiceUnavailable(String),
72
73    /// Database error
74    #[error("Database error: {0}")]
75    Database(String),
76
77    /// External service error
78    #[error("External service error: {0}")]
79    ExternalService(String),
80
81    /// Custom error with status code
82    #[error("{message}")]
83    Custom {
84        status: StatusCode,
85        error_code: String,
86        message: String,
87    },
88}
89
90impl HttpError {
91    /// Get the HTTP status code for this error
92    pub fn status_code(&self) -> StatusCode {
93        match self {
94            HttpError::BadRequest(_) => StatusCode::BAD_REQUEST,
95            HttpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
96            HttpError::Forbidden(_) => StatusCode::FORBIDDEN,
97            HttpError::NotFound(_) => StatusCode::NOT_FOUND,
98            HttpError::Conflict(_) => StatusCode::CONFLICT,
99            HttpError::ValidationError(_) => StatusCode::UNPROCESSABLE_ENTITY,
100            HttpError::InternalServer(_) => StatusCode::INTERNAL_SERVER_ERROR,
101            HttpError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
102            HttpError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
103            HttpError::ExternalService(_) => StatusCode::BAD_GATEWAY,
104            HttpError::Custom { status, .. } => *status,
105        }
106    }
107
108    /// Get the error code string
109    pub fn error_code(&self) -> String {
110        match self {
111            HttpError::BadRequest(_) => "BAD_REQUEST".to_string(),
112            HttpError::Unauthorized(_) => "UNAUTHORIZED".to_string(),
113            HttpError::Forbidden(_) => "FORBIDDEN".to_string(),
114            HttpError::NotFound(_) => "NOT_FOUND".to_string(),
115            HttpError::Conflict(_) => "CONFLICT".to_string(),
116            HttpError::ValidationError(_) => "VALIDATION_ERROR".to_string(),
117            HttpError::InternalServer(_) => "INTERNAL_SERVER_ERROR".to_string(),
118            HttpError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE".to_string(),
119            HttpError::Database(_) => "DATABASE_ERROR".to_string(),
120            HttpError::ExternalService(_) => "EXTERNAL_SERVICE_ERROR".to_string(),
121            HttpError::Custom { error_code, .. } => error_code.clone(),
122        }
123    }
124
125    /// Create a custom error with specific status code
126    pub fn custom(
127        status: StatusCode,
128        error_code: impl Into<String>,
129        message: impl Into<String>,
130    ) -> Self {
131        HttpError::Custom {
132            status,
133            error_code: error_code.into(),
134            message: message.into(),
135        }
136    }
137}
138
139impl IntoResponse for HttpError {
140    fn into_response(self) -> Response {
141        let status = self.status_code();
142        let error_code = self.error_code();
143        let message = self.to_string();
144
145        let body = ErrorResponse::new(error_code, message);
146
147        (status, Json(body)).into_response()
148    }
149}
150
151/// Convenience type for Result with HttpError
152pub type HttpResult<T> = Result<T, HttpError>;
153
154// Helpful From implementations for common error types
155
156#[cfg(feature = "validation")]
157impl From<validator::ValidationErrors> for HttpError {
158    fn from(err: validator::ValidationErrors) -> Self {
159        use std::fmt::Write;
160
161        let mut message = String::new();
162        let mut first = true;
163
164        for (field, field_errors) in err.field_errors() {
165            for error in field_errors {
166                if !first {
167                    write!(&mut message, "; ").unwrap();
168                }
169                first = false;
170
171                write!(&mut message, "{}: ", field).unwrap();
172
173                if let Some(msg) = &error.message {
174                    write!(&mut message, "{}", msg).unwrap();
175                } else {
176                    write!(&mut message, "validation failed ({})", error.code).unwrap();
177                }
178            }
179        }
180
181        if message.is_empty() {
182            HttpError::ValidationError("Validation failed".to_string())
183        } else {
184            HttpError::ValidationError(message)
185        }
186    }
187}
188
189/// Macro for creating app-specific error types that wrap HttpError
190///
191/// This makes it easy to create custom error types for your application
192/// while leveraging the existing HttpError infrastructure.
193///
194/// ## Example
195///
196/// ```rust
197/// use lmrc_http_common::app_error;
198///
199/// app_error! {
200///     MyAppError {
201///         BusinessLogic(String),
202///         ExternalApi(String),
203///     }
204/// }
205///
206/// // Now you can use MyAppError in your handlers:
207/// // fn my_handler() -> Result<(), MyAppError> { ... }
208/// ```
209#[macro_export]
210macro_rules! app_error {
211    ($name:ident { $($variant:ident($ty:ty)),* $(,)? }) => {
212        #[derive(Debug, thiserror::Error)]
213        pub enum $name {
214            /// HTTP error
215            #[error(transparent)]
216            Http(#[from] $crate::error::HttpError),
217
218            $(
219                #[error("{0}")]
220                $variant($ty),
221            )*
222        }
223
224        impl axum::response::IntoResponse for $name {
225            fn into_response(self) -> axum::response::Response {
226                match self {
227                    Self::Http(e) => e.into_response(),
228                    _ => {
229                        $crate::error::HttpError::InternalServer(self.to_string()).into_response()
230                    }
231                }
232            }
233        }
234    };
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_error_status_codes() {
243        assert_eq!(
244            HttpError::BadRequest("test".into()).status_code(),
245            StatusCode::BAD_REQUEST
246        );
247        assert_eq!(
248            HttpError::Unauthorized("test".into()).status_code(),
249            StatusCode::UNAUTHORIZED
250        );
251        assert_eq!(
252            HttpError::NotFound("test".into()).status_code(),
253            StatusCode::NOT_FOUND
254        );
255    }
256
257    #[test]
258    fn test_custom_error() {
259        let err = HttpError::custom(StatusCode::IM_A_TEAPOT, "TEAPOT", "I'm a teapot");
260        assert_eq!(err.status_code(), StatusCode::IM_A_TEAPOT);
261        assert_eq!(err.error_code(), "TEAPOT");
262    }
263}