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