1use axum::{
2 http::{header::RETRY_AFTER, HeaderName, HeaderValue, StatusCode},
3 Json,
4};
5use serde::Serialize;
6use serde_json::Value;
7use std::fmt;
8
9#[derive(Debug, Clone, Serialize)]
32#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
33pub struct ApiError {
34 pub code: String,
36 pub message: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
41 pub details: Option<Value>,
42}
43
44impl ApiError {
45 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
50 Self {
51 code: code.into(),
52 message: message.into(),
53 details: None,
54 }
55 }
56
57 pub fn with_details(mut self, details: Value) -> Self {
59 self.details = Some(details);
60 self
61 }
62
63 pub fn bad_request(
68 code: impl Into<String>,
69 message: impl Into<String>,
70 ) -> (StatusCode, Json<Self>) {
71 (StatusCode::BAD_REQUEST, Json(Self::new(code, message)))
72 }
73
74 pub fn unauthorized(message: impl Into<String>) -> (StatusCode, Json<Self>) {
76 (
77 StatusCode::UNAUTHORIZED,
78 Json(Self::new("AUTH_REQUIRED", message)),
79 )
80 }
81
82 pub fn forbidden(message: impl Into<String>) -> (StatusCode, Json<Self>) {
84 (StatusCode::FORBIDDEN, Json(Self::new("FORBIDDEN", message)))
85 }
86
87 pub fn not_found(message: impl Into<String>) -> (StatusCode, Json<Self>) {
89 (StatusCode::NOT_FOUND, Json(Self::new("NOT_FOUND", message)))
90 }
91
92 pub fn conflict(message: impl Into<String>) -> (StatusCode, Json<Self>) {
94 (StatusCode::CONFLICT, Json(Self::new("CONFLICT", message)))
95 }
96
97 pub fn unprocessable_entity(message: impl Into<String>) -> (StatusCode, Json<Self>) {
99 (
100 StatusCode::UNPROCESSABLE_ENTITY,
101 Json(Self::new("VALIDATION_ERROR", message)),
102 )
103 }
104
105 pub fn internal(message: impl Into<String>) -> (StatusCode, Json<Self>) {
107 (
108 StatusCode::INTERNAL_SERVER_ERROR,
109 Json(Self::new("INTERNAL_ERROR", message)),
110 )
111 }
112
113 pub fn db_error() -> (StatusCode, Json<Self>) {
115 (
116 StatusCode::INTERNAL_SERVER_ERROR,
117 Json(Self::new("DB_ERROR", "database error")),
118 )
119 }
120
121 pub fn too_many_requests(message: impl Into<String>) -> (StatusCode, Json<Self>) {
123 (
124 StatusCode::TOO_MANY_REQUESTS,
125 Json(Self::new("RATE_LIMITED", message)),
126 )
127 }
128
129 pub fn too_many_requests_with_retry_after(
148 message: impl Into<String>,
149 retry_after: std::time::Duration,
150 ) -> (StatusCode, [(HeaderName, HeaderValue); 1], Json<Self>) {
151 (
152 StatusCode::TOO_MANY_REQUESTS,
153 [(RETRY_AFTER, HeaderValue::from(ceil_secs(retry_after)))],
154 Json(Self::new("RATE_LIMITED", message)),
155 )
156 }
157
158 pub fn service_unavailable(message: impl Into<String>) -> (StatusCode, Json<Self>) {
160 (
161 StatusCode::SERVICE_UNAVAILABLE,
162 Json(Self::new("SERVICE_UNAVAILABLE", message)),
163 )
164 }
165
166 pub fn service_unavailable_with_retry_after(
188 message: impl Into<String>,
189 retry_after: std::time::Duration,
190 ) -> (StatusCode, [(HeaderName, HeaderValue); 1], Json<Self>) {
191 (
192 StatusCode::SERVICE_UNAVAILABLE,
193 [(RETRY_AFTER, HeaderValue::from(ceil_secs(retry_after)))],
194 Json(Self::new("SERVICE_UNAVAILABLE", message)),
195 )
196 }
197
198 pub fn not_implemented(message: impl Into<String>) -> (StatusCode, Json<Self>) {
200 (
201 StatusCode::NOT_IMPLEMENTED,
202 Json(Self::new("NOT_IMPLEMENTED", message)),
203 )
204 }
205
206 pub fn with_source(mut self, source: &str) -> Self {
221 let mut details = self.details.take().unwrap_or_else(|| serde_json::json!({}));
222 if let serde_json::Value::Object(ref mut map) = details {
223 map.insert(
224 "source".to_string(),
225 serde_json::Value::String(source.to_string()),
226 );
227 }
228 self.details = Some(details);
229 self
230 }
231
232 #[cfg(feature = "problem")]
260 pub fn into_problem(self, status: StatusCode) -> crate::Problem {
261 crate::Problem::from((status, self))
262 }
263}
264
265pub(crate) fn ceil_secs(d: std::time::Duration) -> u64 {
271 d.as_secs() + u64::from(d.subsec_nanos() > 0)
272}
273
274impl From<std::io::Error> for ApiError {
286 fn from(err: std::io::Error) -> Self {
287 Self::new("IO_ERROR", format!("IO error: {}", err))
288 }
289}
290
291impl From<serde_json::Error> for ApiError {
295 fn from(err: serde_json::Error) -> Self {
296 Self::new("JSON_ERROR", format!("JSON error: {}", err))
297 }
298}
299
300#[cfg(feature = "sqlx")]
313impl From<sqlx::Error> for ApiError {
314 fn from(err: sqlx::Error) -> Self {
315 match err {
316 sqlx::Error::RowNotFound => Self::new("NOT_FOUND", "record not found"),
317 sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::WorkerCrashed => {
318 Self::new("SERVICE_UNAVAILABLE", "database unavailable")
319 }
320 sqlx::Error::Database(db_err) => {
321 if db_err.is_unique_violation() || db_err.is_foreign_key_violation() {
322 Self::new("CONFLICT", db_err.message().to_string())
323 } else if db_err.is_check_violation() {
324 Self::new("VALIDATION_ERROR", db_err.message().to_string())
325 } else {
326 Self::new("DB_ERROR", db_err.message().to_string())
327 }
328 }
329 _ => Self::new("DB_ERROR", format!("database error: {}", err)),
330 }
331 }
332}
333
334#[cfg(feature = "validator")]
335fn collect_validation_errors(
336 prefix: Option<&str>,
337 errors: &validator::ValidationErrors,
338 out: &mut serde_json::Map<String, serde_json::Value>,
339) {
340 use validator::ValidationErrorsKind;
341
342 for (field, kind) in errors.errors() {
343 let base = if let Some(prefix) = prefix {
344 format!("{}.{}", prefix, field)
345 } else {
346 field.to_string()
347 };
348
349 match kind {
350 ValidationErrorsKind::Field(field_errors) => {
351 let items = field_errors
352 .iter()
353 .map(|err| {
354 let mut obj = serde_json::Map::new();
355 obj.insert(
356 "code".to_string(),
357 serde_json::Value::String(err.code.to_string()),
358 );
359 if let Some(message) = &err.message {
360 obj.insert(
361 "message".to_string(),
362 serde_json::Value::String(message.to_string()),
363 );
364 }
365 if !err.params.is_empty() {
366 let params = match serde_json::to_value(&err.params) {
367 Ok(v) => v,
368 Err(_) => serde_json::Value::Null,
369 };
370 obj.insert("params".to_string(), params);
371 }
372 serde_json::Value::Object(obj)
373 })
374 .collect::<Vec<_>>();
375 out.insert(base, serde_json::Value::Array(items));
376 }
377 ValidationErrorsKind::Struct(nested) => {
378 collect_validation_errors(Some(&base), nested, out);
379 }
380 ValidationErrorsKind::List(items) => {
381 for (index, nested) in items {
382 let indexed = format!("{}[{}]", base, index);
383 collect_validation_errors(Some(&indexed), nested, out);
384 }
385 }
386 }
387 }
388}
389
390#[cfg(feature = "validator")]
391impl From<validator::ValidationErrors> for ApiError {
392 fn from(errors: validator::ValidationErrors) -> Self {
393 let mut fields = serde_json::Map::new();
394 collect_validation_errors(None, &errors, &mut fields);
395
396 Self::new("VALIDATION_ERROR", "validation failed").with_details(serde_json::json!({
397 "fields": fields
398 }))
399 }
400}
401
402#[cfg(any(feature = "validator", feature = "extract"))]
409pub(crate) fn json_rejection_to_api_error(
410 rejection: axum::extract::rejection::JsonRejection,
411) -> (StatusCode, Json<ApiError>) {
412 use axum::extract::rejection::JsonRejection;
413 let code = match &rejection {
414 JsonRejection::JsonSyntaxError(_) => "INVALID_JSON",
415 JsonRejection::JsonDataError(_) => "INVALID_BODY",
416 JsonRejection::MissingJsonContentType(_) => "UNSUPPORTED_MEDIA_TYPE",
417 _ => "BAD_REQUEST",
418 };
419 (
420 rejection.status(),
421 Json(ApiError::new(code, rejection.body_text())),
422 )
423}
424
425impl fmt::Display for ApiError {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 write!(f, "{}: {}", self.code, self.message)
428 }
429}
430
431impl std::error::Error for ApiError {}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use serde_json::json;
437
438 #[test]
439 fn new_sets_fields() {
440 let err = ApiError::new("MY_CODE", "my message");
441 assert_eq!(err.code, "MY_CODE");
442 assert_eq!(err.message, "my message");
443 assert!(err.details.is_none());
444 }
445
446 #[test]
447 fn with_details_sets_details() {
448 let err = ApiError::new("CODE", "msg").with_details(json!({ "field": "name" }));
449 assert_eq!(err.details.unwrap()["field"], "name");
450 }
451
452 #[test]
453 fn serializes_without_details() {
454 let err = ApiError::new("NOT_FOUND", "item not found");
455 let v = serde_json::to_value(&err).unwrap();
456 assert_eq!(v["code"], "NOT_FOUND");
457 assert_eq!(v["message"], "item not found");
458 assert!(v.get("details").is_none());
459 }
460
461 #[test]
462 fn serializes_with_details() {
463 let err = ApiError::new("VALIDATION_ERROR", "invalid").with_details(json!({ "x": 1 }));
464 let v = serde_json::to_value(&err).unwrap();
465 assert_eq!(v["details"]["x"], 1);
466 }
467
468 #[test]
469 fn display_formats_code_and_message() {
470 let err = ApiError::new("NOT_FOUND", "item not found");
471 assert_eq!(err.to_string(), "NOT_FOUND: item not found");
472 }
473
474 #[test]
475 fn implements_std_error() {
476 let err = ApiError::new("ERR", "something failed");
477 let _: &dyn std::error::Error = &err;
478 }
479
480 macro_rules! assert_factory {
481 ($method:expr, $expected_status:expr, $expected_code:expr) => {{
482 let (status, Json(body)) = $method;
483 assert_eq!(status, $expected_status);
484 assert_eq!(body.code, $expected_code);
485 }};
486 }
487
488 #[test]
489 fn bad_request_status_and_code() {
490 assert_factory!(
491 ApiError::bad_request("INVALID_FIELD", "bad"),
492 StatusCode::BAD_REQUEST,
493 "INVALID_FIELD"
494 );
495 }
496
497 #[test]
498 fn unauthorized_status_and_code() {
499 assert_factory!(
500 ApiError::unauthorized("please log in"),
501 StatusCode::UNAUTHORIZED,
502 "AUTH_REQUIRED"
503 );
504 }
505
506 #[test]
507 fn forbidden_status_and_code() {
508 assert_factory!(
509 ApiError::forbidden("no access"),
510 StatusCode::FORBIDDEN,
511 "FORBIDDEN"
512 );
513 }
514
515 #[test]
516 fn not_found_status_and_code() {
517 assert_factory!(
518 ApiError::not_found("missing"),
519 StatusCode::NOT_FOUND,
520 "NOT_FOUND"
521 );
522 }
523
524 #[test]
525 fn conflict_status_and_code() {
526 assert_factory!(
527 ApiError::conflict("already exists"),
528 StatusCode::CONFLICT,
529 "CONFLICT"
530 );
531 }
532
533 #[test]
534 fn unprocessable_entity_status_and_code() {
535 assert_factory!(
536 ApiError::unprocessable_entity("invalid input"),
537 StatusCode::UNPROCESSABLE_ENTITY,
538 "VALIDATION_ERROR"
539 );
540 }
541
542 #[test]
543 fn internal_status_and_code() {
544 assert_factory!(
545 ApiError::internal("oops"),
546 StatusCode::INTERNAL_SERVER_ERROR,
547 "INTERNAL_ERROR"
548 );
549 }
550
551 #[test]
552 fn db_error_status_and_code() {
553 assert_factory!(
554 ApiError::db_error(),
555 StatusCode::INTERNAL_SERVER_ERROR,
556 "DB_ERROR"
557 );
558 }
559
560 #[test]
561 fn too_many_requests_status_and_code() {
562 assert_factory!(
563 ApiError::too_many_requests("slow down"),
564 StatusCode::TOO_MANY_REQUESTS,
565 "RATE_LIMITED"
566 );
567 }
568
569 #[test]
570 fn service_unavailable_status_and_code() {
571 assert_factory!(
572 ApiError::service_unavailable("down for maintenance"),
573 StatusCode::SERVICE_UNAVAILABLE,
574 "SERVICE_UNAVAILABLE"
575 );
576 }
577
578 #[test]
579 fn not_implemented_status_and_code() {
580 assert_factory!(
581 ApiError::not_implemented("coming soon"),
582 StatusCode::NOT_IMPLEMENTED,
583 "NOT_IMPLEMENTED"
584 );
585 }
586
587 #[test]
588 fn ceil_secs_rounds_up_to_whole_seconds() {
589 use std::time::Duration;
590 assert_eq!(ceil_secs(Duration::from_secs(0)), 0);
591 assert_eq!(ceil_secs(Duration::from_secs(2)), 2);
592 assert_eq!(ceil_secs(Duration::from_millis(1500)), 2);
593 }
594
595 #[test]
596 fn too_many_requests_with_retry_after_status_header_and_body() {
597 let (status, [(name, value)], Json(body)) = ApiError::too_many_requests_with_retry_after(
598 "slow down",
599 std::time::Duration::from_secs(30),
600 );
601 assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
602 assert_eq!(name, RETRY_AFTER);
603 assert_eq!(value, "30");
604 assert_eq!(
605 serde_json::to_value(&body).unwrap(),
606 json!({ "code": "RATE_LIMITED", "message": "slow down" })
607 );
608 }
609
610 #[test]
611 fn service_unavailable_with_retry_after_status_header_and_body() {
612 let (status, [(name, value)], Json(body)) = ApiError::service_unavailable_with_retry_after(
613 "down for maintenance",
614 std::time::Duration::from_secs(30),
615 );
616 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
617 assert_eq!(name, RETRY_AFTER);
618 assert_eq!(value, "30");
619 assert_eq!(
620 serde_json::to_value(&body).unwrap(),
621 json!({ "code": "SERVICE_UNAVAILABLE", "message": "down for maintenance" })
622 );
623 }
624
625 #[cfg(feature = "problem")]
626 #[test]
627 fn into_problem_reproduces_reference_wire_shape() {
628 let problem = ApiError::new("NOT_FOUND", "item 42 does not exist")
629 .with_details(json!({ "id": 42 }))
630 .into_problem(StatusCode::NOT_FOUND);
631 assert_eq!(
632 serde_json::to_value(&problem).unwrap(),
633 json!({
634 "title": "Not Found",
635 "status": 404,
636 "detail": "item 42 does not exist",
637 "code": "NOT_FOUND",
638 "details": { "id": 42 }
639 })
640 );
641 }
642
643 #[test]
644 fn with_source_adds_source_to_details() {
645 let err = ApiError::new("NOT_FOUND", "missing").with_source("db query");
646 let v = serde_json::to_value(&err).unwrap();
647 assert_eq!(v["details"]["source"], "db query");
648 assert_eq!(v["code"], "NOT_FOUND");
649 }
650
651 #[test]
652 fn with_source_and_with_details_both_present() {
653 let err = ApiError::new("ERROR", "msg")
654 .with_details(json!({ "user_id": 123 }))
655 .with_source("from somewhere");
656 let v = serde_json::to_value(&err).unwrap();
657 assert_eq!(v["details"]["source"], "from somewhere");
658 assert_eq!(v["details"]["user_id"], 123);
659 }
660
661 #[test]
662 fn from_io_error_creates_io_error_code() {
663 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
664 let api_err: ApiError = io_err.into();
665 assert_eq!(api_err.code, "IO_ERROR");
666 assert!(api_err.message.contains("IO error"));
667 }
668
669 #[test]
670 fn from_serde_json_error_creates_json_error_code() {
671 let json_str = "{ invalid json }";
672 let json_err: Result<serde_json::Value, _> = serde_json::from_str(json_str);
673 let api_err: ApiError = json_err.unwrap_err().into();
674 assert_eq!(api_err.code, "JSON_ERROR");
675 assert!(api_err.message.contains("JSON error"));
676 }
677
678 #[test]
679 fn io_error_conversion_captures_kind() {
680 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
681 let api_err: ApiError = io_err.into();
682 assert!(api_err.message.contains("permission denied"));
683 }
684
685 #[cfg(feature = "validator")]
686 #[test]
687 fn from_validation_errors_single_field() {
688 use std::borrow::Cow;
689 use validator::{ValidationError, ValidationErrors};
690
691 let mut errors = ValidationErrors::new();
692 let mut email = ValidationError::new("email");
693 email.message = Some(Cow::Borrowed("invalid email"));
694 errors.add("email", email);
695
696 let api_err: ApiError = errors.into();
697 let v = serde_json::to_value(api_err).unwrap();
698
699 assert_eq!(v["code"], "VALIDATION_ERROR");
700 assert_eq!(v["message"], "validation failed");
701 assert_eq!(v["details"]["fields"]["email"][0]["code"], "email");
702 assert_eq!(
703 v["details"]["fields"]["email"][0]["message"],
704 "invalid email"
705 );
706 }
707
708 #[cfg(feature = "validator")]
709 #[test]
710 fn from_validation_errors_multiple_fields_with_params() {
711 use std::borrow::Cow;
712 use validator::{ValidationError, ValidationErrors};
713
714 let mut errors = ValidationErrors::new();
715
716 let mut username = ValidationError::new("length");
717 username.message = Some(Cow::Borrowed("username too short"));
718 username.add_param(Cow::Borrowed("min"), &3);
719 errors.add("username", username);
720
721 let mut age = ValidationError::new("range");
722 age.add_param(Cow::Borrowed("min"), &18);
723 errors.add("age", age);
724
725 let api_err: ApiError = errors.into();
726 let v = serde_json::to_value(api_err).unwrap();
727
728 assert_eq!(v["details"]["fields"]["username"][0]["code"], "length");
729 assert_eq!(v["details"]["fields"]["username"][0]["params"]["min"], 3);
730 assert_eq!(v["details"]["fields"]["age"][0]["code"], "range");
731 assert_eq!(v["details"]["fields"]["age"][0]["params"]["min"], 18);
732 }
733
734 #[cfg(feature = "sqlx")]
735 #[test]
736 fn sqlx_row_not_found_maps_to_not_found() {
737 let api_err: ApiError = sqlx::Error::RowNotFound.into();
738 assert_eq!(api_err.code, "NOT_FOUND");
739 assert_eq!(api_err.message, "record not found");
740 }
741
742 #[cfg(feature = "sqlx")]
743 #[test]
744 fn sqlx_pool_timed_out_maps_to_service_unavailable() {
745 let api_err: ApiError = sqlx::Error::PoolTimedOut.into();
746 assert_eq!(api_err.code, "SERVICE_UNAVAILABLE");
747 }
748
749 #[cfg(feature = "sqlx")]
750 #[test]
751 fn sqlx_pool_closed_maps_to_service_unavailable() {
752 let api_err: ApiError = sqlx::Error::PoolClosed.into();
753 assert_eq!(api_err.code, "SERVICE_UNAVAILABLE");
754 }
755
756 #[cfg(feature = "sqlx")]
757 #[test]
758 fn sqlx_unknown_variant_maps_to_db_error() {
759 let api_err: ApiError = sqlx::Error::Protocol("unexpected packet".into()).into();
761 assert_eq!(api_err.code, "DB_ERROR");
762 assert!(api_err.message.contains("database error"));
763 }
764}