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