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 } => {
154 Some(format!("Database error: {message}"))
155 },
156 InternalApiError::JsonError(e) => Some(format!("JSON processing error: {e}")),
157 InternalApiError::AuthenticationError { message } => {
158 Some(format!("Authentication error: {message}"))
159 },
160 InternalApiError::BadRequest { .. }
161 | InternalApiError::Unauthorized { .. }
162 | InternalApiError::ConflictError { .. }
163 | InternalApiError::RateLimited { .. }
164 | InternalApiError::ServiceUnavailable { .. }
165 | InternalApiError::InternalError { .. } => None,
166 };
167
168 Self::new(code, message).with_details(details.unwrap_or_default())
169 }
170}
171
172#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ErrorCode {
175 NotFound,
176 BadRequest,
177 Unauthorized,
178 Forbidden,
179 InternalError,
180 ValidationError,
181 ConflictError,
182 RateLimited,
183 ServiceUnavailable,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ValidationError {
188 pub field: String,
189
190 pub message: String,
191
192 pub code: String,
193
194 pub context: Option<Value>,
195}
196
197#[derive(Debug, Serialize, Deserialize)]
198pub struct ApiError {
199 pub code: ErrorCode,
200
201 pub message: String,
202
203 pub details: Option<String>,
204
205 pub error_key: Option<String>,
206
207 pub path: Option<String>,
208
209 #[serde(default)]
210 pub validation_errors: Vec<ValidationError>,
211
212 pub timestamp: DateTime<Utc>,
213
214 pub trace_id: Option<String>,
215}
216
217impl ApiError {
218 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
219 Self {
220 code,
221 message: message.into(),
222 details: None,
223 error_key: None,
224 path: None,
225 validation_errors: Vec::new(),
226 timestamp: Utc::now(),
227 trace_id: None,
228 }
229 }
230
231 pub fn with_details(mut self, details: impl Into<String>) -> Self {
232 self.details = Some(details.into());
233 self
234 }
235
236 pub fn with_error_key(mut self, key: impl Into<String>) -> Self {
237 self.error_key = Some(key.into());
238 self
239 }
240
241 pub fn with_path(mut self, path: impl Into<String>) -> Self {
242 self.path = Some(path.into());
243 self
244 }
245
246 pub fn with_validation_errors(mut self, errors: Vec<ValidationError>) -> Self {
247 self.validation_errors = errors;
248 self
249 }
250
251 pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
252 self.trace_id = Some(id.into());
253 self
254 }
255
256 pub fn not_found(message: impl Into<String>) -> Self {
257 Self::new(ErrorCode::NotFound, message)
258 }
259
260 pub fn bad_request(message: impl Into<String>) -> Self {
261 Self::new(ErrorCode::BadRequest, message)
262 }
263
264 pub fn unauthorized(message: impl Into<String>) -> Self {
265 Self::new(ErrorCode::Unauthorized, message)
266 }
267
268 pub fn forbidden(message: impl Into<String>) -> Self {
269 Self::new(ErrorCode::Forbidden, message)
270 }
271
272 pub fn internal_error(message: impl Into<String>) -> Self {
273 Self::new(ErrorCode::InternalError, message)
274 }
275
276 pub fn validation_error(message: impl Into<String>, errors: Vec<ValidationError>) -> Self {
277 Self::new(ErrorCode::ValidationError, message).with_validation_errors(errors)
278 }
279
280 pub fn conflict(message: impl Into<String>) -> Self {
281 Self::new(ErrorCode::ConflictError, message)
282 }
283}
284
285#[derive(Debug, Serialize, Deserialize)]
286pub struct ErrorResponse {
287 pub error: ApiError,
288
289 pub api_version: String,
290}
291
292#[cfg(feature = "web")]
293impl ErrorCode {
294 pub const fn status_code(&self) -> StatusCode {
295 match self {
296 Self::NotFound => StatusCode::NOT_FOUND,
297 Self::BadRequest => StatusCode::BAD_REQUEST,
298 Self::Unauthorized => StatusCode::UNAUTHORIZED,
299 Self::Forbidden => StatusCode::FORBIDDEN,
300 Self::ValidationError => StatusCode::UNPROCESSABLE_ENTITY,
301 Self::ConflictError => StatusCode::CONFLICT,
302 Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
303 Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
304 Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
305 }
306 }
307}
308
309#[cfg(feature = "web")]
310impl IntoResponse for ApiError {
311 fn into_response(self) -> axum::response::Response {
312 let status = self.code.status_code();
313
314 if status.is_server_error() {
315 tracing::error!(
316 error_code = ?self.code,
317 message = %self.message,
318 path = ?self.path,
319 trace_id = ?self.trace_id,
320 "API server error response"
321 );
322 } else if status.is_client_error() {
323 tracing::warn!(
324 error_code = ?self.code,
325 message = %self.message,
326 path = ?self.path,
327 trace_id = ?self.trace_id,
328 "API client error response"
329 );
330 }
331
332 (status, Json(self)).into_response()
333 }
334}
335
336#[cfg(feature = "web")]
337impl IntoResponse for InternalApiError {
338 fn into_response(self) -> axum::response::Response {
339 let error: ApiError = self.into();
340 error.into_response()
341 }
342}