Skip to main content

api_error/
lib.rs

1// Copyright 2025-Present Centreon
2// SPDX-License-Identifier: Apache-2.0
3#![warn(clippy::pedantic)]
4#![cfg_attr(docsrs, feature(doc_cfg))]
5
6//! # Api Error
7//!
8//! A Rust crate for easily defining API-friendly error types with HTTP status codes
9//! and user-facing error messages.
10//!
11//! ## Usage
12//!
13//! ### Basic Enum Example
14//!
15//! ```rust
16//! use api_error::ApiError;
17//! use http::StatusCode;
18//!
19//! #[derive(Debug, thiserror::Error, ApiError)]
20//! enum MyError {
21//!     #[error("Invalid input")]
22//!     #[api_error(status_code = 400, message = "The provided input is invalid")]
23//!     InvalidInput,
24//!
25//!     #[error("Resource not found")]
26//!     #[api_error(status_code = 404, message = "The requested resource was not found")]
27//!     NotFound,
28//!
29//!     #[error("Internal error")]
30//!     #[api_error(status_code = StatusCode::INTERNAL_SERVER_ERROR)]
31//!     Internal,
32//! }
33//!
34//! let err = MyError::InvalidInput;
35//! assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
36//! assert_eq!(err.message().as_ref(), "The provided input is invalid");
37//! assert_eq!(err.to_string(), "Invalid input");  // From thiserror
38//! ```
39//!
40//! ### Enum with Fields and Formatting
41//!
42//! ```rust
43//! use api_error::ApiError;
44//!
45//! #[derive(Debug, thiserror::Error, ApiError)]
46//! enum AppError {
47//!     // Unnamed fields with positional formatting
48//!     #[error("Database error: {0}")]
49//!     #[api_error(status_code = 500, message = "Database operation failed: {0}")]
50//!     Database(String),
51//!
52//!     // Named fields with named formatting
53//!     #[error("Validation failed on {field}")]
54//!     #[api_error(status_code = 422, message = "Field `{field}` has invalid value")]
55//!     Validation { field: String, value: String },
56//! }
57//!
58//! let err = AppError::Database("Connection timeout".to_string());
59//! assert_eq!(err.status_code().as_u16(), 500);
60//! assert_eq!(err.message().as_ref(), "Database operation failed: Connection timeout");
61//!
62//! let err = AppError::Validation {
63//!     field: "email".to_string(),
64//!     value: "invalid".to_string(),
65//! };
66//! assert_eq!(err.status_code().as_u16(), 422);
67//! assert_eq!(err.message().as_ref(), "Field `email` has invalid value");
68//! ```
69//!
70//! ### Struct Example
71//!
72//! ```rust
73//! use api_error::ApiError;
74//!
75//! #[derive(Debug, thiserror::Error, ApiError)]
76//! #[error("Authentication failed: {reason}")]
77//! #[api_error(status_code = 401, message = "Authentication failed")]
78//! struct AuthError {
79//!     reason: String,
80//! }
81//!
82//! let err = AuthError {
83//!     reason: "Invalid token".to_string(),
84//! };
85//! assert_eq!(err.status_code().as_u16(), 401);
86//! assert_eq!(err.message().as_ref(), "Authentication failed");
87//! ```
88//!
89//! ### Message Inheritance
90//!
91//! Use `message(inherit)` to use the `Display` implementation as the user-facing message:
92//!
93//! ```rust
94//! use api_error::ApiError;
95//!
96//! #[derive(Debug, thiserror::Error, ApiError)]
97//! enum MyError {
98//!     #[error("User-friendly error message")]
99//!     #[api_error(message(inherit), status_code = 400)]
100//!     BadRequest,
101//! }
102//!
103//! let err = MyError::BadRequest;
104//! assert_eq!(err.message().as_ref(), "User-friendly error message");
105//! ```
106//!
107//! ### Transparent Forwarding
108//!
109//! Forward both status code and message from an inner error:
110//!
111//! ```rust
112//! use api_error::ApiError;
113//!
114//! #[derive(Debug, thiserror::Error, ApiError)]
115//! #[error("Database error")]
116//! #[api_error(status_code = 503, message = "Service temporarily unavailable")]
117//! struct DatabaseError;
118//!
119//! #[derive(Debug, thiserror::Error, ApiError)]
120//! enum AppError {
121//!     #[error(transparent)]
122//!     #[api_error(transparent)]
123//!     Database(DatabaseError),
124//!
125//!     #[error("Other error")]
126//!     #[api_error(status_code = 500, message = "Internal error")]
127//!     Other,
128//! }
129//!
130//! let err = AppError::Database(DatabaseError);
131//! assert_eq!(err.status_code().as_u16(), 503);  // Forwarded from DatabaseError
132//! assert_eq!(err.message().as_ref(), "Service temporarily unavailable");
133//! ```
134//!
135//! ### Axum Integration
136//!
137//! With the `axum` feature enabled, `ApiError` types automatically implement `IntoResponse`:
138//!
139//! ```rust
140//! use api_error::ApiError;
141//! use axum::{Router, routing::get};
142//!
143//! #[derive(Debug, thiserror::Error, ApiError)]
144//! enum MyApiError {
145//!     #[error("Not found")]
146//!     #[api_error(status_code = 404, message = "Resource not found")]
147//!     NotFound,
148//! }
149//!
150//! async fn handler() -> Result<String, MyApiError> {
151//!     Err(MyApiError::NotFound)
152//! }
153//!
154//! let app: Router = Router::new().route("/", get(handler));
155//!
156//! // Returns JSON response:
157//! // Status: 404
158//! // Body: {"message": "Resource not found"}
159//! ```
160//!
161//! ### Attaching extended data to the response
162//!
163//! Override [`ApiError::extended`] to attach a structured payload that the
164//! default responder will serialize under an `"extended"` key alongside
165//! `"message"`. Returning `None` (the default) omits the field entirely.
166//!
167//! ```rust
168//! # use api_error::ApiError;
169//! # use http::StatusCode;
170//! # use serde_json::json;
171//! # use std::borrow::Cow;
172//! #[derive(Debug, thiserror::Error)]
173//! #[error("validation failed")]
174//! struct ValidationError {
175//!     field: &'static str,
176//! }
177//!
178//! impl ApiError for ValidationError {
179//!     fn status_code(&self) -> StatusCode { StatusCode::UNPROCESSABLE_ENTITY }
180//!     fn message(&self) -> Cow<'_, str> { Cow::Borrowed("validation failed") }
181//!     fn extended(&self) -> Option<serde_json::Value> {
182//!         Some(json!({ "field": self.field }))
183//!     }
184//! }
185//!
186//! // Resulting JSON body:
187//! // {"message": "validation failed", "extended": {"field": "email"}}
188//! ```
189//!
190//! Note: when using `#[derive(ApiError)]`, the generated `impl` covers all
191//! trait methods, so overriding `extended` requires writing the `impl`
192//! manually.
193//!
194//! ### Customizing axum error response format
195//!
196//! The default response body is `{"message": "<error msg>"}` (plus an
197//! `"extended"` field when [`ApiError::extended`] returns `Some`) with the
198//! error's HTTP status code. To use a different format, register a custom
199//! responder once at startup with [`axum::set_error_responder`]. Every type
200//! deriving [`ApiError`] will route through it.
201//!
202//! ```no_run
203//! use api_error::ApiError;
204//! use axum_core::response::{IntoResponse, Response};
205//! use http::StatusCode;
206//! use serde_json::json;
207//!
208//! fn my_responder(err: &dyn ApiError) -> Response {
209//!     let status = err.status_code();
210//!     let body = serde_json::to_vec(&json!({
211//!         "error": {
212//!             "code": status.as_u16(),
213//!             "message": err.message(),
214//!         }
215//!     })).unwrap();
216//!     (status, body).into_response()
217//! }
218//!
219//! api_error::axum::set_error_responder(my_responder);
220//! ```
221
222// Compile the README's code blocks as doctests. Opt-in via
223// `RUSTFLAGS="--cfg readme_doctest" cargo test --all-features` (this is what CI runs).
224#[cfg(readme_doctest)]
225#[doc = include_str!("../../README.md")]
226mod _readme_doctest {}
227
228use std::{borrow::Cow, convert::Infallible};
229
230use http::StatusCode;
231
232#[doc(hidden)]
233pub use ::http as __http;
234
235/// Derive macro for implementing [`ApiError`] on enums and structs.
236///
237/// This macro generates an [`ApiError`] implementation based on
238/// `#[api_error(...)]` attributes, removing the need to write
239/// `status_code()` and `message()` by hand.
240///
241/// It is intended to be used together with `thiserror::Error`.
242///
243/// ---
244///
245/// # Basic usage
246///
247/// ```
248/// # use api_error::ApiError;
249///
250/// #[derive(Debug, thiserror::Error, ApiError)]
251/// enum MyError {
252///     #[error("Internal failure")]
253///     #[api_error(status_code = 500, message = "Something went wrong")]
254///     Failure,
255/// }
256/// ```
257///
258/// ---
259///
260/// # `#[api_error]` attribute
261///
262/// The `#[api_error(...)]` attribute may be applied to:
263/// - enums
264/// - enum variants
265/// - structs
266///
267/// ## `status_code`
268///
269/// Sets the HTTP status code returned by the generated implementation.
270///
271/// You can either use the [`StatusCode`] enum or
272/// a status code literal:
273///
274/// ```
275/// # use api_error::ApiError;
276/// # use http::StatusCode;
277/// #[derive(Debug, thiserror::Error, ApiError)]
278/// enum MyError {
279///     #[api_error(status_code = 400)]
280///     #[error("Got error because of A")]
281///     ReasonA,
282///
283///     #[api_error(status_code = StatusCode::CONFLICT)]
284///     #[error("Got error because of B")]
285///     ReasonB,
286/// }
287/// assert_eq!(MyError::ReasonB.status_code(), StatusCode::CONFLICT)
288/// ```
289///
290/// If omitted, the status code defaults to
291/// `500 Internal Server Error`.
292///
293/// ---
294///
295/// ## `message`
296///
297/// Sets the client-facing error message.
298///
299/// ```ignore
300/// # use api_error::ApiError;
301/// # use http::StatusCode;
302/// #[api_error(message = "Invalid input")]
303/// ```
304///
305/// The message supports formatting using:
306/// - tuple indices (`{0}`, `{1}`, …)
307/// - named fields (`{field}`)
308///
309/// If omitted, the HTTP status reason phrase is used.
310///
311/// ---
312///
313/// ## `message(inherit)`
314///
315/// Uses the type’s `Display` implementation (from `thiserror`)
316/// as the API error message.
317///
318/// ```ignore
319/// #[error("Forbidden")]
320/// #[api_error(message(inherit))]
321/// struct Forbidden;
322/// ```
323///
324/// ---
325///
326/// ## `transparent`
327///
328/// Marks the type as a transparent wrapper around another [`ApiError`].
329///
330/// ```
331/// # use api_error::ApiError;
332/// #[derive(Debug, thiserror::Error, ApiError)]
333/// #[error(transparent)]
334/// #[api_error(transparent)]
335/// struct Wrapper(InnerError);
336///
337/// #[derive(Debug, thiserror::Error, ApiError)]
338/// #[error("My inner error")]
339/// struct InnerError;
340/// ```
341///
342/// ### Rules
343///
344/// - `transparent` must be used **alone**
345/// - all API metadata is delegated to the wrapped error
346///
347/// ---
348///
349/// # Multiple attributes
350///
351/// Multiple `#[api_error]` attributes may be used.
352/// When the same field is specified multiple times,
353/// the **last occurrence wins**.
354///
355/// ```rust
356/// #[api_error(message = "Initial")]
357/// #[api_error(status_code = 202)]
358/// #[api_error(message = "Final")]
359/// ```
360#[cfg(feature = "derive")]
361pub use api_error_derive::ApiError;
362
363/// An error that can be returned by a service API.
364/// ```
365/// # use http::StatusCode;
366/// # use api_error::ApiError;
367/// # use std::borrow::Cow;
368///
369/// #[derive(Debug, thiserror::Error)]
370/// enum MyServiceErrors {
371///     #[error("Database error: {0}")]
372///     Db(String),
373///     #[error("Authentication error")]
374///     Auth,
375/// // etc...
376/// }
377///
378/// impl ApiError for MyServiceErrors {
379///     fn status_code(&self) -> StatusCode {
380///         match self {
381///             MyServiceErrors::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
382///             MyServiceErrors::Auth => StatusCode::UNAUTHORIZED,
383///         }
384///     }
385///     fn message(&self) -> Cow<'_, str> {
386///         match self {
387///             MyServiceErrors::Db(_) => "Database error".into(),
388///             MyServiceErrors::Auth => "Authentication error".into(),
389///         }
390///     }
391/// }
392///
393/// assert_eq!(MyServiceErrors::Db("test".to_string()).status_code(), StatusCode::INTERNAL_SERVER_ERROR);
394/// assert_eq!(MyServiceErrors::Auth.status_code(), StatusCode::UNAUTHORIZED);
395pub trait ApiError: std::error::Error {
396    /// Returns the HTTP status code associated with the error.
397    fn status_code(&self) -> StatusCode {
398        StatusCode::INTERNAL_SERVER_ERROR
399    }
400
401    /// Returns a human-readable message describing the error.
402    /// It can be potentially shown to the user.
403    fn message(&self) -> Cow<'_, str> {
404        let msg = self
405            .status_code()
406            .canonical_reason()
407            .unwrap_or("Unknown error");
408
409        Cow::Borrowed(msg)
410    }
411
412    /// Returns an optional structured payload to include in the default
413    /// axum response body under the `"extended"` key.
414    ///
415    /// Returning `None` (the default) omits the field entirely. Override
416    /// this when you need to surface machine-readable details (e.g. a list
417    /// of invalid fields, a retry-after hint, an upstream error code) in
418    /// addition to the human-readable [`message`](Self::message).
419    ///
420    /// Only available with the `axum` feature.
421    #[cfg(feature = "axum")]
422    fn extended(&self) -> Option<serde_json::Value> {
423        None
424    }
425}
426
427impl ApiError for Infallible {}
428impl<T: ApiError> ApiError for &T {
429    fn status_code(&self) -> StatusCode {
430        (*self).status_code()
431    }
432
433    fn message(&self) -> Cow<'_, str> {
434        (*self).message()
435    }
436
437    #[cfg(feature = "axum")]
438    fn extended(&self) -> Option<serde_json::Value> {
439        (*self).extended()
440    }
441}
442
443/// Custom implementation for axum integration
444#[cfg(feature = "axum")]
445pub mod axum {
446    use std::sync::OnceLock;
447
448    use axum_core::{
449        body::Body,
450        response::{IntoResponse, Response},
451    };
452    use serde_core::{Serialize, ser::SerializeMap};
453
454    #[doc(hidden)]
455    pub use ::axum_core as __axum_core;
456
457    use super::ApiError;
458
459    #[doc(hidden)]
460    pub static __ERROR_RESPONDER: OnceLock<ApiErrorResponder> = OnceLock::new();
461
462    /// A function that converts an [`ApiError`] into an axum [`Response`].
463    ///
464    /// Register one globally with [`set_error_responder`] to customize the
465    /// response format produced by types deriving [`ApiError`].
466    pub type ApiErrorResponder = fn(&dyn ApiError) -> Response;
467
468    /// Sets a custom [`ApiErrorResponder`] that will be used to convert
469    /// [`ApiError`] to a [`Response`].
470    ///
471    /// For a non-panicking alternative, use [`try_set_error_responder`].
472    ///
473    /// # Panics
474    ///
475    /// Panics if the responder is already set.
476    pub fn set_error_responder(f: ApiErrorResponder) {
477        __ERROR_RESPONDER
478            .set(f)
479            .expect("an api error responder should be set only once");
480    }
481
482    /// Tries to set a custom [`ApiErrorResponder`] that will be used to convert
483    /// [`ApiError`] to a [`Response`].
484    ///
485    /// # Errors
486    ///
487    /// Returns an error if the responder is already set.
488    pub fn try_set_error_responder(f: ApiErrorResponder) -> Result<(), ApiErrorResponder> {
489        __ERROR_RESPONDER.set(f)
490    }
491
492    /// The default [`ApiErrorResponder`].
493    ///
494    /// Returns a [`Response`] whose status is [`ApiError::status_code`] and
495    /// whose JSON body is:
496    ///
497    /// ```json
498    /// { "message": "<ApiError::message()>" }
499    /// ```
500    ///
501    /// When [`ApiError::extended`] returns `Some(value)`, the body also
502    /// includes an `"extended"` field carrying that value:
503    ///
504    /// ```json
505    /// { "message": "<ApiError::message()>", "extended": <value> }
506    /// ```
507    pub fn default_error_responder(api_error: &dyn ApiError) -> Response {
508        ApiErrorResponse::new(api_error).into_response()
509    }
510
511    pub struct ApiErrorResponse<'a>(&'a dyn ApiError);
512
513    impl<'a> ApiErrorResponse<'a> {
514        pub fn new(api_error: &'a dyn ApiError) -> Self {
515            Self(api_error)
516        }
517    }
518
519    impl Serialize for ApiErrorResponse<'_> {
520        fn serialize<S: serde_core::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
521            let extended = self.0.extended();
522            let message = self.0.message();
523
524            let field_cnt = 1 + usize::from(extended.is_some());
525            let mut map = serializer.serialize_map(Some(field_cnt))?;
526
527            map.serialize_entry("message", &message)?;
528
529            if let Some(v) = &extended {
530                map.serialize_entry("extended", v)?;
531            }
532
533            map.end()
534        }
535    }
536
537    impl IntoResponse for ApiErrorResponse<'_> {
538        fn into_response(self) -> Response {
539            let body =
540                serde_json::to_vec(&self).expect("AxumApiError serialization should not fail");
541
542            (self.0.status_code(), Body::from(body)).into_response()
543        }
544    }
545}