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#[cfg(feature = "axum")]
236#[doc(hidden)]
237pub use ::serde_json as __serde_json;
238
239/// Derive macro for implementing [`ApiError`] on enums and structs.
240///
241/// This macro generates an [`ApiError`] implementation based on
242/// `#[api_error(...)]` attributes, removing the need to write
243/// `status_code()` and `message()` by hand.
244///
245/// It is intended to be used together with `thiserror::Error`.
246///
247/// ---
248///
249/// # Basic usage
250///
251/// ```
252/// # use api_error::ApiError;
253///
254/// #[derive(Debug, thiserror::Error, ApiError)]
255/// enum MyError {
256///     #[error("Internal failure")]
257///     #[api_error(status_code = 500, message = "Something went wrong")]
258///     Failure,
259/// }
260/// ```
261///
262/// ---
263///
264/// # `#[api_error]` attribute
265///
266/// The `#[api_error(...)]` attribute may be applied to:
267/// - enums
268/// - enum variants
269/// - structs
270///
271/// ## `status_code`
272///
273/// Sets the HTTP status code returned by the generated implementation.
274///
275/// You can either use the [`StatusCode`] enum or
276/// a status code literal:
277///
278/// ```
279/// # use api_error::ApiError;
280/// # use http::StatusCode;
281/// #[derive(Debug, thiserror::Error, ApiError)]
282/// enum MyError {
283///     #[api_error(status_code = 400)]
284///     #[error("Got error because of A")]
285///     ReasonA,
286///
287///     #[api_error(status_code = StatusCode::CONFLICT)]
288///     #[error("Got error because of B")]
289///     ReasonB,
290/// }
291/// assert_eq!(MyError::ReasonB.status_code(), StatusCode::CONFLICT)
292/// ```
293///
294/// If omitted, the status code defaults to
295/// `500 Internal Server Error`.
296///
297/// ---
298///
299/// ## `message`
300///
301/// Sets the client-facing error message.
302///
303/// ```ignore
304/// # use api_error::ApiError;
305/// # use http::StatusCode;
306/// #[api_error(message = "Invalid input")]
307/// ```
308///
309/// The message supports formatting using:
310/// - tuple indices (`{0}`, `{1}`, …)
311/// - named fields (`{field}`)
312///
313/// If omitted, the HTTP status reason phrase is used.
314///
315/// ---
316///
317/// ## `message(inherit)`
318///
319/// Uses the type’s `Display` implementation (from `thiserror`)
320/// as the API error message.
321///
322/// ```ignore
323/// #[error("Forbidden")]
324/// #[api_error(message(inherit))]
325/// struct Forbidden;
326/// ```
327///
328/// ---
329///
330/// ## `transparent`
331///
332/// Marks the type as a transparent wrapper around another [`ApiError`].
333///
334/// ```
335/// # use api_error::ApiError;
336/// #[derive(Debug, thiserror::Error, ApiError)]
337/// #[error(transparent)]
338/// #[api_error(transparent)]
339/// struct Wrapper(InnerError);
340///
341/// #[derive(Debug, thiserror::Error, ApiError)]
342/// #[error("My inner error")]
343/// struct InnerError;
344/// ```
345///
346/// ### Rules
347///
348/// - `transparent` must be used **alone**
349/// - all API metadata is delegated to the wrapped error
350///
351/// ---
352///
353/// # Multiple attributes
354///
355/// Multiple `#[api_error]` attributes may be used.
356/// When the same field is specified multiple times,
357/// the **last occurrence wins**.
358///
359/// ```rust
360/// #[api_error(message = "Initial")]
361/// #[api_error(status_code = 202)]
362/// #[api_error(message = "Final")]
363/// ```
364#[cfg(feature = "derive")]
365pub use api_error_derive::ApiError;
366
367/// An error that can be returned by a service API.
368/// ```
369/// # use http::StatusCode;
370/// # use api_error::ApiError;
371/// # use std::borrow::Cow;
372///
373/// #[derive(Debug, thiserror::Error)]
374/// enum MyServiceErrors {
375///     #[error("Database error: {0}")]
376///     Db(String),
377///     #[error("Authentication error")]
378///     Auth,
379/// // etc...
380/// }
381///
382/// impl ApiError for MyServiceErrors {
383///     fn status_code(&self) -> StatusCode {
384///         match self {
385///             MyServiceErrors::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
386///             MyServiceErrors::Auth => StatusCode::UNAUTHORIZED,
387///         }
388///     }
389///     fn message(&self) -> Cow<'_, str> {
390///         match self {
391///             MyServiceErrors::Db(_) => "Database error".into(),
392///             MyServiceErrors::Auth => "Authentication error".into(),
393///         }
394///     }
395/// }
396///
397/// assert_eq!(MyServiceErrors::Db("test".to_string()).status_code(), StatusCode::INTERNAL_SERVER_ERROR);
398/// assert_eq!(MyServiceErrors::Auth.status_code(), StatusCode::UNAUTHORIZED);
399pub trait ApiError: std::error::Error {
400    /// Returns the HTTP status code associated with the error.
401    fn status_code(&self) -> StatusCode {
402        StatusCode::INTERNAL_SERVER_ERROR
403    }
404
405    /// Returns a human-readable message describing the error.
406    /// It can be potentially shown to the user.
407    fn message(&self) -> Cow<'_, str> {
408        let msg = self
409            .status_code()
410            .canonical_reason()
411            .unwrap_or("Unknown error");
412
413        Cow::Borrowed(msg)
414    }
415
416    /// Returns an optional structured payload to include in the default
417    /// axum response body under the `"extended"` key.
418    ///
419    /// Returning `None` (the default) omits the field entirely. Override
420    /// this when you need to surface machine-readable details (e.g. a list
421    /// of invalid fields, a retry-after hint, an upstream error code) in
422    /// addition to the human-readable [`message`](Self::message).
423    ///
424    /// Only available with the `axum` feature.
425    #[cfg(feature = "axum")]
426    fn extended(&self) -> Option<serde_json::Value> {
427        None
428    }
429}
430
431impl ApiError for Infallible {}
432impl<T: ApiError> ApiError for &T {
433    fn status_code(&self) -> StatusCode {
434        (*self).status_code()
435    }
436
437    fn message(&self) -> Cow<'_, str> {
438        (*self).message()
439    }
440
441    #[cfg(feature = "axum")]
442    fn extended(&self) -> Option<serde_json::Value> {
443        (*self).extended()
444    }
445}
446
447/// Custom implementation for axum integration
448#[cfg(feature = "axum")]
449pub mod axum {
450    use std::sync::OnceLock;
451
452    use axum_core::{
453        body::Body,
454        response::{IntoResponse, Response},
455    };
456    use http::{HeaderValue, header::CONTENT_TYPE};
457    use serde_core::{Serialize, ser::SerializeMap};
458
459    #[doc(hidden)]
460    pub use ::axum_core as __axum_core;
461
462    use super::ApiError;
463
464    #[doc(hidden)]
465    pub static __ERROR_RESPONDER: OnceLock<ApiErrorResponder> = OnceLock::new();
466
467    /// A function that converts an [`ApiError`] into an axum [`Response`].
468    ///
469    /// Register one globally with [`set_error_responder`] to customize the
470    /// response format produced by types deriving [`ApiError`].
471    pub type ApiErrorResponder = fn(&dyn ApiError) -> Response;
472
473    /// Sets a custom [`ApiErrorResponder`] that will be used to convert
474    /// [`ApiError`] to a [`Response`].
475    ///
476    /// For a non-panicking alternative, use [`try_set_error_responder`].
477    ///
478    /// # Panics
479    ///
480    /// Panics if the responder is already set.
481    pub fn set_error_responder(f: ApiErrorResponder) {
482        __ERROR_RESPONDER
483            .set(f)
484            .expect("an api error responder should be set only once");
485    }
486
487    /// Tries to set a custom [`ApiErrorResponder`] that will be used to convert
488    /// [`ApiError`] to a [`Response`].
489    ///
490    /// # Errors
491    ///
492    /// Returns an error if the responder is already set.
493    pub fn try_set_error_responder(f: ApiErrorResponder) -> Result<(), ApiErrorResponder> {
494        __ERROR_RESPONDER.set(f)
495    }
496
497    /// The default [`ApiErrorResponder`].
498    ///
499    /// Returns a [`Response`] whose status is [`ApiError::status_code`] and
500    /// whose JSON body is:
501    ///
502    /// ```json
503    /// { "message": "<ApiError::message()>" }
504    /// ```
505    ///
506    /// When [`ApiError::extended`] returns `Some(value)`, the body also
507    /// includes an `"extended"` field carrying that value:
508    ///
509    /// ```json
510    /// { "message": "<ApiError::message()>", "extended": <value> }
511    /// ```
512    pub fn default_error_responder(api_error: &dyn ApiError) -> Response {
513        ApiErrorResponse::new(api_error).into_response()
514    }
515
516    pub struct ApiErrorResponse<'a>(&'a dyn ApiError);
517
518    impl<'a> ApiErrorResponse<'a> {
519        pub fn new(api_error: &'a dyn ApiError) -> Self {
520            Self(api_error)
521        }
522    }
523
524    impl Serialize for ApiErrorResponse<'_> {
525        fn serialize<S: serde_core::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
526            let extended = self.0.extended();
527            let message = self.0.message();
528
529            let field_cnt = 1 + usize::from(extended.is_some());
530            let mut map = serializer.serialize_map(Some(field_cnt))?;
531
532            map.serialize_entry("message", &message)?;
533
534            if let Some(v) = &extended {
535                map.serialize_entry("extended", v)?;
536            }
537
538            map.end()
539        }
540    }
541
542    impl IntoResponse for ApiErrorResponse<'_> {
543        fn into_response(self) -> Response {
544            const APPLICATION_JSON: HeaderValue = HeaderValue::from_static("application/json");
545            let body =
546                serde_json::to_vec(&self).expect("AxumApiError serialization should not fail");
547
548            let mut res = (self.0.status_code(), Body::from(body)).into_response();
549            res.headers_mut().insert(CONTENT_TYPE, APPLICATION_JSON);
550            res
551        }
552    }
553}