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
160use std::borrow::Cow;
161
162use http::StatusCode;
163
164/// Derive macro for implementing [`ApiError`] on enums and structs.
165///
166/// This macro generates an [`ApiError`] implementation based on
167/// `#[api_error(...)]` attributes, removing the need to write
168/// `status_code()` and `message()` by hand.
169///
170/// It is intended to be used together with `thiserror::Error`.
171///
172/// ---
173///
174/// # Basic usage
175///
176/// ```
177/// # use api_error::ApiError;
178///
179/// #[derive(Debug, thiserror::Error, ApiError)]
180/// enum MyError {
181/// #[error("Internal failure")]
182/// #[api_error(status_code = 500, message = "Something went wrong")]
183/// Failure,
184/// }
185/// ```
186///
187/// ---
188///
189/// # `#[api_error]` attribute
190///
191/// The `#[api_error(...)]` attribute may be applied to:
192/// - enums
193/// - enum variants
194/// - structs
195///
196/// ## `status_code`
197///
198/// Sets the HTTP status code returned by the generated implementation.
199///
200/// You can either use the [`StatusCode`] enum or
201/// a status code literal:
202///
203/// ```
204/// # use api_error::ApiError;
205/// # use http::StatusCode;
206/// #[derive(Debug, thiserror::Error, ApiError)]
207/// enum MyError {
208/// #[api_error(status_code = 400)]
209/// #[error("Got error because of A")]
210/// ReasonA,
211///
212/// #[api_error(status_code = StatusCode::CONFLICT)]
213/// #[error("Got error because of B")]
214/// ReasonB,
215/// }
216/// assert_eq!(MyError::ReasonB.status_code(), StatusCode::CONFLICT)
217/// ```
218///
219/// If omitted, the status code defaults to
220/// `500 Internal Server Error`.
221///
222/// ---
223///
224/// ## `message`
225///
226/// Sets the client-facing error message.
227///
228/// ```ignore
229/// # use api_error::ApiError;
230/// # use http::StatusCode;
231/// #[api_error(message = "Invalid input")]
232/// ```
233///
234/// The message supports formatting using:
235/// - tuple indices (`{0}`, `{1}`, …)
236/// - named fields (`{field}`)
237///
238/// If omitted, the HTTP status reason phrase is used.
239///
240/// ---
241///
242/// ## `message(inherit)`
243///
244/// Uses the type’s `Display` implementation (from `thiserror`)
245/// as the API error message.
246///
247/// ```ignore
248/// #[error("Forbidden")]
249/// #[api_error(message(inherit))]
250/// struct Forbidden;
251/// ```
252///
253/// ---
254///
255/// ## `transparent`
256///
257/// Marks the type as a transparent wrapper around another [`ApiError`].
258///
259/// ```
260/// # use api_error::ApiError;
261/// #[derive(Debug, thiserror::Error, ApiError)]
262/// #[error(transparent)]
263/// #[api_error(transparent)]
264/// struct Wrapper(InnerError);
265///
266/// #[derive(Debug, thiserror::Error, ApiError)]
267/// #[error("My inner error")]
268/// struct InnerError;
269/// ```
270///
271/// ### Rules
272///
273/// - `transparent` must be used **alone**
274/// - all API metadata is delegated to the wrapped error
275///
276/// ---
277///
278/// # Multiple attributes
279///
280/// Multiple `#[api_error]` attributes may be used.
281/// When the same field is specified multiple times,
282/// the **last occurrence wins**.
283///
284/// ```rust
285/// #[api_error(message = "Initial")]
286/// #[api_error(status_code = 202)]
287/// #[api_error(message = "Final")]
288/// ```
289#[cfg(feature = "derive")]
290pub use api_error_derive::ApiError;
291
292/// An error that can be returned by a service API.
293/// ```
294/// # use http::StatusCode;
295/// # use api_error::ApiError;
296/// # use std::borrow::Cow;
297///
298/// #[derive(Debug, thiserror::Error)]
299/// enum MyServiceErrors {
300/// #[error("Database error: {0}")]
301/// Db(String),
302/// #[error("Authentication error")]
303/// Auth,
304/// // etc...
305/// }
306///
307/// impl ApiError for MyServiceErrors {
308/// fn status_code(&self) -> StatusCode {
309/// match self {
310/// MyServiceErrors::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
311/// MyServiceErrors::Auth => StatusCode::UNAUTHORIZED,
312/// }
313/// }
314/// fn message(&self) -> Cow<'_, str> {
315/// match self {
316/// MyServiceErrors::Db(_) => "Database error".into(),
317/// MyServiceErrors::Auth => "Authentication error".into(),
318/// }
319/// }
320/// }
321///
322/// assert_eq!(MyServiceErrors::Db("test".to_string()).status_code(), StatusCode::INTERNAL_SERVER_ERROR);
323/// assert_eq!(MyServiceErrors::Auth.status_code(), StatusCode::UNAUTHORIZED);
324pub trait ApiError: std::error::Error {
325 /// Returns the HTTP status code associated with the error.
326 fn status_code(&self) -> StatusCode {
327 StatusCode::INTERNAL_SERVER_ERROR
328 }
329
330 /// Returns a human-readable message describing the error.
331 /// It can be potentially shown to the user.
332 fn message(&self) -> Cow<'_, str> {
333 let msg = self
334 .status_code()
335 .canonical_reason()
336 .unwrap_or("Unknown error");
337
338 Cow::Borrowed(msg)
339 }
340}
341
342/// Custom implementation for axum integration
343#[cfg(feature = "axum")]
344pub mod axum {
345 use std::borrow::Cow;
346
347 use axum_core::{
348 body::Body,
349 response::{IntoResponse, Response},
350 };
351 use http::StatusCode;
352 use serde_core::{Serialize, ser::SerializeMap};
353
354 use crate::ApiError;
355
356 pub struct ApiErrorResponse<'a> {
357 message: Cow<'a, str>,
358 status_code: StatusCode,
359 }
360
361 impl<'a> ApiErrorResponse<'a> {
362 pub fn new(api_error: &'a dyn ApiError) -> Self {
363 Self {
364 message: api_error.message(),
365 status_code: api_error.status_code(),
366 }
367 }
368 }
369
370 impl Serialize for ApiErrorResponse<'_> {
371 fn serialize<S: serde_core::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
372 let mut map = serializer.serialize_map(Some(1))?;
373 map.serialize_entry("message", &self.message)?;
374 map.end()
375 }
376 }
377
378 impl IntoResponse for ApiErrorResponse<'_> {
379 fn into_response(self) -> Response {
380 let body =
381 serde_json::to_vec(&self).expect("AxumApiError serialization should not fail");
382
383 (self.status_code, Body::from(body)).into_response()
384 }
385 }
386}