Skip to main content

ecr_server/
error.rs

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}