1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5#[cfg(feature = "web")]
6use axum::http::StatusCode;
7#[cfg(feature = "web")]
8use axum::response::IntoResponse;
9#[cfg(feature = "web")]
10use axum::Json;
11
12#[derive(Debug, thiserror::Error)]
13pub enum InternalApiError {
14 #[error("Resource not found: {resource_type} with ID '{id}'")]
15 NotFound { resource_type: String, id: String },
16
17 #[error("Bad request: {message}")]
18 BadRequest { message: String },
19
20 #[error("Unauthorized access: {reason}")]
21 Unauthorized { reason: String },
22
23 #[error("Access forbidden: {resource} - {reason}")]
24 Forbidden { resource: String, reason: String },
25
26 #[error("Validation failed for field '{field}': {reason}")]
27 ValidationError { field: String, reason: String },
28
29 #[error("Conflict: {resource} already exists")]
30 ConflictError { resource: String },
31
32 #[error("Rate limit exceeded for {resource}")]
33 RateLimited { resource: String },
34
35 #[error("Service temporarily unavailable: {service}")]
36 ServiceUnavailable { service: String },
37
38 #[error("Database operation failed: {message}")]
39 DatabaseError { message: String },
40
41 #[error("JSON serialization failed")]
42 JsonError(#[from] serde_json::Error),
43
44 #[error("Authentication token error: {message}")]
45 AuthenticationError { message: String },
46
47 #[error("Internal server error: {message}")]
48 InternalError { message: String },
49}
50
51impl InternalApiError {
52 pub fn not_found(resource_type: impl Into<String>, id: impl Into<String>) -> Self {
53 Self::NotFound {
54 resource_type: resource_type.into(),
55 id: id.into(),
56 }
57 }
58
59 pub fn bad_request(message: impl Into<String>) -> Self {
60 Self::BadRequest {
61 message: message.into(),
62 }
63 }
64
65 pub fn unauthorized(reason: impl Into<String>) -> Self {
66 Self::Unauthorized {
67 reason: reason.into(),
68 }
69 }
70
71 pub fn forbidden(resource: impl Into<String>, reason: impl Into<String>) -> Self {
72 Self::Forbidden {
73 resource: resource.into(),
74 reason: reason.into(),
75 }
76 }
77
78 pub fn validation_error(field: impl Into<String>, reason: impl Into<String>) -> Self {
79 Self::ValidationError {
80 field: field.into(),
81 reason: reason.into(),
82 }
83 }
84
85 pub fn conflict(resource: impl Into<String>) -> Self {
86 Self::ConflictError {
87 resource: resource.into(),
88 }
89 }
90
91 pub fn rate_limited(resource: impl Into<String>) -> Self {
92 Self::RateLimited {
93 resource: resource.into(),
94 }
95 }
96
97 pub fn service_unavailable(service: impl Into<String>) -> Self {
98 Self::ServiceUnavailable {
99 service: service.into(),
100 }
101 }
102
103 pub fn internal_error(message: impl Into<String>) -> Self {
104 Self::InternalError {
105 message: message.into(),
106 }
107 }
108
109 pub fn database_error(message: impl Into<String>) -> Self {
110 Self::DatabaseError {
111 message: message.into(),
112 }
113 }
114
115 pub fn authentication_error(message: impl Into<String>) -> Self {
116 Self::AuthenticationError {
117 message: message.into(),
118 }
119 }
120
121 pub const fn error_code(&self) -> ErrorCode {
122 match self {
123 Self::NotFound { .. } => ErrorCode::NotFound,
124 Self::BadRequest { .. } => ErrorCode::BadRequest,
125 Self::Unauthorized { .. } => ErrorCode::Unauthorized,
126 Self::Forbidden { .. } => ErrorCode::Forbidden,
127 Self::ValidationError { .. } => ErrorCode::ValidationError,
128 Self::ConflictError { .. } => ErrorCode::ConflictError,
129 Self::RateLimited { .. } => ErrorCode::RateLimited,
130 Self::ServiceUnavailable { .. } => ErrorCode::ServiceUnavailable,
131 Self::DatabaseError { .. }
132 | Self::JsonError(_)
133 | Self::AuthenticationError { .. }
134 | Self::InternalError { .. } => ErrorCode::InternalError,
135 }
136 }
137}
138
139impl From<InternalApiError> for ApiError {
140 fn from(error: InternalApiError) -> Self {
141 let code = error.error_code();
142 let message = error.to_string();
143 let details = match &error {
144 InternalApiError::NotFound { resource_type, id } => Some(format!(
145 "The requested {resource_type} with ID '{id}' does not exist"
146 )),
147 InternalApiError::ValidationError { field, reason } => {
148 Some(format!("Field '{field}': {reason}"))
149 },
150 InternalApiError::Forbidden { resource, reason } => {
151 Some(format!("Access to {resource} denied: {reason}"))
152 },
153 InternalApiError::DatabaseError { message } => Some(format!("Database error: {message}")),
154 InternalApiError::JsonError(e) => Some(format!("JSON processing error: {e}")),
155 InternalApiError::AuthenticationError { message } => {
156 Some(format!("Authentication error: {message}"))
157 },
158 InternalApiError::BadRequest { .. }
159 | InternalApiError::Unauthorized { .. }
160 | InternalApiError::ConflictError { .. }
161 | InternalApiError::RateLimited { .. }
162 | InternalApiError::ServiceUnavailable { .. }
163 | InternalApiError::InternalError { .. } => None,
164 };
165
166 Self::new(code, message).with_details(details.unwrap_or_default())
167 }
168}
169
170#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum ErrorCode {
173 NotFound,
174 BadRequest,
175 Unauthorized,
176 Forbidden,
177 InternalError,
178 ValidationError,
179 ConflictError,
180 RateLimited,
181 ServiceUnavailable,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct ValidationError {
186 pub field: String,
187
188 pub message: String,
189
190 pub code: String,
191
192 pub context: Option<Value>,
193}
194
195#[derive(Debug, Serialize, Deserialize)]
196pub struct ApiError {
197 pub code: ErrorCode,
198
199 pub message: String,
200
201 pub details: Option<String>,
202
203 pub error_key: Option<String>,
204
205 pub path: Option<String>,
206
207 #[serde(default)]
208 pub validation_errors: Vec<ValidationError>,
209
210 pub timestamp: DateTime<Utc>,
211
212 pub trace_id: Option<String>,
213}
214
215impl ApiError {
216 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
217 Self {
218 code,
219 message: message.into(),
220 details: None,
221 error_key: None,
222 path: None,
223 validation_errors: Vec::new(),
224 timestamp: Utc::now(),
225 trace_id: None,
226 }
227 }
228
229 pub fn with_details(mut self, details: impl Into<String>) -> Self {
230 self.details = Some(details.into());
231 self
232 }
233
234 pub fn with_error_key(mut self, key: impl Into<String>) -> Self {
235 self.error_key = Some(key.into());
236 self
237 }
238
239 pub fn with_path(mut self, path: impl Into<String>) -> Self {
240 self.path = Some(path.into());
241 self
242 }
243
244 pub fn with_validation_errors(mut self, errors: Vec<ValidationError>) -> Self {
245 self.validation_errors = errors;
246 self
247 }
248
249 pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
250 self.trace_id = Some(id.into());
251 self
252 }
253
254 pub fn not_found(message: impl Into<String>) -> Self {
255 Self::new(ErrorCode::NotFound, message)
256 }
257
258 pub fn bad_request(message: impl Into<String>) -> Self {
259 Self::new(ErrorCode::BadRequest, message)
260 }
261
262 pub fn unauthorized(message: impl Into<String>) -> Self {
263 Self::new(ErrorCode::Unauthorized, message)
264 }
265
266 pub fn forbidden(message: impl Into<String>) -> Self {
267 Self::new(ErrorCode::Forbidden, message)
268 }
269
270 pub fn internal_error(message: impl Into<String>) -> Self {
271 Self::new(ErrorCode::InternalError, message)
272 }
273
274 pub fn validation_error(message: impl Into<String>, errors: Vec<ValidationError>) -> Self {
275 Self::new(ErrorCode::ValidationError, message).with_validation_errors(errors)
276 }
277
278 pub fn conflict(message: impl Into<String>) -> Self {
279 Self::new(ErrorCode::ConflictError, message)
280 }
281}
282
283#[derive(Debug, Serialize, Deserialize)]
284pub struct ErrorResponse {
285 pub error: ApiError,
286
287 pub api_version: String,
288}
289
290#[cfg(feature = "web")]
291impl ErrorCode {
292 pub const fn status_code(&self) -> StatusCode {
293 match self {
294 Self::NotFound => StatusCode::NOT_FOUND,
295 Self::BadRequest => StatusCode::BAD_REQUEST,
296 Self::Unauthorized => StatusCode::UNAUTHORIZED,
297 Self::Forbidden => StatusCode::FORBIDDEN,
298 Self::ValidationError => StatusCode::UNPROCESSABLE_ENTITY,
299 Self::ConflictError => StatusCode::CONFLICT,
300 Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
301 Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
302 Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
303 }
304 }
305}
306
307#[cfg(feature = "web")]
308impl IntoResponse for ApiError {
309 fn into_response(self) -> axum::response::Response {
310 let status = self.code.status_code();
311
312 if status.is_server_error() {
313 tracing::error!(
314 error_code = ?self.code,
315 message = %self.message,
316 path = ?self.path,
317 trace_id = ?self.trace_id,
318 "API server error response"
319 );
320 } else if status.is_client_error() {
321 tracing::warn!(
322 error_code = ?self.code,
323 message = %self.message,
324 path = ?self.path,
325 trace_id = ?self.trace_id,
326 "API client error response"
327 );
328 }
329
330 (status, Json(self)).into_response()
331 }
332}
333
334#[cfg(feature = "web")]
335impl IntoResponse for InternalApiError {
336 fn into_response(self) -> axum::response::Response {
337 let error: ApiError = self.into();
338 error.into_response()
339 }
340}