axum_anyhow/
error.rs

1use crate::hook::invoke_hook;
2use anyhow::Error;
3use axum::{
4    http::StatusCode,
5    response::{IntoResponse, Response},
6    Json,
7};
8use serde::Serialize;
9
10/// An API error that can be converted into an HTTP response.
11///
12/// This struct contains the HTTP status code, a title, and a detailed description
13/// of the error. When converted into a response, it produces a JSON body with
14/// these fields.
15///
16/// # JSON Response Format
17///
18/// ```json
19/// {
20///   "status": 404,
21///   "title": "Not Found",
22///   "detail": "The requested resource does not exist"
23/// }
24/// ```
25///
26/// # Example
27///
28/// ```rust
29/// use axum::http::StatusCode;
30/// use axum_anyhow::ApiError;
31///
32/// let error = ApiError {
33///     status: StatusCode::NOT_FOUND,
34///     title: "Not Found".to_string(),
35///     detail: "User not found".to_string(),
36///     error: None,
37/// };
38/// ```
39#[derive(Debug)]
40pub struct ApiError {
41    /// The HTTP status code for this error
42    pub status: StatusCode,
43    /// A short, human-readable summary of the error
44    pub title: String,
45    /// A detailed explanation of the error
46    pub detail: String,
47    /// The underlying error that caused this API error
48    pub error: Option<Error>,
49}
50
51impl ApiError {
52    /// Creates a new builder for constructing an `ApiError`.
53    ///
54    /// # Example
55    ///
56    /// ```rust
57    /// use axum::http::StatusCode;
58    /// use axum_anyhow::ApiError;
59    /// use anyhow::anyhow;
60    ///
61    /// let error = ApiError::builder()
62    ///     .status(StatusCode::BAD_REQUEST)
63    ///     .title("Validation Error")
64    ///     .detail("Email address is required")
65    ///     .build();
66    /// ```
67    pub fn builder() -> ApiErrorBuilder {
68        ApiErrorBuilder::default()
69    }
70    /// Converts this `ApiError` into an `anyhow::Error`.
71    ///
72    /// If the `ApiError` contains an underlying error, it will be returned with
73    /// additional context from the title and detail. Otherwise, a new error is
74    /// created from the title and detail.
75    ///
76    /// # Example
77    ///
78    /// ```rust
79    /// use axum::http::StatusCode;
80    /// use axum_anyhow::ApiError;
81    /// use anyhow::anyhow;
82    ///
83    /// let api_error = ApiError::builder()
84    ///     .status(StatusCode::INTERNAL_SERVER_ERROR)
85    ///     .title("Database Error")
86    ///     .detail("Failed to connect")
87    ///     .error(anyhow!("Connection timeout"))
88    ///     .build();
89    ///
90    /// let anyhow_error = api_error.into_error();
91    /// ```
92    pub fn into_error(self) -> Error {
93        if let Some(error) = self.error {
94            error.context(format!("{}: {}", self.title, self.detail))
95        } else {
96            anyhow::anyhow!("{}: {}", self.title, self.detail)
97        }
98    }
99}
100
101impl Default for ApiError {
102    /// Creates a default `ApiError` with:
103    /// - `status`: `StatusCode::INTERNAL_SERVER_ERROR`
104    /// - `title`: `"Internal Error"`
105    /// - `detail`: `"Something went wrong"`
106    /// - `error`: `None`
107    ///
108    /// # Example
109    ///
110    /// ```rust
111    /// use axum::http::StatusCode;
112    /// use axum_anyhow::ApiError;
113    ///
114    /// let error = ApiError::default();
115    /// assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
116    /// assert_eq!(error.title, "Internal Error");
117    /// assert_eq!(error.detail, "Something went wrong");
118    /// assert!(error.error.is_none());
119    /// ```
120    fn default() -> Self {
121        Self {
122            status: StatusCode::INTERNAL_SERVER_ERROR,
123            title: "Internal Error".to_string(),
124            detail: "Something went wrong".to_string(),
125            error: None,
126        }
127    }
128}
129
130/// Converts from `anyhow::Error` to `ApiError`.
131///
132/// By default, all errors are converted to 500 Internal Server Error responses.
133/// Use the extension traits to specify different status codes.
134impl<E> From<E> for ApiError
135where
136    E: Into<anyhow::Error>,
137{
138    fn from(err: E) -> Self {
139        ApiError::builder().error(err).build()
140    }
141}
142
143/// The JSON structure used in error responses.
144#[derive(Serialize)]
145struct ApiErrorResponse {
146    status: u16,
147    title: String,
148    detail: String,
149}
150
151/// Converts from `ApiError` to an HTTP `Response`.
152///
153/// This implementation allows `ApiError` to be used as a return type in Axum handlers.
154/// The error is serialized as JSON with the status code, title, and detail fields.
155impl IntoResponse for ApiError {
156    fn into_response(self) -> Response {
157        let body = Json(ApiErrorResponse {
158            status: self.status.as_u16(),
159            title: self.title,
160            detail: self.detail,
161        });
162
163        (self.status, body).into_response()
164    }
165}
166
167/// A builder for constructing `ApiError` instances.
168///
169/// This builder provides a fluent interface for creating `ApiError` instances with
170/// optional fields. The `status`, `title`, and `detail` fields are required and must
171/// be set before calling `build()`.
172///
173/// # Example
174///
175/// ```rust
176/// use axum::http::StatusCode;
177/// use axum_anyhow::ApiError;
178/// use anyhow::anyhow;
179///
180/// let error = ApiError::builder()
181///     .status(StatusCode::INTERNAL_SERVER_ERROR)
182///     .title("Database Error")
183///     .detail("Failed to connect to the database")
184///     .error(anyhow!("Connection timeout"))
185///     .build();
186/// ```
187#[derive(Default)]
188pub struct ApiErrorBuilder {
189    status: Option<StatusCode>,
190    title: Option<String>,
191    detail: Option<String>,
192    error: Option<Error>,
193}
194
195impl ApiErrorBuilder {
196    /// Sets the HTTP status code for the error.
197    ///
198    /// # Example
199    ///
200    /// ```rust
201    /// use axum::http::StatusCode;
202    /// use axum_anyhow::ApiError;
203    ///
204    /// let error = ApiError::builder()
205    ///     .status(StatusCode::NOT_FOUND)
206    ///     .title("Not Found")
207    ///     .detail("Resource not found")
208    ///     .build();
209    /// ```
210    pub fn status(mut self, status: StatusCode) -> Self {
211        self.status = Some(status);
212        self
213    }
214
215    /// Sets the title for the error.
216    ///
217    /// # Example
218    ///
219    /// ```rust
220    /// use axum::http::StatusCode;
221    /// use axum_anyhow::ApiError;
222    ///
223    /// let error = ApiError::builder()
224    ///     .status(StatusCode::BAD_REQUEST)
225    ///     .title("Invalid Input")
226    ///     .detail("The provided email is invalid")
227    ///     .build();
228    /// ```
229    pub fn title(mut self, title: impl Into<String>) -> Self {
230        self.title = Some(title.into());
231        self
232    }
233
234    /// Sets the detail message for the error.
235    ///
236    /// # Example
237    ///
238    /// ```rust
239    /// use axum::http::StatusCode;
240    /// use axum_anyhow::ApiError;
241    ///
242    /// let error = ApiError::builder()
243    ///     .status(StatusCode::FORBIDDEN)
244    ///     .title("Access Denied")
245    ///     .detail("You do not have permission to access this resource")
246    ///     .build();
247    /// ```
248    pub fn detail(mut self, detail: impl Into<String>) -> Self {
249        self.detail = Some(detail.into());
250        self
251    }
252
253    /// Sets the underlying error that caused this API error.
254    ///
255    /// # Example
256    ///
257    /// ```rust
258    /// use axum::http::StatusCode;
259    /// use axum_anyhow::ApiError;
260    /// use anyhow::anyhow;
261    ///
262    /// let error = ApiError::builder()
263    ///     .status(StatusCode::INTERNAL_SERVER_ERROR)
264    ///     .title("Database Error")
265    ///     .detail("Failed to execute query")
266    ///     .error(anyhow!("Connection pool exhausted"))
267    ///     .build();
268    ///
269    /// assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
270    /// assert_eq!(error.title, "Database Error");
271    /// assert_eq!(error.detail, "Failed to execute query");
272    /// assert_eq!(error.error.unwrap().to_string(), "Connection pool exhausted");
273    /// ```
274    pub fn error(mut self, error: impl Into<Error>) -> Self {
275        self.error = Some(error.into());
276        self
277    }
278
279    /// Builds the `ApiError` instance.
280    ///
281    /// If `status`, `title`, or `detail` have not been set, they will default to:
282    /// - `status`: `StatusCode::INTERNAL_SERVER_ERROR`
283    /// - `title`: `"Internal Error"`
284    /// - `detail`: `"Something went wrong"`
285    ///
286    /// # Example
287    ///
288    /// ```rust
289    /// use axum::http::StatusCode;
290    /// use axum_anyhow::ApiError;
291    ///
292    /// let error = ApiError::builder()
293    ///     .status(StatusCode::BAD_REQUEST)
294    ///     .title("Bad Request")
295    ///     .detail("Invalid request parameters")
296    ///     .build();
297    ///
298    /// assert_eq!(error.status, StatusCode::BAD_REQUEST);
299    /// assert_eq!(error.title, "Bad Request");
300    /// assert_eq!(error.detail, "Invalid request parameters");
301    ///
302    /// // Using defaults
303    /// let default_error = ApiError::builder().build();
304    /// assert_eq!(default_error.status, StatusCode::INTERNAL_SERVER_ERROR);
305    /// assert_eq!(default_error.title, "Internal Error");
306    /// assert_eq!(default_error.detail, "Something went wrong");
307    /// ```
308    pub fn build(self) -> ApiError {
309        let error = ApiError {
310            status: self.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
311            title: self.title.unwrap_or_else(|| "Internal Error".to_string()),
312            detail: self
313                .detail
314                .unwrap_or_else(|| "Something went wrong".to_string()),
315            error: self.error,
316        };
317        invoke_hook(&error);
318        error
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use anyhow::anyhow;
326    use http_body_util::BodyExt;
327    use serde_json::Value;
328
329    #[test]
330    fn test_into_api_error_from_anyhow() {
331        let anyhow_err = anyhow!("Something went wrong");
332        let api_err: ApiError = anyhow_err.into();
333
334        assert_eq!(api_err.status, StatusCode::INTERNAL_SERVER_ERROR);
335        assert_eq!(api_err.title, "Internal Error");
336        assert_eq!(api_err.detail, "Something went wrong");
337    }
338
339    #[test]
340    fn test_api_error_builder() {
341        let error = ApiError::builder()
342            .status(StatusCode::BAD_REQUEST)
343            .title("Validation Error")
344            .detail("Email is required")
345            .build();
346
347        assert_eq!(error.status, StatusCode::BAD_REQUEST);
348        assert_eq!(error.title, "Validation Error");
349        assert_eq!(error.detail, "Email is required");
350        assert!(error.error.is_none());
351    }
352
353    #[test]
354    fn test_api_error_builder_with_error() {
355        let underlying_error = anyhow!("Database connection failed");
356        let error = ApiError::builder()
357            .status(StatusCode::INTERNAL_SERVER_ERROR)
358            .title("Database Error")
359            .detail("Could not connect to the database")
360            .error(underlying_error)
361            .build();
362
363        assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
364        assert_eq!(error.title, "Database Error");
365        assert_eq!(error.detail, "Could not connect to the database");
366        assert!(error.error.is_some());
367    }
368
369    #[test]
370    fn test_api_error_builder_with_string_conversions() {
371        let error = ApiError::builder()
372            .status(StatusCode::NOT_FOUND)
373            .title("Not Found".to_string())
374            .detail("Resource not found".to_string())
375            .build();
376
377        assert_eq!(error.status, StatusCode::NOT_FOUND);
378        assert_eq!(error.title, "Not Found");
379        assert_eq!(error.detail, "Resource not found");
380    }
381
382    #[test]
383    fn test_api_error_builder_missing_status() {
384        let error = ApiError::builder()
385            .title("Error")
386            .detail("Something went wrong")
387            .build();
388
389        assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
390        assert_eq!(error.title, "Error");
391        assert_eq!(error.detail, "Something went wrong");
392    }
393
394    #[test]
395    fn test_api_error_builder_missing_title() {
396        let error = ApiError::builder()
397            .status(StatusCode::BAD_REQUEST)
398            .detail("Something went wrong")
399            .build();
400
401        assert_eq!(error.status, StatusCode::BAD_REQUEST);
402        assert_eq!(error.title, "Internal Error");
403        assert_eq!(error.detail, "Something went wrong");
404    }
405
406    #[test]
407    fn test_api_error_builder_missing_detail() {
408        let error = ApiError::builder()
409            .status(StatusCode::BAD_REQUEST)
410            .title("Error")
411            .build();
412
413        assert_eq!(error.status, StatusCode::BAD_REQUEST);
414        assert_eq!(error.title, "Error");
415        assert_eq!(error.detail, "Something went wrong");
416    }
417
418    #[test]
419    fn test_api_error_builder_all_defaults() {
420        let error = ApiError::builder().build();
421
422        assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
423        assert_eq!(error.title, "Internal Error");
424        assert_eq!(error.detail, "Something went wrong");
425        assert!(error.error.is_none());
426    }
427
428    #[test]
429    fn test_api_error_builder_fluent_interface() {
430        let error = ApiError::builder()
431            .status(StatusCode::CONFLICT)
432            .title("Conflict")
433            .detail("User already exists")
434            .error(anyhow!("Duplicate email"))
435            .build();
436
437        assert_eq!(error.status, StatusCode::CONFLICT);
438        assert_eq!(error.title, "Conflict");
439        assert_eq!(error.detail, "User already exists");
440        assert!(error.error.is_some());
441    }
442
443    #[test]
444    fn test_api_error_default() {
445        let error = ApiError::default();
446
447        assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
448        assert_eq!(error.title, "Internal Error");
449        assert_eq!(error.detail, "Something went wrong");
450        assert!(error.error.is_none());
451    }
452
453    #[test]
454    fn test_anyhow_error_coerced_to_api_error_has_defaults() {
455        let anyhow_err = anyhow!("Some error occurred");
456        let api_err: ApiError = anyhow_err.into();
457
458        assert_eq!(api_err.status, StatusCode::INTERNAL_SERVER_ERROR);
459        assert_eq!(api_err.title, "Internal Error");
460        assert_eq!(api_err.detail, "Something went wrong");
461        assert!(api_err.error.is_some());
462    }
463
464    #[test]
465    fn test_api_error_default_matches_builder_defaults() {
466        let from_default = ApiError::default();
467        let from_builder = ApiError::builder().build();
468
469        assert_eq!(from_default.status, from_builder.status);
470        assert_eq!(from_default.title, from_builder.title);
471        assert_eq!(from_default.detail, from_builder.detail);
472        assert!(from_default.error.is_none());
473        assert!(from_builder.error.is_none());
474    }
475
476    #[tokio::test]
477    async fn test_into_response_status() {
478        let api_err = ApiError::builder()
479            .status(StatusCode::BAD_REQUEST)
480            .title("Bad Request")
481            .detail("Invalid data")
482            .build();
483
484        let response = api_err.into_response();
485        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
486    }
487
488    #[tokio::test]
489    async fn test_into_response_json_structure() {
490        let api_err = ApiError::builder()
491            .status(StatusCode::NOT_FOUND)
492            .title("Not Found")
493            .detail("Resource does not exist")
494            .build();
495
496        let response = api_err.into_response();
497
498        // Verify status
499        assert_eq!(response.status(), StatusCode::NOT_FOUND);
500
501        // Verify JSON body structure
502        let body = response.into_body();
503        let bytes = body.collect().await.unwrap().to_bytes();
504        let json: Value = serde_json::from_slice(&bytes).unwrap();
505
506        assert_eq!(json["status"], 404);
507        assert_eq!(json["title"], "Not Found");
508        assert_eq!(json["detail"], "Resource does not exist");
509    }
510
511    #[test]
512    fn test_into_error_with_underlying_error() {
513        let underlying = anyhow!("Connection timeout");
514        let api_error = ApiError::builder()
515            .status(StatusCode::INTERNAL_SERVER_ERROR)
516            .title("Database Error")
517            .detail("Failed to connect")
518            .error(underlying)
519            .build();
520
521        let anyhow_error = api_error.into_error();
522        let error_msg = format!("{:#}", anyhow_error);
523
524        // Should contain both the context and the underlying error
525        assert!(error_msg.contains("Database Error: Failed to connect"));
526        assert!(error_msg.contains("Connection timeout"));
527    }
528
529    #[test]
530    fn test_into_error_without_underlying_error() {
531        let api_error = ApiError::builder()
532            .status(StatusCode::BAD_REQUEST)
533            .title("Validation Error")
534            .detail("Email is required")
535            .build();
536
537        let anyhow_error = api_error.into_error();
538        let error_msg = anyhow_error.to_string();
539
540        assert_eq!(error_msg, "Validation Error: Email is required");
541    }
542}