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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.23.0] - 2025-10-12

### Added
- Added feature-gated detail payload storage to `AppError` with new
`with_details`, `with_details_json`, and `with_details_text` helpers plus unit
tests covering both serde-json configurations.
- Exposed the stored details through `ProblemJson` and legacy `ErrorResponse`
conversions so RFC7807 and historical payloads emit the supplied data.

### Changed
- Updated the documentation set to highlight the new helpers and clarify
feature requirements for attaching structured details.

## [0.22.0] - 2025-10-11

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "masterror"
version = "0.22.0"
version = "0.23.0"
rust-version = "1.90"
edition = "2024"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with

~~~toml
[dependencies]
masterror = { version = "0.22.0", default-features = false }
masterror = { version = "0.23.0", default-features = false }
# or with features:
# masterror = { version = "0.22.0", features = [
# masterror = { version = "0.23.0", features = [
# "std", "axum", "actix", "openapi",
# "serde_json", "tracing", "metrics", "backtrace",
# "sqlx", "sqlx-migrate", "reqwest", "redis",
Expand Down
14 changes: 9 additions & 5 deletions docs/wiki/masterror-application-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ pub fn parse_payload(json: &str) -> masterror::AppResult<&str> {
```

`with_context` stores the original `serde_json::Error` for logging; clients only
see the sanitized message, code, and JSON details.
see the sanitized message, code, and JSON details. Enable the `serde_json`
feature to use `.with_details(..)`; without it, fall back to
`AppError::with_details_text` for plain-text payloads.

## Deriving domain errors

Expand Down Expand Up @@ -154,10 +156,12 @@ fn missing_field_is_bad_request() {
assert!(matches!(err.kind, AppErrorKind::BadRequest));
assert_eq!(err.code.unwrap().as_str(), "MISSING_FIELD");

let response: masterror::ErrorResponse = err.clone().into();
assert_eq!(response.status.as_u16(), 400);
let response: masterror::ErrorResponse = (&err).into();
assert_eq!(response.status, 400);
assert!(response.details.is_some());
}
```

Cloning is cheap because `AppError` stores data on the stack and shares context
via `Arc` under the hood. Use these assertions to guarantee stable APIs.
Use these assertions to guarantee stable APIs without exposing secrets. Borrowed
conversions (`(&err).into()`) preserve the original error so it can be reused in
additional assertions.
14 changes: 6 additions & 8 deletions docs/wiki/patterns-and-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,28 +56,26 @@ pub fn validate(payload: &CreateUser) -> masterror::AppResult<()> {
```

`validator::ValidationErrors` implements `Serialize`, so it plugs directly into
`with_details`.
`with_details`. When `serde_json` is disabled, switch to
`AppError::with_details_text`.

## Emitting HTTP responses manually

Sometimes you need to control the HTTP layer yourself (e.g., custom middleware).
Convert `AppError` into `ErrorResponse` and format it however you need.

```rust
fn to_json(err: &masterror::AppError) -> serde_json::Value {
let response: masterror::ErrorResponse = err.clone().into();
fn to_json(err: masterror::AppError) -> serde_json::Value {
let response: masterror::ErrorResponse = err.into();
serde_json::json!({
"status": response.status.as_u16(),
"status": response.status,
"code": response.code,
"message": response.message,
"details": response.details,
})
}
```

The clone is cheap because `AppError` uses shared references for heavy context
objects.

## Capturing reproducible logs

1. Log errors at the boundary with `tracing::error!`, including `kind`,
Expand Down Expand Up @@ -109,7 +107,7 @@ reconstruct what happened.
| Validation failures return HTTP 500 | Enable the `validator` feature and expose handlers as `AppResult<T>`. |
| JSON response lacks `code` | Call `.with_code(AppCode::new("..."))` or derive it via `#[app_error(code = ...)]`. |
| Logs show duplicated errors | Log once per request at the boundary; do not log again inside helpers. |
| `with_details` fails to compile | Ensure the value implements `Serialize` (derive or implement it manually). |
| `with_details` fails to compile | Ensure the value implements `Serialize` and enable the `serde_json` feature, or call `with_details_text`. |
| Need to inspect nested errors | Call `err.context()` to retrieve captured sources, including `anyhow::Error`. |

## Testing strategies
Expand Down
97 changes: 97 additions & 0 deletions src/app_error/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ use std::{
}
};

#[cfg(feature = "serde_json")]
use serde::Serialize;
#[cfg(feature = "serde_json")]
use serde_json::{Value as JsonValue, to_value};
#[cfg(feature = "tracing")]
use tracing::{Level, event};

Expand Down Expand Up @@ -61,6 +65,12 @@ pub struct ErrorInner {
pub retry: Option<RetryAdvice>,
/// Optional authentication challenge for `WWW-Authenticate`.
pub www_authenticate: Option<String>,
/// Optional structured details exposed to clients.
#[cfg(feature = "serde_json")]
pub details: Option<JsonValue>,
/// Optional textual details when JSON is unavailable.
#[cfg(not(feature = "serde_json"))]
pub details: Option<String>,
pub source: Option<Arc<dyn CoreError + Send + Sync + 'static>>,
#[cfg(feature = "backtrace")]
pub backtrace: Option<Backtrace>,
Expand Down Expand Up @@ -238,6 +248,7 @@ impl Error {
edit_policy: MessageEditPolicy::Preserve,
retry: None,
www_authenticate: None,
details: None,
source: None,
#[cfg(feature = "backtrace")]
backtrace: None,
Expand Down Expand Up @@ -461,6 +472,92 @@ impl Error {
self
}

/// Attach structured JSON details for the client payload.
///
/// The details are omitted from responses when the error has been marked as
/// [`redactable`](Self::redactable).
///
/// # Examples
///
/// ```rust
/// # #[cfg(feature = "serde_json")]
/// # {
/// use masterror::{AppError, AppErrorKind};
/// use serde_json::json;
///
/// let err = AppError::new(AppErrorKind::Validation, "invalid input")
/// .with_details_json(json!({"field": "email"}));
/// assert!(err.details.is_some());
/// # }
/// ```
#[must_use]
#[cfg(feature = "serde_json")]
pub fn with_details_json(mut self, details: JsonValue) -> Self {
self.details = Some(details);
self.mark_dirty();
self
}

/// Serialize and attach structured details.
///
/// Returns [`AppError`] with [`AppErrorKind::BadRequest`] if serialization
/// fails.
///
/// # Examples
///
/// ```rust
/// # #[cfg(feature = "serde_json")]
/// # {
/// use masterror::{AppError, AppErrorKind};
/// use serde::Serialize;
///
/// #[derive(Serialize)]
/// struct Extra {
/// reason: &'static str
/// }
///
/// let err = AppError::new(AppErrorKind::BadRequest, "invalid")
/// .with_details(Extra {
/// reason: "missing"
/// })
/// .expect("details should serialize");
/// assert!(err.details.is_some());
/// # }
/// ```
#[cfg(feature = "serde_json")]
#[allow(clippy::result_large_err)]
pub fn with_details<T>(self, payload: T) -> crate::AppResult<Self>
where
T: Serialize
{
let details = to_value(payload).map_err(|err| Self::bad_request(err.to_string()))?;
Ok(self.with_details_json(details))
}

/// Attach plain-text details for client payloads.
///
/// The text is omitted from responses when the error is
/// [`redactable`](Self::redactable).
///
/// # Examples
///
/// ```rust
/// # #[cfg(not(feature = "serde_json"))]
/// # {
/// use masterror::{AppError, AppErrorKind};
///
/// let err = AppError::new(AppErrorKind::Internal, "boom").with_details_text("retry later");
/// assert!(err.details.is_some());
/// # }
/// ```
#[must_use]
#[cfg(not(feature = "serde_json"))]
pub fn with_details_text(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self.mark_dirty();
self
}

/// Borrow the attached metadata.
#[must_use]
pub fn metadata(&self) -> &Metadata {
Expand Down
39 changes: 39 additions & 0 deletions src/app_error/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,45 @@ fn metadata_and_code_are_preserved() {
assert_eq!(metadata.get("attempt"), Some(&FieldValue::I64(2)));
}

#[cfg(feature = "serde_json")]
#[test]
fn with_details_json_attaches_payload() {
use serde_json::json;

let payload = json!({"field": "email"});
let err = AppError::validation("invalid").with_details_json(payload.clone());
assert_eq!(err.details, Some(payload));
}

#[cfg(feature = "serde_json")]
#[test]
fn with_details_serialization_failure_is_bad_request() {
use serde::{Serialize, Serializer};

struct Failing;

impl Serialize for Failing {
fn serialize<S>(&self, _: S) -> Result<S::Ok, S::Error>
where
S: Serializer
{
Err(serde::ser::Error::custom("nope"))
}
}

let err = AppError::internal("boom")
.with_details(Failing)
.expect_err("should fail");
assert!(matches!(err.kind, AppErrorKind::BadRequest));
}

#[cfg(not(feature = "serde_json"))]
#[test]
fn with_details_text_attaches_payload() {
let err = AppError::internal("boom").with_details_text("retry later");
assert_eq!(err.details.as_deref(), Some("retry later"));
}

#[test]
fn context_redact_field_overrides_policy() {
let err = super::Context::new(AppErrorKind::Service)
Expand Down
1 change: 1 addition & 0 deletions src/response/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ impl Debug for ProblemJsonFormatter<'_> {
.field("title", &self.inner.title)
.field("status", &self.inner.status)
.field("detail", &self.inner.detail)
.field("details", &self.inner.details)
.field("code", &self.inner.code)
.field("grpc", &self.inner.grpc)
.field("metadata", &self.inner.metadata)
Expand Down
28 changes: 26 additions & 2 deletions src/response/mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,24 @@ impl From<AppError> for ErrorResponse {
Some(msg) if !matches!(policy, crate::MessageEditPolicy::Redact) => msg.into_owned(),
_ => kind.to_string()
};
#[cfg(feature = "serde_json")]
let details = if matches!(policy, crate::MessageEditPolicy::Redact) {
None
} else {
err.details.take()
};
#[cfg(not(feature = "serde_json"))]
let details = if matches!(policy, crate::MessageEditPolicy::Redact) {
None
} else {
err.details.take()
};

Self {
status,
code,
message,
details: None,
details,
retry,
www_authenticate
}
Expand All @@ -44,12 +56,24 @@ impl From<&AppError> for ErrorResponse {
} else {
err.render_message().into_owned()
};
#[cfg(feature = "serde_json")]
let details = if matches!(err.edit_policy, crate::MessageEditPolicy::Redact) {
None
} else {
err.details.clone()
};
#[cfg(not(feature = "serde_json"))]
let details = if matches!(err.edit_policy, crate::MessageEditPolicy::Redact) {
None
} else {
err.details.clone()
};

Self {
status,
code: err.code,
message,
details: None,
details,
retry: err.retry,
www_authenticate: err.www_authenticate.clone()
}
Expand Down
Loading
Loading