1use axum::{
9 Json,
10 http::StatusCode,
11 response::{IntoResponse, Response},
12};
13use serde_json::json;
14
15#[derive(Debug)]
17pub struct ApiError {
18 status: StatusCode,
19 message: String,
20}
21
22impl ApiError {
23 pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
24 Self {
25 status,
26 message: message.into(),
27 }
28 }
29
30 pub fn bad_request(message: impl Into<String>) -> Self {
33 Self::new(StatusCode::BAD_REQUEST, message)
34 }
35
36 pub fn status(&self) -> StatusCode {
39 self.status
40 }
41}
42
43impl std::fmt::Display for ApiError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.write_str(&self.message)
46 }
47}
48
49impl IntoResponse for ApiError {
50 fn into_response(self) -> Response {
51 if self.status.is_server_error() {
55 tracing::error!(status = %self.status, error = %self.message, "request failed");
56 return (self.status, Json(json!({ "error": "internal server error" }))).into_response();
57 }
58
59 (self.status, Json(json!({ "error": self.message }))).into_response()
60 }
61}
62
63impl From<anyhow::Error> for ApiError {
64 fn from(error: anyhow::Error) -> Self {
67 Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("{error:#}"))
68 }
69}
70
71impl From<sqlx::Error> for ApiError {
72 fn from(error: sqlx::Error) -> Self {
73 Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80 use axum::body::to_bytes;
81
82 async fn body_of(error: ApiError) -> serde_json::Value {
83 let response = error.into_response();
84 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
85 serde_json::from_slice(&bytes).unwrap()
86 }
87
88 #[tokio::test]
89 async fn client_errors_explain_themselves() {
90 let body = body_of(ApiError::bad_request("date is not a calendar date")).await;
91 assert_eq!(body["error"], "date is not a calendar date");
92 }
93
94 #[tokio::test]
95 async fn server_errors_do_not_leak_their_cause() {
96 let body = body_of(ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "relation \"users\" does not exist")).await;
97 assert_eq!(body["error"], "internal server error", "internals must not reach the client");
98 }
99
100 #[test]
101 fn the_status_survives_into_the_response() {
102 let response = ApiError::new(StatusCode::UNAUTHORIZED, "nope").into_response();
103 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
104 }
105}