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}