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