Skip to main content

typeway_server/
error.rs

1//! Structured error responses and error-handling utilities.
2//!
3//! [`JsonError`] provides a standard JSON error format for API responses.
4//! The `CatchPanic` layer catches panics in handlers and converts them
5//! to 500 responses.
6
7use http::StatusCode;
8use serde::Serialize;
9
10use crate::body::{body_from_bytes, body_from_string, BoxBody};
11use crate::response::IntoResponse;
12
13/// A structured JSON error response.
14///
15/// Serializes to `{"error": {"status": 400, "message": "..."}}`.
16///
17/// # Example
18///
19/// ```
20/// use typeway_server::error::JsonError;
21/// use typeway_server::Json;
22///
23/// #[derive(serde::Serialize)]
24/// struct User { id: u32 }
25///
26/// async fn get_user() -> Result<Json<User>, JsonError> {
27///     // Return a structured JSON error on failure:
28///     Err(JsonError::not_found("user not found"))
29/// }
30/// ```
31#[derive(Debug, Clone)]
32pub struct JsonError {
33    pub status: StatusCode,
34    pub message: String,
35}
36
37#[derive(Serialize)]
38struct JsonErrorBody {
39    error: JsonErrorInner,
40}
41
42#[derive(Serialize)]
43struct JsonErrorInner {
44    status: u16,
45    message: String,
46}
47
48impl JsonError {
49    /// Create a new error with the given status code and message.
50    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
51        JsonError {
52            status,
53            message: message.into(),
54        }
55    }
56
57    /// 400 Bad Request.
58    pub fn bad_request(message: impl Into<String>) -> Self {
59        Self::new(StatusCode::BAD_REQUEST, message)
60    }
61
62    /// 401 Unauthorized.
63    pub fn unauthorized(message: impl Into<String>) -> Self {
64        Self::new(StatusCode::UNAUTHORIZED, message)
65    }
66
67    /// 403 Forbidden.
68    pub fn forbidden(message: impl Into<String>) -> Self {
69        Self::new(StatusCode::FORBIDDEN, message)
70    }
71
72    /// 404 Not Found.
73    pub fn not_found(message: impl Into<String>) -> Self {
74        Self::new(StatusCode::NOT_FOUND, message)
75    }
76
77    /// 409 Conflict.
78    pub fn conflict(message: impl Into<String>) -> Self {
79        Self::new(StatusCode::CONFLICT, message)
80    }
81
82    /// 422 Unprocessable Entity.
83    pub fn unprocessable(message: impl Into<String>) -> Self {
84        Self::new(StatusCode::UNPROCESSABLE_ENTITY, message)
85    }
86
87    /// 500 Internal Server Error.
88    pub fn internal(message: impl Into<String>) -> Self {
89        Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
90    }
91}
92
93impl IntoResponse for JsonError {
94    fn into_response(self) -> http::Response<BoxBody> {
95        let body = JsonErrorBody {
96            error: JsonErrorInner {
97                status: self.status.as_u16(),
98                message: self.message,
99            },
100        };
101        match serde_json::to_vec(&body) {
102            Ok(bytes) => {
103                let body = body_from_bytes(bytes::Bytes::from(bytes));
104                let mut res = http::Response::new(body);
105                *res.status_mut() = self.status;
106                res.headers_mut().insert(
107                    http::header::CONTENT_TYPE,
108                    http::HeaderValue::from_static("application/json"),
109                );
110                res
111            }
112            Err(e) => {
113                let mut res = http::Response::new(body_from_string(format!(
114                    "error serialization failed: {e}"
115                )));
116                *res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
117                res
118            }
119        }
120    }
121}
122
123impl std::fmt::Display for JsonError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        write!(
126            f,
127            "{} {}: {}",
128            self.status.as_u16(),
129            self.status,
130            self.message
131        )
132    }
133}
134
135impl std::error::Error for JsonError {}
136
137/// Implement `From<(StatusCode, String)>` so existing extractor errors
138/// can be converted to `JsonError` automatically.
139impl From<(StatusCode, String)> for JsonError {
140    fn from((status, message): (StatusCode, String)) -> Self {
141        JsonError { status, message }
142    }
143}
144
145// ---------------------------------------------------------------------------
146// OpenAPI error responses (feature = "openapi")
147// ---------------------------------------------------------------------------
148
149#[cfg(feature = "openapi")]
150impl typeway_openapi::ErrorResponses for JsonError {
151    fn error_responses() -> indexmap::IndexMap<String, typeway_openapi::spec::Response> {
152        use typeway_openapi::spec::*;
153
154        let mut content = indexmap::IndexMap::new();
155        let mut properties = indexmap::IndexMap::new();
156
157        let mut error_props = indexmap::IndexMap::new();
158        error_props.insert("status".to_string(), Schema::integer());
159        error_props.insert("message".to_string(), Schema::string());
160
161        properties.insert(
162            "error".to_string(),
163            Schema {
164                schema_type: Some("object".into()),
165                properties: Some(error_props),
166                ..Default::default()
167            },
168        );
169
170        content.insert(
171            "application/json".to_string(),
172            MediaType {
173                schema: Some(Schema {
174                    schema_type: Some("object".into()),
175                    properties: Some(properties),
176                    description: Some("JSON error response".into()),
177                    ..Default::default()
178                }),
179                example: None,
180            },
181        );
182
183        let mut responses = indexmap::IndexMap::new();
184        responses.insert(
185            "4XX".to_string(),
186            Response {
187                description: "Client error".to_string(),
188                content: content.clone(),
189            },
190        );
191        responses.insert(
192            "5XX".to_string(),
193            Response {
194                description: "Server error".to_string(),
195                content,
196            },
197        );
198        responses
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn json_error_response() {
208        let err = JsonError::not_found("user not found");
209        let res = err.into_response();
210        assert_eq!(res.status(), StatusCode::NOT_FOUND);
211        assert_eq!(
212            res.headers().get("content-type").unwrap(),
213            "application/json"
214        );
215    }
216
217    #[test]
218    fn json_error_from_tuple() {
219        let err: JsonError = (StatusCode::BAD_REQUEST, "bad input".to_string()).into();
220        assert_eq!(err.status, StatusCode::BAD_REQUEST);
221        assert_eq!(err.message, "bad input");
222    }
223}