From 5f95892b54ed477f9617c13cab904aae1214662b Mon Sep 17 00:00:00 2001 From: totodore Date: Tue, 19 May 2026 09:23:18 +0200 Subject: [PATCH] feat: add extended method behind axum feature flag --- README.md | 42 +++++++++++-- api-error/src/lib.rs | 110 +++++++++++++++++++++++++++++------ api-error/tests/responder.rs | 43 ++++++++++++++ 3 files changed, 173 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index df1b398..c5efb2e 100644 --- a/README.md +++ b/README.md @@ -165,12 +165,46 @@ let app: Router = Router::new().route("/", get(handler)); // Body: {"message": "Resource not found"} ``` +### Attaching extended data to the response + +Override `ApiError::extended` to attach a structured payload that the default +responder will serialize under an `"extended"` key alongside `"message"`. +Returning `None` (the default) omits the field entirely. + +```rust +use api_error::ApiError; +use http::StatusCode; +use serde_json::json; +use std::borrow::Cow; + +#[derive(Debug, thiserror::Error)] +#[error("validation failed")] +struct ValidationError { + field: &'static str, +} + +impl ApiError for ValidationError { + fn status_code(&self) -> StatusCode { StatusCode::UNPROCESSABLE_ENTITY } + fn message(&self) -> Cow<'_, str> { Cow::Borrowed("validation failed") } + fn extended(&self) -> Option { + Some(json!({ "field": self.field })) + } +} + +// Resulting JSON body: +// {"message": "validation failed", "extended": {"field": "email"}} +``` + +> Note: when using `#[derive(ApiError)]`, the generated `impl` covers all trait +> methods, so overriding `extended` requires writing the `impl` manually. + ### Customizing axum error response format -The default response body is `{"message": ""}` with the error's HTTP -status code. To use a different format, register a custom responder once at -startup with `api_error::axum::set_error_responder`. Every type deriving -`ApiError` will route through it. +The default response body is `{"message": ""}` (plus an `"extended"` +field when `ApiError::extended` returns `Some`) with the error's HTTP status +code. To use a different format, register a custom responder once at startup +with `api_error::axum::set_error_responder`. Every type deriving `ApiError` +will route through it. ```rust use api_error::ApiError; diff --git a/api-error/src/lib.rs b/api-error/src/lib.rs index 1eb13c8..a6db11a 100644 --- a/api-error/src/lib.rs +++ b/api-error/src/lib.rs @@ -156,12 +156,46 @@ //! // Body: {"message": "Resource not found"} //! ``` //! +//! ### Attaching extended data to the response +//! +//! Override [`ApiError::extended`] to attach a structured payload that the +//! default responder will serialize under an `"extended"` key alongside +//! `"message"`. Returning `None` (the default) omits the field entirely. +//! +//! ```rust +//! # use api_error::ApiError; +//! # use http::StatusCode; +//! # use serde_json::json; +//! # use std::borrow::Cow; +//! #[derive(Debug, thiserror::Error)] +//! #[error("validation failed")] +//! struct ValidationError { +//! field: &'static str, +//! } +//! +//! impl ApiError for ValidationError { +//! fn status_code(&self) -> StatusCode { StatusCode::UNPROCESSABLE_ENTITY } +//! fn message(&self) -> Cow<'_, str> { Cow::Borrowed("validation failed") } +//! fn extended(&self) -> Option { +//! Some(json!({ "field": self.field })) +//! } +//! } +//! +//! // Resulting JSON body: +//! // {"message": "validation failed", "extended": {"field": "email"}} +//! ``` +//! +//! Note: when using `#[derive(ApiError)]`, the generated `impl` covers all +//! trait methods, so overriding `extended` requires writing the `impl` +//! manually. +//! //! ### Customizing axum error response format //! -//! The default response body is `{"message": ""}` with the error's -//! HTTP status code. To use a different format, register a custom responder -//! once at startup with [`axum::set_error_responder`]. Every type deriving -//! [`ApiError`] will route through it. +//! The default response body is `{"message": ""}` (plus an +//! `"extended"` field when [`ApiError::extended`] returns `Some`) with the +//! error's HTTP status code. To use a different format, register a custom +//! responder once at startup with [`axum::set_error_responder`]. Every type +//! deriving [`ApiError`] will route through it. //! //! ```no_run //! use api_error::ApiError; @@ -189,7 +223,7 @@ #[doc = include_str!("../../README.md")] mod _readme_doctest {} -use std::borrow::Cow; +use std::{borrow::Cow, convert::Infallible}; use http::StatusCode; @@ -372,18 +406,47 @@ pub trait ApiError: std::error::Error { Cow::Borrowed(msg) } + + /// Returns an optional structured payload to include in the default + /// axum response body under the `"extended"` key. + /// + /// Returning `None` (the default) omits the field entirely. Override + /// this when you need to surface machine-readable details (e.g. a list + /// of invalid fields, a retry-after hint, an upstream error code) in + /// addition to the human-readable [`message`](Self::message). + /// + /// Only available with the `axum` feature. + #[cfg(feature = "axum")] + fn extended(&self) -> Option { + None + } +} + +impl ApiError for Infallible {} +impl ApiError for &T { + fn status_code(&self) -> StatusCode { + (*self).status_code() + } + + fn message(&self) -> Cow<'_, str> { + (*self).message() + } + + #[cfg(feature = "axum")] + fn extended(&self) -> Option { + (*self).extended() + } } /// Custom implementation for axum integration #[cfg(feature = "axum")] pub mod axum { - use std::{borrow::Cow, sync::OnceLock}; + use std::sync::OnceLock; use axum_core::{ body::Body, response::{IntoResponse, Response}, }; - use http::StatusCode; use serde_core::{Serialize, ser::SerializeMap}; #[doc(hidden)] @@ -432,28 +495,39 @@ pub mod axum { /// ```json /// { "message": "" } /// ``` + /// + /// When [`ApiError::extended`] returns `Some(value)`, the body also + /// includes an `"extended"` field carrying that value: + /// + /// ```json + /// { "message": "", "extended": } + /// ``` pub fn default_error_responder(api_error: &dyn ApiError) -> Response { ApiErrorResponse::new(api_error).into_response() } - pub struct ApiErrorResponse<'a> { - message: Cow<'a, str>, - status_code: StatusCode, - } + pub struct ApiErrorResponse<'a>(&'a dyn ApiError); impl<'a> ApiErrorResponse<'a> { pub fn new(api_error: &'a dyn ApiError) -> Self { - Self { - message: api_error.message(), - status_code: api_error.status_code(), - } + Self(api_error) } } impl Serialize for ApiErrorResponse<'_> { fn serialize(&self, serializer: S) -> Result { - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("message", &self.message)?; + let extended = self.0.extended(); + let message = self.0.message(); + + let field_cnt = 1 + usize::from(extended.is_some()); + let mut map = serializer.serialize_map(Some(field_cnt))?; + + map.serialize_entry("message", &message)?; + + if let Some(v) = &extended { + map.serialize_entry("extended", v)?; + } + map.end() } } @@ -463,7 +537,7 @@ pub mod axum { let body = serde_json::to_vec(&self).expect("AxumApiError serialization should not fail"); - (self.status_code, Body::from(body)).into_response() + (self.0.status_code(), Body::from(body)).into_response() } } } diff --git a/api-error/tests/responder.rs b/api-error/tests/responder.rs index 801a894..e3424a2 100644 --- a/api-error/tests/responder.rs +++ b/api-error/tests/responder.rs @@ -3,17 +3,39 @@ #![cfg(feature = "axum")] +use std::borrow::Cow; + use api_error::{ ApiError, axum::{ApiErrorResponse, set_error_responder}, }; use axum_core::response::{IntoResponse, Response}; +use http::StatusCode; +use serde_json::json; #[derive(Debug, thiserror::Error, ApiError)] #[error("boom")] #[api_error(status_code = 418, message = "I'm a teapot")] struct TeapotError; +#[derive(Debug, thiserror::Error)] +#[error("validation failed")] +struct ValidationError; + +impl ApiError for ValidationError { + fn status_code(&self) -> StatusCode { + StatusCode::UNPROCESSABLE_ENTITY + } + + fn message(&self) -> Cow<'_, str> { + Cow::Borrowed("validation failed") + } + + fn extended(&self) -> Option { + Some(json!({ "field": "email" })) + } +} + fn custom_responder(err: &dyn ApiError) -> Response { let mut resp = ApiErrorResponse::new(err).into_response(); resp.headers_mut() @@ -41,3 +63,24 @@ fn default_responder_forwards_status_code() { let resp = TeapotError.into_response(); assert_eq!(resp.status(), 418); } + +#[test] +fn extended_defaults_to_none_for_derived_errors() { + assert!(TeapotError.extended().is_none()); +} + +#[test] +fn extended_appears_in_response_body() { + let body = serde_json::to_string(&ApiErrorResponse::new(&ValidationError)).unwrap(); + assert_eq!( + body, + r#"{"message":"validation failed","extended":{"field":"email"}}"# + ); +} + +#[test] +fn extended_forwarded_through_reference() { + let err = ValidationError; + let by_ref: &ValidationError = &err; + assert_eq!(by_ref.extended(), Some(json!({ "field": "email" }))); +}