diff --git a/CHANGELOG.md b/CHANGELOG.md index e4546e5..957b2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.12.1] - 2025-10-30 + +### Added +- Introduced the `Context` builder for enriching error conversions with + metadata, caller tracking, and redaction hints via `ResultExt::ctx`. +- Implemented the `ResultExt` trait to wrap fallible operations into + `masterror::Error` without extra allocations while merging context fields. + +### Documentation +- Added rustdoc examples showcasing `Context` chaining and the new + `ResultExt` helper. + +### Tests +- Added unit coverage for `ResultExt::ctx`, ensuring happy-path results pass + through and error branches preserve metadata and sources. + ## [0.12.0] - 2025-10-29 ### Added diff --git a/Cargo.lock b/Cargo.lock index 87dfc89..2c79379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.12.0" +version = "0.12.1" dependencies = [ "actix-web", "axum", diff --git a/Cargo.toml b/Cargo.toml index 83bd271..523c059 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.12.0" +version = "0.12.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 2695548..49416f2 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.12.0", default-features = false } +masterror = { version = "0.12.1", default-features = false } # or with features: -# masterror = { version = "0.12.0", features = [ +# masterror = { version = "0.12.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -75,10 +75,10 @@ masterror = { version = "0.12.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.12.0", default-features = false } +masterror = { version = "0.12.1", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.12.0", features = [ +# masterror = { version = "0.12.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -637,13 +637,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.12.0", default-features = false } +masterror = { version = "0.12.1", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.12.0", features = [ +masterror = { version = "0.12.1", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -652,7 +652,7 @@ masterror = { version = "0.12.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.12.0", features = [ +masterror = { version = "0.12.1", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index 54a1279..855fe37 100644 --- a/README.ru.md +++ b/README.ru.md @@ -38,9 +38,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.12.0", default-features = false } +masterror = { version = "0.12.1", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.12.0", features = [ +# masterror = { version = "0.12.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", diff --git a/src/app_error.rs b/src/app_error.rs index cc5e928..c34406c 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -60,11 +60,13 @@ //! transport boundary (e.g. in `IntoResponse`) to avoid duplicate logs. mod constructors; +mod context; mod core; mod metadata; pub use core::{AppError, AppResult, Error, MessageEditPolicy}; +pub use context::Context; pub use metadata::{Field, FieldValue, Metadata, field}; #[cfg(test)] diff --git a/src/app_error/context.rs b/src/app_error/context.rs new file mode 100644 index 0000000..61b1901 --- /dev/null +++ b/src/app_error/context.rs @@ -0,0 +1,141 @@ +use std::{error::Error as StdError, panic::Location}; + +use super::{ + core::{AppError, Error, MessageEditPolicy}, + metadata::{Field, FieldValue} +}; +use crate::{AppCode, AppErrorKind}; + +/// Builder describing how to convert an external error into [`AppError`]. +/// +/// The context captures the target [`AppCode`], [`AppErrorKind`], optional +/// metadata fields and redaction policy. It is primarily consumed by +/// [`ResultExt`](crate::ResultExt) when promoting `Result` values into +/// [`AppError`]. +/// +/// # Examples +/// +/// ```rust +/// use std::io::{Error as IoError, ErrorKind}; +/// +/// use masterror::{AppErrorKind, Context, ResultExt, field}; +/// +/// fn failing_io() -> Result<(), IoError> { +/// Err(IoError::from(ErrorKind::Other)) +/// } +/// +/// let err = failing_io() +/// .ctx(|| { +/// Context::new(AppErrorKind::Service) +/// .with(field::str("operation", "sync")) +/// .redact(true) +/// .track_caller() +/// }) +/// .unwrap_err(); +/// +/// assert_eq!(err.kind, AppErrorKind::Service); +/// assert!(err.metadata().get("operation").is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Context { + code: AppCode, + category: AppErrorKind, + fields: Vec, + edit_policy: MessageEditPolicy, + caller_location: Option<&'static Location<'static>>, + code_overridden: bool +} + +impl Context { + /// Create a new [`Context`] targeting the provided [`AppErrorKind`]. + /// + /// The initial [`AppCode`] defaults to the canonical mapping for the + /// supplied kind. Use [`Context::code`] to override it. + #[must_use] + pub fn new(category: AppErrorKind) -> Self { + Self { + code: AppCode::from(category), + category, + fields: Vec::new(), + edit_policy: MessageEditPolicy::Preserve, + caller_location: None, + code_overridden: false + } + } + + /// Override the public [`AppCode`]. + #[must_use] + pub fn code(mut self, code: AppCode) -> Self { + self.code = code; + self.code_overridden = true; + self + } + + /// Update the [`AppErrorKind`]. + /// + /// When the code has not been overridden explicitly, it is kept in sync + /// with the new kind. + #[must_use] + pub fn category(mut self, category: AppErrorKind) -> Self { + self.category = category; + if !self.code_overridden { + self.code = AppCode::from(category); + } + self + } + + /// Attach a metadata [`Field`]. + #[must_use] + pub fn with(mut self, field: Field) -> Self { + self.fields.push(field); + self + } + + /// Toggle message redaction policy. + #[must_use] + pub fn redact(mut self, redact: bool) -> Self { + self.edit_policy = if redact { + MessageEditPolicy::Redact + } else { + MessageEditPolicy::Preserve + }; + self + } + + /// Capture caller location and store it as metadata. + #[must_use] + #[track_caller] + pub fn track_caller(mut self) -> Self { + self.caller_location = Some(Location::caller()); + self + } + + pub(crate) fn into_error(mut self, source: E) -> Error + where + E: StdError + Send + Sync + 'static + { + if let Some(location) = self.caller_location { + self.fields.push(Field::new( + "caller.file", + FieldValue::Str(location.file().into()) + )); + self.fields.push(Field::new( + "caller.line", + FieldValue::U64(u64::from(location.line())) + )); + self.fields.push(Field::new( + "caller.column", + FieldValue::U64(u64::from(location.column())) + )); + } + + let mut error = AppError::bare(self.category).with_code(self.code); + if !self.fields.is_empty() { + error = error.with_fields(self.fields); + } + if matches!(self.edit_policy, MessageEditPolicy::Redact) { + error = error.redactable(); + } + error.with_source(source) + } +} diff --git a/src/lib.rs b/src/lib.rs index 8834eed..67dbcd2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -233,6 +233,7 @@ mod kind; #[doc(hidden)] pub mod provide; mod response; +mod result_ext; #[cfg(feature = "frontend")] #[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] @@ -246,7 +247,7 @@ pub mod turnkey; pub mod prelude; pub use app_error::{ - AppError, AppResult, Error, Field, FieldValue, MessageEditPolicy, Metadata, field + AppError, AppResult, Context, Error, Field, FieldValue, MessageEditPolicy, Metadata, field }; pub use code::AppCode; pub use kind::AppErrorKind; @@ -278,3 +279,4 @@ pub use kind::AppErrorKind; /// ``` pub use masterror_derive::*; pub use response::{ErrorResponse, RetryAdvice}; +pub use result_ext::ResultExt; diff --git a/src/result_ext.rs b/src/result_ext.rs new file mode 100644 index 0000000..87f3a9f --- /dev/null +++ b/src/result_ext.rs @@ -0,0 +1,152 @@ +use std::error::Error as StdError; + +use crate::app_error::{Context, Error}; + +/// Extension trait for enriching `Result` errors with [`Context`]. +/// +/// The [`ctx`](ResultExt::ctx) method converts the error side of a `Result` +/// into [`Error`] while attaching metadata, category and edit policy captured +/// by [`Context`]. +/// +/// # Examples +/// +/// ```rust +/// use std::io::{Error as IoError, ErrorKind}; +/// +/// use masterror::{AppErrorKind, Context, ResultExt, field}; +/// +/// fn validate() -> Result<(), IoError> { +/// Err(IoError::from(ErrorKind::Other)) +/// } +/// +/// let err = validate() +/// .ctx(|| Context::new(AppErrorKind::Validation).with(field::str("phase", "validate"))) +/// .unwrap_err(); +/// +/// assert_eq!(err.kind, AppErrorKind::Validation); +/// assert!(err.metadata().get("phase").is_some()); +/// ``` +pub trait ResultExt { + /// Convert an error into [`Error`] using [`Context`] supplied by `build`. + #[allow(clippy::result_large_err)] + fn ctx(self, build: impl FnOnce() -> Context) -> Result + where + E: StdError + Send + Sync + 'static; +} + +impl ResultExt for Result { + fn ctx(self, build: impl FnOnce() -> Context) -> Result + where + E: StdError + Send + Sync + 'static + { + self.map_err(|err| build().into_error(err)) + } +} + +#[cfg(test)] +mod tests { + use std::{ + borrow::Cow, + error::Error as StdError, + fmt::{Display, Formatter, Result as FmtResult}, + sync::Arc + }; + + use super::ResultExt; + use crate::{ + AppCode, AppErrorKind, + app_error::{Context, FieldValue, MessageEditPolicy}, + field + }; + + #[derive(Debug)] + struct DummyError; + + impl Display for DummyError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + f.write_str("dummy") + } + } + + impl StdError for DummyError {} + + #[test] + fn ctx_preserves_ok() { + let res: Result = Ok(5); + let value = res + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect("ok"); + assert_eq!(value, 5); + } + + #[test] + fn ctx_wraps_err_with_context() { + let result: Result<(), DummyError> = Err(DummyError); + let err = result + .ctx(|| { + Context::new(AppErrorKind::Service) + .with(field::str("operation", "sync")) + .redact(true) + .track_caller() + }) + .expect_err("err"); + + assert_eq!(err.kind, AppErrorKind::Service); + assert_eq!(err.code, AppCode::Service); + assert!(matches!(err.edit_policy, MessageEditPolicy::Redact)); + + let metadata = err.metadata(); + assert_eq!( + metadata.get("operation"), + Some(&FieldValue::Str(Cow::Borrowed("sync"))) + ); + let caller_file = metadata.get("caller.file").expect("caller file field"); + assert_eq!(caller_file, &FieldValue::Str(Cow::Borrowed(file!()))); + assert!(metadata.get("caller.line").is_some()); + assert!(metadata.get("caller.column").is_some()); + } + + #[derive(Debug, Clone)] + struct SharedError(Arc); + + #[derive(Debug)] + struct InnerError; + + impl Display for InnerError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + f.write_str("inner") + } + } + + impl StdError for InnerError {} + + impl Display for SharedError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&*self.0, f) + } + } + + impl StdError for SharedError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&*self.0) + } + } + + #[test] + fn ctx_preserves_source_without_extra_arc_clone() { + let inner = Arc::new(InnerError); + let shared = SharedError(inner.clone()); + let err = Result::<(), SharedError>::Err(shared.clone()) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + + drop(shared); + assert_eq!(Arc::strong_count(&inner), 2); + + let stored = err + .source_ref() + .and_then(|src| src.downcast_ref::()) + .expect("shared source"); + assert!(Arc::ptr_eq(&stored.0, &inner)); + } +}