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