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