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}