1use axum::http::StatusCode;
2use axum::response::{IntoResponse, Response};
3use axum::Json;
4use serde::Serialize;
5
6#[derive(Debug)]
7pub enum ApiError {
8 NotFound(String),
9 BadRequest(String),
10 Unauthorized,
11 Unavailable(String),
12 Internal(String),
13}
14
15#[derive(Serialize)]
16struct ErrorBody {
17 error: String,
18 detail: String,
19}
20
21impl ApiError {
22 fn parts(&self) -> (StatusCode, &'static str, String) {
23 match self {
24 ApiError::NotFound(detail) => (StatusCode::NOT_FOUND, "not_found", detail.clone()),
25 ApiError::BadRequest(detail) => {
26 (StatusCode::BAD_REQUEST, "bad_request", detail.clone())
27 }
28 ApiError::Unauthorized => (
29 StatusCode::UNAUTHORIZED,
30 "unauthorized",
31 "a valid bearer token is required".to_string(),
32 ),
33 ApiError::Unavailable(detail) => (
34 StatusCode::SERVICE_UNAVAILABLE,
35 "unavailable",
36 detail.clone(),
37 ),
38 ApiError::Internal(detail) => (
39 StatusCode::INTERNAL_SERVER_ERROR,
40 "internal",
41 detail.clone(),
42 ),
43 }
44 }
45}
46
47impl IntoResponse for ApiError {
48 fn into_response(self) -> Response {
49 let (status, error, detail) = self.parts();
50 if status.is_server_error() {
51 tracing::error!(%error, %detail, "request failed");
52 }
53 (
54 status,
55 Json(ErrorBody {
56 error: error.to_string(),
57 detail,
58 }),
59 )
60 .into_response()
61 }
62}
63
64impl From<ecr_store::Error> for ApiError {
65 fn from(err: ecr_store::Error) -> Self {
66 use ecr_store::Error as E;
67 match &err {
68 E::MessageNotFound { .. } | E::PartNotFound { .. } => {
69 ApiError::NotFound(err.to_string())
70 }
71 E::InvalidTag { .. } | E::UnknownSendAccount { .. } => {
72 ApiError::BadRequest(err.to_string())
73 }
74 E::ToolMissing { .. }
75 | E::ConfigNotFound { .. }
76 | E::MaildirMissing { .. }
77 | E::NoDatabasePath { .. } => ApiError::Unavailable(err.to_string()),
78 _ => ApiError::Internal(err.to_string()),
79 }
80 }
81}
82
83pub type ApiResult<T> = Result<T, ApiError>;
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 fn status_of(err: ecr_store::Error) -> StatusCode {
90 ApiError::from(err).parts().0
91 }
92
93 #[test]
94 fn a_missing_message_is_a_404() {
95 assert_eq!(
96 status_of(ecr_store::Error::MessageNotFound {
97 id: "x".to_string()
98 }),
99 StatusCode::NOT_FOUND
100 );
101 }
102
103 #[test]
104 fn an_invalid_tag_is_a_400_not_a_500() {
105 assert_eq!(
106 status_of(ecr_store::Error::InvalidTag {
107 tag: "bad\n".to_string(),
108 reason: "no newlines"
109 }),
110 StatusCode::BAD_REQUEST
111 );
112 }
113
114 #[test]
115 fn a_missing_tool_is_a_503_because_it_is_an_environment_problem() {
116 assert_eq!(
117 status_of(ecr_store::Error::ToolMissing { tool: "notmuch" }),
118 StatusCode::SERVICE_UNAVAILABLE
119 );
120 }
121
122 #[test]
123 fn an_unexpected_tool_failure_is_a_500() {
124 assert_eq!(
125 status_of(ecr_store::Error::ToolFailed {
126 tool: "notmuch",
127 stderr: "boom".to_string()
128 }),
129 StatusCode::INTERNAL_SERVER_ERROR
130 );
131 }
132
133 #[test]
134 fn unauthorized_does_not_leak_detail() {
135 let (status, error, detail) = ApiError::Unauthorized.parts();
136 assert_eq!(status, StatusCode::UNAUTHORIZED);
137 assert_eq!(error, "unauthorized");
138 assert!(!detail.contains("token="), "{detail}");
139 }
140}