1use axum::http::{HeaderValue, StatusCode, header};
46use axum::response::{IntoResponse, Response};
47use serde::Serialize;
48
49#[derive(Debug)]
54struct StringError(String);
55
56impl std::fmt::Display for StringError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.write_str(&self.0)
59 }
60}
61
62impl std::error::Error for StringError {}
63
64#[derive(Clone, Debug, Serialize)]
66pub struct ProblemDetails {
67 #[serde(rename = "type")]
70 pub type_uri: String,
71 pub title: String,
73 pub status: u16,
75 pub detail: String,
77 pub instance: Option<String>,
79 pub code: String,
81 pub request_id: Option<String>,
83 pub errors: Vec<ProblemFieldError>,
85}
86
87#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
89pub struct ProblemFieldError {
90 pub field: String,
92 pub messages: Vec<String>,
94}
95
96pub struct AutumnError {
136 inner: Box<dyn std::error::Error + Send + Sync>,
137 status: StatusCode,
138 details: Option<std::collections::HashMap<String, Vec<String>>>,
139 problem_type: Option<&'static str>,
140}
141
142pub type AutumnResult<T> = Result<T, AutumnError>;
158
159impl<E> From<E> for AutumnError
160where
161 E: std::error::Error + Send + Sync + 'static,
162{
163 fn from(err: E) -> Self {
164 Self {
165 inner: Box::new(err),
166 status: StatusCode::INTERNAL_SERVER_ERROR,
167 details: None,
168 problem_type: None,
169 }
170 }
171}
172
173impl AutumnError {
174 #[must_use]
187 pub const fn with_status(mut self, status: StatusCode) -> Self {
188 self.status = status;
189 self
190 }
191
192 pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
204 Self {
205 inner: Box::new(err),
206 status: StatusCode::INTERNAL_SERVER_ERROR,
207 details: None,
208 problem_type: None,
209 }
210 }
211
212 pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
224 Self {
225 inner: Box::new(err),
226 status: StatusCode::NOT_FOUND,
227 details: None,
228 problem_type: None,
229 }
230 }
231
232 pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
244 Self {
245 inner: Box::new(err),
246 status: StatusCode::BAD_REQUEST,
247 details: None,
248 problem_type: None,
249 }
250 }
251
252 pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
267 Self {
268 inner: Box::new(err),
269 status: StatusCode::UNPROCESSABLE_ENTITY,
270 details: None,
271 problem_type: None,
272 }
273 }
274
275 pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
287 Self {
288 inner: Box::new(err),
289 status: StatusCode::SERVICE_UNAVAILABLE,
290 details: None,
291 problem_type: None,
292 }
293 }
294
295 pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
307 Self {
308 inner: Box::new(err),
309 status: StatusCode::UNAUTHORIZED,
310 details: None,
311 problem_type: None,
312 }
313 }
314
315 pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
327 Self {
328 inner: Box::new(err),
329 status: StatusCode::FORBIDDEN,
330 details: None,
331 problem_type: None,
332 }
333 }
334
335 #[must_use]
357 pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
358 Self {
359 inner: Box::new(StringError("Validation failed".into())),
360 status: StatusCode::UNPROCESSABLE_ENTITY,
361 details: Some(details),
362 problem_type: None,
363 }
364 }
365
366 pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
380 Self::internal_server_error(StringError(msg.into()))
381 }
382
383 pub fn not_found_msg(msg: impl Into<String>) -> Self {
396 Self::not_found(StringError(msg.into()))
397 }
398
399 pub fn bad_request_msg(msg: impl Into<String>) -> Self {
411 Self::bad_request(StringError(msg.into()))
412 }
413
414 pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
426 Self::unprocessable(StringError(msg.into()))
427 }
428
429 pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
441 Self::unauthorized(StringError(msg.into()))
442 }
443
444 pub fn forbidden_msg(msg: impl Into<String>) -> Self {
456 Self::forbidden(StringError(msg.into()))
457 }
458
459 pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
471 Self::service_unavailable(StringError(msg.into()))
472 }
473
474 pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
489 Self {
490 inner: Box::new(err),
491 status: StatusCode::CONFLICT,
492 details: None,
493 problem_type: Some("https://autumn.dev/problems/conflict"),
494 }
495 }
496
497 pub fn conflict_msg(msg: impl Into<String>) -> Self {
509 Self::conflict(StringError(msg.into()))
510 }
511
512 #[must_use]
524 pub const fn status(&self) -> StatusCode {
525 self.status
526 }
527
528 #[must_use]
533 pub fn source_chain(&self) -> Vec<String> {
534 let mut chain = Vec::new();
535 let mut source = self.inner.source();
536 while let Some(error) = source {
537 chain.push(error.to_string());
538 source = error.source();
539 }
540 chain
541 }
542}
543
544impl std::fmt::Display for AutumnError {
545 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546 write!(f, "{}", self.inner)
547 }
548}
549
550impl std::fmt::Debug for AutumnError {
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 f.debug_struct("AutumnError")
553 .field("status", &self.status)
554 .field("inner", &self.inner)
555 .field("details", &self.details)
556 .field("problem_type", &self.problem_type)
557 .finish()
558 }
559}
560
561impl ProblemDetails {
562 #[must_use]
564 pub fn new(
565 status: StatusCode,
566 detail: impl Into<String>,
567 details: Option<&std::collections::HashMap<String, Vec<String>>>,
568 ) -> Self {
569 problem_details(status, detail.into(), details, None, None, None, true)
570 }
571}
572
573#[must_use]
575pub(crate) fn problem_details(
576 status: StatusCode,
577 detail: String,
578 details: Option<&std::collections::HashMap<String, Vec<String>>>,
579 explicit_type: Option<&'static str>,
580 request_id: Option<String>,
581 instance: Option<String>,
582 expose_internal_detail: bool,
583) -> ProblemDetails {
584 let has_validation_errors = details.is_some_and(|map| !map.is_empty());
585 let safe_detail = if status.is_server_error() && !expose_internal_detail {
586 server_error_detail(status)
587 } else {
588 detail
589 };
590
591 ProblemDetails {
592 type_uri: explicit_type
593 .unwrap_or_else(|| problem_type_for(status, has_validation_errors))
594 .to_owned(),
595 title: problem_title_for(status, has_validation_errors).to_owned(),
596 status: status.as_u16(),
597 detail: safe_detail,
598 instance,
599 code: problem_code_for(status, has_validation_errors).to_owned(),
600 request_id,
601 errors: validation_errors(details),
602 }
603}
604
605#[must_use]
608pub(crate) fn problem_details_json_string(
609 status: StatusCode,
610 detail: impl Into<String>,
611 details: Option<&std::collections::HashMap<String, Vec<String>>>,
612 explicit_type: Option<&'static str>,
613 request_id: Option<String>,
614 instance: Option<String>,
615 expose_internal_detail: bool,
616) -> String {
617 let problem = problem_details(
618 status,
619 detail.into(),
620 details,
621 explicit_type,
622 request_id,
623 instance,
624 expose_internal_detail,
625 );
626 problem_details_to_json_string(&problem)
627}
628
629#[must_use]
631pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
632 serde_json::to_string(&problem).unwrap_or_else(|_| {
633 r#"{"type":"https://autumn.dev/problems/internal-server-error","title":"Internal Server Error","status":500,"detail":"Internal server error","instance":null,"code":"autumn.internal_server_error","request_id":null,"errors":[]}"#.to_owned()
634 })
635}
636
637fn validation_errors(
638 details: Option<&std::collections::HashMap<String, Vec<String>>>,
639) -> Vec<ProblemFieldError> {
640 let mut errors: Vec<_> = details
641 .into_iter()
642 .flat_map(std::collections::HashMap::iter)
643 .map(|(field, messages)| ProblemFieldError {
644 field: field.clone(),
645 messages: messages.clone(),
646 })
647 .collect();
648 errors.sort_by(|left, right| left.field.cmp(&right.field));
649 errors
650}
651
652const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
653 if has_validation_errors {
654 return "https://autumn.dev/problems/validation-failed";
655 }
656
657 match status {
658 StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
659 StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
660 StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
661 StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
662 StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
663 StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
664 StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
665 StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
666 StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
667 StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
668 _ => "about:blank",
669 }
670}
671
672fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
673 if has_validation_errors {
674 return "Validation Failed";
675 }
676
677 match status {
678 StatusCode::BAD_REQUEST => "Bad Request",
679 StatusCode::UNAUTHORIZED => "Unauthorized",
680 StatusCode::FORBIDDEN => "Forbidden",
681 StatusCode::NOT_FOUND => "Not Found",
682 StatusCode::CONFLICT => "Conflict",
683 StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
684 StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
685 StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
686 StatusCode::NOT_IMPLEMENTED => "Not Implemented",
687 StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
688 _ => status.canonical_reason().unwrap_or("Error"),
689 }
690}
691
692fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
693 if has_validation_errors {
694 return "autumn.validation_failed";
695 }
696
697 match status {
698 StatusCode::BAD_REQUEST => "autumn.bad_request",
699 StatusCode::UNAUTHORIZED => "autumn.unauthorized",
700 StatusCode::FORBIDDEN => "autumn.forbidden",
701 StatusCode::NOT_FOUND => "autumn.not_found",
702 StatusCode::CONFLICT => "autumn.conflict",
703 StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
704 StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
705 StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
706 StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
707 StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
708 _ if status.is_client_error() => "autumn.client_error",
709 _ if status.is_server_error() => "autumn.server_error",
710 _ => "autumn.error",
711 }
712}
713
714fn server_error_detail(status: StatusCode) -> String {
715 match status {
716 StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
717 StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
718 _ => "Internal server error".to_owned(),
719 }
720}
721
722impl IntoResponse for AutumnError {
723 fn into_response(self) -> Response {
724 let status = self.status;
725 let message = self.inner.to_string();
726 let details = self.details.clone();
727 let problem_type = self.problem_type;
728
729 let error_info = crate::middleware::AutumnErrorInfo {
732 status,
733 message: message.clone(),
734 details: details.clone(),
735 problem_type,
736 };
737
738 let body = problem_details(
739 status,
740 message,
741 details.as_ref(),
742 problem_type,
743 None,
744 None,
745 true,
746 );
747 let mut response = (status, axum::Json(body)).into_response();
748 response.headers_mut().insert(
749 header::CONTENT_TYPE,
750 HeaderValue::from_static("application/problem+json"),
751 );
752 if status == StatusCode::CONFLICT {
753 response.headers_mut().insert(
754 "HX-Trigger",
755 HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
756 );
757 }
758 response.extensions_mut().insert(error_info);
759 response
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766 use axum::http::StatusCode;
767
768 #[derive(Debug)]
769 struct TestError(String);
770
771 impl std::fmt::Display for TestError {
772 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
773 write!(f, "{}", self.0)
774 }
775 }
776
777 impl std::error::Error for TestError {}
778
779 #[derive(Debug)]
780 struct WrappedError {
781 message: String,
782 source: TestError,
783 }
784
785 impl std::fmt::Display for WrappedError {
786 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
787 write!(f, "{}", self.message)
788 }
789 }
790
791 impl std::error::Error for WrappedError {
792 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
793 Some(&self.source)
794 }
795 }
796
797 #[test]
798 fn blanket_from_defaults_to_500() {
799 let err: AutumnError = TestError("boom".into()).into();
800 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
801 }
802
803 #[test]
804 fn internal_server_error_is_500() {
805 let err = AutumnError::internal_server_error(TestError("boom".into()));
806 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
807 }
808
809 #[test]
810 fn test_not_found_error() {
811 let err = AutumnError::not_found(std::io::Error::other("no such user"));
812 assert_eq!(err.status(), StatusCode::NOT_FOUND);
813 }
814
815 #[test]
816 fn not_found_is_404() {
817 let err = AutumnError::not_found(TestError("missing".into()));
818 assert_eq!(err.status(), StatusCode::NOT_FOUND);
819 }
820
821 #[test]
822 fn bad_request_is_400() {
823 let err = AutumnError::bad_request(TestError("invalid input".into()));
824 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
825 }
826
827 #[test]
828 fn unprocessable_is_422() {
829 let err = AutumnError::unprocessable(TestError("bad entity".into()));
830 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
831 }
832
833 #[test]
834 fn unauthorized_is_401() {
835 let err = AutumnError::unauthorized(TestError("unauthorized".into()));
836 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
837 }
838
839 #[test]
840 fn forbidden_is_403() {
841 let err = AutumnError::forbidden(TestError("forbidden".into()));
842 assert_eq!(err.status(), StatusCode::FORBIDDEN);
843 }
844
845 #[test]
846 fn validation_is_422() {
847 let mut details = std::collections::HashMap::new();
848 details.insert("field".to_string(), vec!["error".to_string()]);
849 let err = AutumnError::validation(details);
850 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
851 }
852
853 #[test]
854 fn service_unavailable_is_503() {
855 let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
856 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
857 }
858
859 #[test]
860 fn internal_server_error_msg_is_500() {
861 let err = AutumnError::internal_server_error_msg("db failure");
862 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
863 assert_eq!(err.to_string(), "db failure");
864 }
865
866 #[test]
867 fn not_found_msg_is_404() {
868 let err = AutumnError::not_found_msg("no such user");
869 assert_eq!(err.status(), StatusCode::NOT_FOUND);
870 assert_eq!(err.to_string(), "no such user");
871 }
872
873 #[test]
874 fn bad_request_msg_is_400() {
875 let err = AutumnError::bad_request_msg("invalid input");
876 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
877 }
878
879 #[test]
880 fn unprocessable_msg_is_422() {
881 let err = AutumnError::unprocessable_msg("title required");
882 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
883 }
884
885 #[test]
886 fn unauthorized_msg_is_401() {
887 let err = AutumnError::unauthorized_msg("login required");
888 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
889 }
890
891 #[test]
892 fn forbidden_msg_is_403() {
893 let err = AutumnError::forbidden_msg("no access");
894 assert_eq!(err.status(), StatusCode::FORBIDDEN);
895 }
896
897 #[test]
898 fn service_unavailable_msg_is_503() {
899 let err = AutumnError::service_unavailable_msg("db down");
900 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
901 assert_eq!(err.to_string(), "db down");
902 }
903
904 #[test]
905 fn with_status_overrides() {
906 let err: AutumnError = TestError("forbidden".into()).into();
907 let err = err.with_status(StatusCode::FORBIDDEN);
908 assert_eq!(err.status(), StatusCode::FORBIDDEN);
909 }
910
911 #[test]
912 fn display_uses_inner_message() {
913 let err: AutumnError = TestError("something broke".into()).into();
914 assert_eq!(err.to_string(), "something broke");
915 }
916
917 #[test]
918 fn source_chain_lists_inner_sources() {
919 let err = AutumnError::internal_server_error(WrappedError {
920 message: "failed to backfill".to_string(),
921 source: TestError("database connection dropped".to_string()),
922 });
923
924 assert_eq!(
925 err.source_chain(),
926 vec!["database connection dropped".to_string()]
927 );
928 }
929
930 #[test]
931 fn into_response_has_correct_status() {
932 let err = AutumnError::not_found(TestError("not found".into()));
933 let response = err.into_response();
934 assert_eq!(response.status(), StatusCode::NOT_FOUND);
935 }
936
937 #[tokio::test]
938 async fn into_response_has_json_body() -> Result<(), axum::Error> {
939 let err = AutumnError::not_found(TestError("not found".into()));
940 let response = err.into_response();
941
942 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
943 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
944
945 assert_eq!(json["status"], 404);
946 assert_eq!(json["detail"], "not found");
947 assert_eq!(json["code"], "autumn.not_found");
948 Ok(())
949 }
950
951 #[test]
952 fn debug_shows_status_and_inner() {
953 let err = AutumnError::bad_request(TestError("oops".into()));
954 let debug = format!("{err:?}");
955 assert!(debug.contains("AutumnError"));
956 assert!(debug.contains("400"));
957 }
958
959 #[tokio::test]
960 async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
961 let err = AutumnError::unprocessable_msg("title required");
962 let response = err.into_response();
963
964 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
965 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
966 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
967 assert_eq!(json["status"], 422);
968 assert_eq!(json["detail"], "title required");
969 assert_eq!(json["code"], "autumn.unprocessable_entity");
970 Ok(())
971 }
972
973 #[tokio::test]
974 async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
975 let err = AutumnError::service_unavailable_msg("db down");
976 let response = err.into_response();
977
978 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
979 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
980 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
981 assert_eq!(json["status"], 503);
982 assert_eq!(json["detail"], "db down");
983 assert_eq!(json["code"], "autumn.service_unavailable");
984 Ok(())
985 }
986
987 #[test]
988 fn conflict_is_409() {
989 let err = AutumnError::conflict(TestError("stale version".into()));
990 assert_eq!(err.status(), StatusCode::CONFLICT);
991 }
992
993 #[test]
994 fn conflict_msg_is_409() {
995 let err = AutumnError::conflict_msg("please reload and retry");
996 assert_eq!(err.status(), StatusCode::CONFLICT);
997 assert_eq!(err.to_string(), "please reload and retry");
998 }
999
1000 #[tokio::test]
1001 async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
1002 let err = AutumnError::conflict_msg("version mismatch");
1003 let response = err.into_response();
1004
1005 assert_eq!(response.status(), StatusCode::CONFLICT);
1006 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1007 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1008 assert_eq!(json["status"], 409);
1009 assert_eq!(json["detail"], "version mismatch");
1010 assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
1011 assert_eq!(json["title"], "Conflict");
1012 Ok(())
1013 }
1014
1015 #[tokio::test]
1016 async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
1017 let err = AutumnError::conflict_msg("version mismatch");
1018 let response = err.into_response();
1019
1020 assert_eq!(response.status(), StatusCode::CONFLICT);
1021 let hx_trigger = response
1022 .headers()
1023 .get("HX-Trigger")
1024 .expect("HX-Trigger header present");
1025 assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
1026 Ok(())
1027 }
1028}