1use axum::{http::StatusCode, Json};
2use serde::Serialize;
3use serde_json::Value;
4use std::fmt;
5
6#[derive(Debug, Clone, Serialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29pub struct ApiError {
30 pub code: String,
32 pub message: String,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
37 pub details: Option<Value>,
38}
39
40impl ApiError {
41 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
46 Self {
47 code: code.into(),
48 message: message.into(),
49 details: None,
50 }
51 }
52
53 pub fn with_details(mut self, details: Value) -> Self {
55 self.details = Some(details);
56 self
57 }
58
59 pub fn bad_request(
64 code: impl Into<String>,
65 message: impl Into<String>,
66 ) -> (StatusCode, Json<Self>) {
67 (StatusCode::BAD_REQUEST, Json(Self::new(code, message)))
68 }
69
70 pub fn unauthorized(message: impl Into<String>) -> (StatusCode, Json<Self>) {
72 (
73 StatusCode::UNAUTHORIZED,
74 Json(Self::new("AUTH_REQUIRED", message)),
75 )
76 }
77
78 pub fn forbidden(message: impl Into<String>) -> (StatusCode, Json<Self>) {
80 (StatusCode::FORBIDDEN, Json(Self::new("FORBIDDEN", message)))
81 }
82
83 pub fn not_found(message: impl Into<String>) -> (StatusCode, Json<Self>) {
85 (StatusCode::NOT_FOUND, Json(Self::new("NOT_FOUND", message)))
86 }
87
88 pub fn conflict(message: impl Into<String>) -> (StatusCode, Json<Self>) {
90 (StatusCode::CONFLICT, Json(Self::new("CONFLICT", message)))
91 }
92
93 pub fn unprocessable_entity(message: impl Into<String>) -> (StatusCode, Json<Self>) {
95 (
96 StatusCode::UNPROCESSABLE_ENTITY,
97 Json(Self::new("VALIDATION_ERROR", message)),
98 )
99 }
100
101 pub fn internal(message: impl Into<String>) -> (StatusCode, Json<Self>) {
103 (
104 StatusCode::INTERNAL_SERVER_ERROR,
105 Json(Self::new("INTERNAL_ERROR", message)),
106 )
107 }
108
109 pub fn db_error() -> (StatusCode, Json<Self>) {
111 (
112 StatusCode::INTERNAL_SERVER_ERROR,
113 Json(Self::new("DB_ERROR", "database error")),
114 )
115 }
116
117 pub fn too_many_requests(message: impl Into<String>) -> (StatusCode, Json<Self>) {
119 (
120 StatusCode::TOO_MANY_REQUESTS,
121 Json(Self::new("RATE_LIMITED", message)),
122 )
123 }
124
125 pub fn service_unavailable(message: impl Into<String>) -> (StatusCode, Json<Self>) {
127 (
128 StatusCode::SERVICE_UNAVAILABLE,
129 Json(Self::new("SERVICE_UNAVAILABLE", message)),
130 )
131 }
132
133 pub fn not_implemented(message: impl Into<String>) -> (StatusCode, Json<Self>) {
135 (
136 StatusCode::NOT_IMPLEMENTED,
137 Json(Self::new("NOT_IMPLEMENTED", message)),
138 )
139 }
140
141 pub fn with_source(mut self, source: &str) -> Self {
156 let mut details = self.details.take().unwrap_or_else(|| serde_json::json!({}));
157 if let serde_json::Value::Object(ref mut map) = details {
158 map.insert(
159 "source".to_string(),
160 serde_json::Value::String(source.to_string()),
161 );
162 }
163 self.details = Some(details);
164 self
165 }
166}
167
168impl From<std::io::Error> for ApiError {
180 fn from(err: std::io::Error) -> Self {
181 Self::new("IO_ERROR", format!("IO error: {}", err))
182 }
183}
184
185impl From<serde_json::Error> for ApiError {
189 fn from(err: serde_json::Error) -> Self {
190 Self::new("JSON_ERROR", format!("JSON error: {}", err))
191 }
192}
193
194#[cfg(feature = "sqlx")]
207impl From<sqlx::Error> for ApiError {
208 fn from(err: sqlx::Error) -> Self {
209 match err {
210 sqlx::Error::RowNotFound => Self::new("NOT_FOUND", "record not found"),
211 sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::WorkerCrashed => {
212 Self::new("SERVICE_UNAVAILABLE", "database unavailable")
213 }
214 sqlx::Error::Database(db_err) => {
215 if db_err.is_unique_violation() || db_err.is_foreign_key_violation() {
216 Self::new("CONFLICT", db_err.message().to_string())
217 } else if db_err.is_check_violation() {
218 Self::new("VALIDATION_ERROR", db_err.message().to_string())
219 } else {
220 Self::new("DB_ERROR", db_err.message().to_string())
221 }
222 }
223 _ => Self::new("DB_ERROR", format!("database error: {}", err)),
224 }
225 }
226}
227
228#[cfg(feature = "validator")]
229fn collect_validation_errors(
230 prefix: Option<&str>,
231 errors: &validator::ValidationErrors,
232 out: &mut serde_json::Map<String, serde_json::Value>,
233) {
234 use validator::ValidationErrorsKind;
235
236 for (field, kind) in errors.errors() {
237 let base = if let Some(prefix) = prefix {
238 format!("{}.{}", prefix, field)
239 } else {
240 field.to_string()
241 };
242
243 match kind {
244 ValidationErrorsKind::Field(field_errors) => {
245 let items = field_errors
246 .iter()
247 .map(|err| {
248 let mut obj = serde_json::Map::new();
249 obj.insert(
250 "code".to_string(),
251 serde_json::Value::String(err.code.to_string()),
252 );
253 if let Some(message) = &err.message {
254 obj.insert(
255 "message".to_string(),
256 serde_json::Value::String(message.to_string()),
257 );
258 }
259 if !err.params.is_empty() {
260 let params = match serde_json::to_value(&err.params) {
261 Ok(v) => v,
262 Err(_) => serde_json::Value::Null,
263 };
264 obj.insert("params".to_string(), params);
265 }
266 serde_json::Value::Object(obj)
267 })
268 .collect::<Vec<_>>();
269 out.insert(base, serde_json::Value::Array(items));
270 }
271 ValidationErrorsKind::Struct(nested) => {
272 collect_validation_errors(Some(&base), nested, out);
273 }
274 ValidationErrorsKind::List(items) => {
275 for (index, nested) in items {
276 let indexed = format!("{}[{}]", base, index);
277 collect_validation_errors(Some(&indexed), nested, out);
278 }
279 }
280 }
281 }
282}
283
284#[cfg(feature = "validator")]
285impl From<validator::ValidationErrors> for ApiError {
286 fn from(errors: validator::ValidationErrors) -> Self {
287 let mut fields = serde_json::Map::new();
288 collect_validation_errors(None, &errors, &mut fields);
289
290 Self::new("VALIDATION_ERROR", "validation failed").with_details(serde_json::json!({
291 "fields": fields
292 }))
293 }
294}
295
296#[cfg(any(feature = "validator", feature = "extract"))]
303pub(crate) fn json_rejection_to_api_error(
304 rejection: axum::extract::rejection::JsonRejection,
305) -> (StatusCode, Json<ApiError>) {
306 use axum::extract::rejection::JsonRejection;
307 let code = match &rejection {
308 JsonRejection::JsonSyntaxError(_) => "INVALID_JSON",
309 JsonRejection::JsonDataError(_) => "INVALID_BODY",
310 JsonRejection::MissingJsonContentType(_) => "UNSUPPORTED_MEDIA_TYPE",
311 _ => "BAD_REQUEST",
312 };
313 (
314 rejection.status(),
315 Json(ApiError::new(code, rejection.body_text())),
316 )
317}
318
319impl fmt::Display for ApiError {
320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321 write!(f, "{}: {}", self.code, self.message)
322 }
323}
324
325impl std::error::Error for ApiError {}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use serde_json::json;
331
332 #[test]
333 fn new_sets_fields() {
334 let err = ApiError::new("MY_CODE", "my message");
335 assert_eq!(err.code, "MY_CODE");
336 assert_eq!(err.message, "my message");
337 assert!(err.details.is_none());
338 }
339
340 #[test]
341 fn with_details_sets_details() {
342 let err = ApiError::new("CODE", "msg").with_details(json!({ "field": "name" }));
343 assert_eq!(err.details.unwrap()["field"], "name");
344 }
345
346 #[test]
347 fn serializes_without_details() {
348 let err = ApiError::new("NOT_FOUND", "item not found");
349 let v = serde_json::to_value(&err).unwrap();
350 assert_eq!(v["code"], "NOT_FOUND");
351 assert_eq!(v["message"], "item not found");
352 assert!(v.get("details").is_none());
353 }
354
355 #[test]
356 fn serializes_with_details() {
357 let err = ApiError::new("VALIDATION_ERROR", "invalid").with_details(json!({ "x": 1 }));
358 let v = serde_json::to_value(&err).unwrap();
359 assert_eq!(v["details"]["x"], 1);
360 }
361
362 #[test]
363 fn display_formats_code_and_message() {
364 let err = ApiError::new("NOT_FOUND", "item not found");
365 assert_eq!(err.to_string(), "NOT_FOUND: item not found");
366 }
367
368 #[test]
369 fn implements_std_error() {
370 let err = ApiError::new("ERR", "something failed");
371 let _: &dyn std::error::Error = &err;
372 }
373
374 macro_rules! assert_factory {
375 ($method:expr, $expected_status:expr, $expected_code:expr) => {{
376 let (status, Json(body)) = $method;
377 assert_eq!(status, $expected_status);
378 assert_eq!(body.code, $expected_code);
379 }};
380 }
381
382 #[test]
383 fn bad_request_status_and_code() {
384 assert_factory!(
385 ApiError::bad_request("INVALID_FIELD", "bad"),
386 StatusCode::BAD_REQUEST,
387 "INVALID_FIELD"
388 );
389 }
390
391 #[test]
392 fn unauthorized_status_and_code() {
393 assert_factory!(
394 ApiError::unauthorized("please log in"),
395 StatusCode::UNAUTHORIZED,
396 "AUTH_REQUIRED"
397 );
398 }
399
400 #[test]
401 fn forbidden_status_and_code() {
402 assert_factory!(
403 ApiError::forbidden("no access"),
404 StatusCode::FORBIDDEN,
405 "FORBIDDEN"
406 );
407 }
408
409 #[test]
410 fn not_found_status_and_code() {
411 assert_factory!(
412 ApiError::not_found("missing"),
413 StatusCode::NOT_FOUND,
414 "NOT_FOUND"
415 );
416 }
417
418 #[test]
419 fn conflict_status_and_code() {
420 assert_factory!(
421 ApiError::conflict("already exists"),
422 StatusCode::CONFLICT,
423 "CONFLICT"
424 );
425 }
426
427 #[test]
428 fn unprocessable_entity_status_and_code() {
429 assert_factory!(
430 ApiError::unprocessable_entity("invalid input"),
431 StatusCode::UNPROCESSABLE_ENTITY,
432 "VALIDATION_ERROR"
433 );
434 }
435
436 #[test]
437 fn internal_status_and_code() {
438 assert_factory!(
439 ApiError::internal("oops"),
440 StatusCode::INTERNAL_SERVER_ERROR,
441 "INTERNAL_ERROR"
442 );
443 }
444
445 #[test]
446 fn db_error_status_and_code() {
447 assert_factory!(
448 ApiError::db_error(),
449 StatusCode::INTERNAL_SERVER_ERROR,
450 "DB_ERROR"
451 );
452 }
453
454 #[test]
455 fn too_many_requests_status_and_code() {
456 assert_factory!(
457 ApiError::too_many_requests("slow down"),
458 StatusCode::TOO_MANY_REQUESTS,
459 "RATE_LIMITED"
460 );
461 }
462
463 #[test]
464 fn service_unavailable_status_and_code() {
465 assert_factory!(
466 ApiError::service_unavailable("down for maintenance"),
467 StatusCode::SERVICE_UNAVAILABLE,
468 "SERVICE_UNAVAILABLE"
469 );
470 }
471
472 #[test]
473 fn not_implemented_status_and_code() {
474 assert_factory!(
475 ApiError::not_implemented("coming soon"),
476 StatusCode::NOT_IMPLEMENTED,
477 "NOT_IMPLEMENTED"
478 );
479 }
480
481 #[test]
482 fn with_source_adds_source_to_details() {
483 let err = ApiError::new("NOT_FOUND", "missing").with_source("db query");
484 let v = serde_json::to_value(&err).unwrap();
485 assert_eq!(v["details"]["source"], "db query");
486 assert_eq!(v["code"], "NOT_FOUND");
487 }
488
489 #[test]
490 fn with_source_and_with_details_both_present() {
491 let err = ApiError::new("ERROR", "msg")
492 .with_details(json!({ "user_id": 123 }))
493 .with_source("from somewhere");
494 let v = serde_json::to_value(&err).unwrap();
495 assert_eq!(v["details"]["source"], "from somewhere");
496 assert_eq!(v["details"]["user_id"], 123);
497 }
498
499 #[test]
500 fn from_io_error_creates_io_error_code() {
501 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
502 let api_err: ApiError = io_err.into();
503 assert_eq!(api_err.code, "IO_ERROR");
504 assert!(api_err.message.contains("IO error"));
505 }
506
507 #[test]
508 fn from_serde_json_error_creates_json_error_code() {
509 let json_str = "{ invalid json }";
510 let json_err: Result<serde_json::Value, _> = serde_json::from_str(json_str);
511 let api_err: ApiError = json_err.unwrap_err().into();
512 assert_eq!(api_err.code, "JSON_ERROR");
513 assert!(api_err.message.contains("JSON error"));
514 }
515
516 #[test]
517 fn io_error_conversion_captures_kind() {
518 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
519 let api_err: ApiError = io_err.into();
520 assert!(api_err.message.contains("permission denied"));
521 }
522
523 #[cfg(feature = "validator")]
524 #[test]
525 fn from_validation_errors_single_field() {
526 use std::borrow::Cow;
527 use validator::{ValidationError, ValidationErrors};
528
529 let mut errors = ValidationErrors::new();
530 let mut email = ValidationError::new("email");
531 email.message = Some(Cow::Borrowed("invalid email"));
532 errors.add("email", email);
533
534 let api_err: ApiError = errors.into();
535 let v = serde_json::to_value(api_err).unwrap();
536
537 assert_eq!(v["code"], "VALIDATION_ERROR");
538 assert_eq!(v["message"], "validation failed");
539 assert_eq!(v["details"]["fields"]["email"][0]["code"], "email");
540 assert_eq!(
541 v["details"]["fields"]["email"][0]["message"],
542 "invalid email"
543 );
544 }
545
546 #[cfg(feature = "validator")]
547 #[test]
548 fn from_validation_errors_multiple_fields_with_params() {
549 use std::borrow::Cow;
550 use validator::{ValidationError, ValidationErrors};
551
552 let mut errors = ValidationErrors::new();
553
554 let mut username = ValidationError::new("length");
555 username.message = Some(Cow::Borrowed("username too short"));
556 username.add_param(Cow::Borrowed("min"), &3);
557 errors.add("username", username);
558
559 let mut age = ValidationError::new("range");
560 age.add_param(Cow::Borrowed("min"), &18);
561 errors.add("age", age);
562
563 let api_err: ApiError = errors.into();
564 let v = serde_json::to_value(api_err).unwrap();
565
566 assert_eq!(v["details"]["fields"]["username"][0]["code"], "length");
567 assert_eq!(v["details"]["fields"]["username"][0]["params"]["min"], 3);
568 assert_eq!(v["details"]["fields"]["age"][0]["code"], "range");
569 assert_eq!(v["details"]["fields"]["age"][0]["params"]["min"], 18);
570 }
571
572 #[cfg(feature = "sqlx")]
573 #[test]
574 fn sqlx_row_not_found_maps_to_not_found() {
575 let api_err: ApiError = sqlx::Error::RowNotFound.into();
576 assert_eq!(api_err.code, "NOT_FOUND");
577 assert_eq!(api_err.message, "record not found");
578 }
579
580 #[cfg(feature = "sqlx")]
581 #[test]
582 fn sqlx_pool_timed_out_maps_to_service_unavailable() {
583 let api_err: ApiError = sqlx::Error::PoolTimedOut.into();
584 assert_eq!(api_err.code, "SERVICE_UNAVAILABLE");
585 }
586
587 #[cfg(feature = "sqlx")]
588 #[test]
589 fn sqlx_pool_closed_maps_to_service_unavailable() {
590 let api_err: ApiError = sqlx::Error::PoolClosed.into();
591 assert_eq!(api_err.code, "SERVICE_UNAVAILABLE");
592 }
593
594 #[cfg(feature = "sqlx")]
595 #[test]
596 fn sqlx_unknown_variant_maps_to_db_error() {
597 let api_err: ApiError = sqlx::Error::Protocol("unexpected packet".into()).into();
599 assert_eq!(api_err.code, "DB_ERROR");
600 assert!(api_err.message.contains("database error"));
601 }
602}