Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value> {
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": "<error msg>"}` 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": "<error msg>"}` (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;
Expand Down
110 changes: 92 additions & 18 deletions api-error/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,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<serde_json::Value> {
//! 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": "<error msg>"}` 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": "<error msg>"}` (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;
Expand Down Expand Up @@ -190,7 +224,7 @@
#[doc = include_str!("../../README.md")]
mod _readme_doctest {}

use std::borrow::Cow;
use std::{borrow::Cow, convert::Infallible};

use http::StatusCode;

Expand Down Expand Up @@ -373,18 +407,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<serde_json::Value> {
None
}
}

impl ApiError for Infallible {}
impl<T: ApiError> 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<serde_json::Value> {
(*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)]
Expand Down Expand Up @@ -433,28 +496,39 @@ pub mod axum {
/// ```json
/// { "message": "<ApiError::message()>" }
/// ```
///
/// When [`ApiError::extended`] returns `Some(value)`, the body also
/// includes an `"extended"` field carrying that value:
///
/// ```json
/// { "message": "<ApiError::message()>", "extended": <value> }
/// ```
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<S: serde_core::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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()
}
}
Expand All @@ -464,7 +538,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()
}
}
}
43 changes: 43 additions & 0 deletions api-error/tests/responder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value> {
Some(json!({ "field": "email" }))
}
}

fn custom_responder(err: &dyn ApiError) -> Response {
let mut resp = ApiErrorResponse::new(err).into_response();
resp.headers_mut()
Expand Down Expand Up @@ -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" })));
}
Loading