From 19dbce016abee3f4e49b8ed4059923e1ea0e3332 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 08:25:52 +0700 Subject: [PATCH] feat: add metadata container and richer app error --- CHANGELOG.md | 16 +++ Cargo.lock | 3 +- Cargo.toml | 5 +- README.md | 23 ++-- README.ru.md | 13 +- README.template.md | 9 +- src/app_error.rs | 7 +- src/app_error/constructors.rs | 9 +- src/app_error/core.rs | 203 ++++++++++++++++++++++----- src/app_error/metadata.rs | 253 ++++++++++++++++++++++++++++++++++ src/app_error/tests.rs | 66 ++++++++- src/lib.rs | 31 ++++- src/response/core.rs | 1 + src/response/mapping.rs | 22 +-- 14 files changed, 573 insertions(+), 88 deletions(-) create mode 100644 src/app_error/metadata.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcd51b..e4546e5 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.0] - 2025-10-29 + +### Added +- Introduced typed `Metadata` storage with `Field`/`FieldValue` builders and helper functions in `field::*`. +- Captured error sources and backtraces inside the new `app_error::Error` container, exposing `MessageEditPolicy` to control redaction. + +### Changed +- Replaced the legacy `AppError` struct with the richer `Error` model carrying `AppCode`, metadata, retry/auth hints and transport policy. +- Updated response mapping and constructors to preserve machine-readable codes without extra allocations. + +### Documentation +- Refreshed crate docs, README (EN/RU) and examples to highlight metadata helpers and the new error contract. + +### Tests +- Added regression coverage ensuring codes, metadata and sources survive conversions without unnecessary cloning. + ## [0.11.2] - 2025-10-28 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 5f1fd71..87dfc89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.11.2" +version = "0.12.0" dependencies = [ "actix-web", "axum", @@ -1630,6 +1630,7 @@ dependencies = [ "tracing", "trybuild", "utoipa", + "uuid", "validator", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index cb7a8ab..83bd271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.11.2" +version = "0.12.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -110,6 +110,9 @@ telegram-webapp-sdk = { version = "0.2", optional = true } wasm-bindgen = { version = "0.2", optional = true } js-sys = { version = "0.3", optional = true } serde-wasm-bindgen = { version = "0.6", optional = true } +uuid = { version = "1", default-features = false, features = [ + "std" +] } [dev-dependencies] serde_json = "1" diff --git a/README.md b/README.md index eb16e1b..2695548 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,12 @@ and typed telemetry. Core is framework-agnostic; integrations are opt-in via feature flags. Stable categories, conservative HTTP mapping, no `unsafe`. -- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse` +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata` - Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain mappings and structured telemetry - Optional Axum/Actix integration and browser/WASM console logging - Optional OpenAPI schema (via `utoipa`) +- Structured metadata helpers via `field::*` builders - Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` - Turnkey domain taxonomy and helpers (`turnkey` feature) @@ -36,9 +37,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.11.2", default-features = false } +masterror = { version = "0.12.0", default-features = false } # or with features: -# masterror = { version = "0.11.2", features = [ +# masterror = { version = "0.12.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -59,6 +60,7 @@ masterror = { version = "0.11.2", default-features = false } - **Framework-agnostic.** No assumptions, no `unsafe`, MSRV pinned. - **Opt-in integrations.** Zero default features; you enable what you need. - **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`. +- **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` @@ -73,10 +75,10 @@ masterror = { version = "0.11.2", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.11.2", default-features = false } +masterror = { version = "0.12.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.11.2", features = [ +# masterror = { version = "0.12.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -95,10 +97,13 @@ masterror = { version = "0.11.2", default-features = false } Create an error: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ With prelude: @@ -632,13 +637,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.11.2", default-features = false } +masterror = { version = "0.12.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.11.2", features = [ +masterror = { version = "0.12.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -647,7 +652,7 @@ masterror = { version = "0.11.2", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.11.2", features = [ +masterror = { version = "0.12.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index ca3c421..54a1279 100644 --- a/README.ru.md +++ b/README.ru.md @@ -19,12 +19,13 @@ ## Основные возможности -- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`. +- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata`. - Деривы `#[derive(Error)]`, `#[app_error]`, `#[provide]` для типизированного телеметрического контекста и прямых конверсий доменных ошибок. - Адаптеры для Axum и Actix плюс логирование в браузер/`JsValue` для WASM (по фичам). - Генерация схем OpenAPI через `utoipa`. +- Структурированные поля `Metadata` через билдеры `field::*`. - Конверсии из распространённых библиотек (`sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` и др.). - Готовый прелюдия-модуль и расширение `turnkey` с собственной таксономией @@ -37,9 +38,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.11.2", default-features = false } +masterror = { version = "0.12.0", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.11.2", features = [ +# masterror = { version = "0.12.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -54,10 +55,13 @@ masterror = { version = "0.11.2", default-features = false } Создание ошибки вручную: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Флаг должен быть установлен"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ Использование прелюдии: @@ -82,6 +86,7 @@ fn do_work(flag: bool) -> AppResult<()> { - `validator` — преобразование `ValidationErrors` в валидационные ошибки API. - `config` — типизированные ошибки конфигурации. - `tokio` — маппинг таймаутов (`tokio::time::error::Elapsed`). +- `metadata` — типизированные поля `Metadata` без аллокаций строк. - `multipart` — обработка ошибок извлечения multipart в Axum. - `teloxide` — маппинг `teloxide_core::RequestError` в доменные категории. - `telegram-webapp-sdk` — обработка ошибок валидации данных Telegram WebApp. diff --git a/README.template.md b/README.template.md index 456a20b..3a757cc 100644 --- a/README.template.md +++ b/README.template.md @@ -19,11 +19,12 @@ and typed telemetry. Core is framework-agnostic; integrations are opt-in via feature flags. Stable categories, conservative HTTP mapping, no `unsafe`. -- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse` +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata` - Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain mappings and structured telemetry - Optional Axum/Actix integration and browser/WASM console logging - Optional OpenAPI schema (via `utoipa`) +- Structured metadata helpers via `field::*` builders - Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` - Turnkey domain taxonomy and helpers (`turnkey` feature) @@ -56,6 +57,7 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } - **Framework-agnostic.** No assumptions, no `unsafe`, MSRV pinned. - **Opt-in integrations.** Zero default features; you enable what you need. - **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`. +- **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` @@ -89,10 +91,13 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } Create an error: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ With prelude: diff --git a/src/app_error.rs b/src/app_error.rs index d76600f..cc5e928 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -11,6 +11,8 @@ //! [`AppErrorKind`]. //! - **Optional message:** human-readable, safe-to-expose text. Do not put //! secrets here. +//! - **Structured metadata:** attach typed key/value pairs for diagnostics via +//! [`Metadata`]. //! - **No panics:** all helpers avoid `unwrap/expect`. //! - **Transport-agnostic:** mapping to HTTP lives in `kind.rs` and //! `convert/*`. @@ -59,8 +61,11 @@ mod constructors; mod core; +mod metadata; -pub use core::{AppError, AppResult}; +pub use core::{AppError, AppResult, Error, MessageEditPolicy}; + +pub use metadata::{Field, FieldValue, Metadata, field}; #[cfg(test)] mod tests; diff --git a/src/app_error/constructors.rs b/src/app_error/constructors.rs index c95d421..20f7521 100644 --- a/src/app_error/constructors.rs +++ b/src/app_error/constructors.rs @@ -62,12 +62,9 @@ impl AppError { /// assert!(err.message.is_none()); /// ``` pub fn database(msg: Option>) -> Self { - Self { - kind: AppErrorKind::Database, - message: msg, - retry: None, - www_authenticate: None - } + let mut err = Self::bare(AppErrorKind::Database); + err.message = msg; + err } /// Build a `Database` error with a message. diff --git a/src/app_error/core.rs b/src/app_error/core.rs index b6863a1..6b27f33 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -1,36 +1,74 @@ -use std::borrow::Cow; +use std::{ + backtrace::Backtrace, + borrow::Cow, + error::Error as StdError, + fmt::{Display, Formatter, Result as FmtResult} +}; use tracing::error; -use crate::{Error, RetryAdvice, code::AppCode, kind::AppErrorKind}; +use super::metadata::{Field, Metadata}; +use crate::{AppCode, AppErrorKind, RetryAdvice}; -/// Thin error wrapper: kind + optional message. -/// -/// `Display` prints only the `kind`. The optional `message` is intended for -/// logs and (when appropriate) public JSON payloads. Keep messages concise and -/// free of sensitive data. -#[derive(Debug, Error)] -#[error("{kind}")] -pub struct AppError { - /// Semantic category of the error. +/// Controls whether the public message may be redacted before exposure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MessageEditPolicy { + /// Message must be preserved as-is. + Preserve, + /// Message may be redacted or replaced at the transport boundary. + Redact +} + +impl Default for MessageEditPolicy { + fn default() -> Self { + Self::Preserve + } +} + +/// Rich application error preserving domain code, taxonomy and metadata. +#[derive(Debug)] +pub struct Error { + /// Stable machine-readable error code. + pub code: AppCode, + /// Semantic error category. pub kind: AppErrorKind, /// Optional, public-friendly message. pub message: Option>, + /// Structured metadata for telemetry. + pub metadata: Metadata, + /// Policy describing whether the message can be redacted. + pub edit_policy: MessageEditPolicy, /// Optional retry advice rendered as `Retry-After`. pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. - pub www_authenticate: Option + pub www_authenticate: Option, + source: Option>, + backtrace: Option +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&self.kind, f) + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source + .as_ref() + .map(|source| &**source as &(dyn StdError + 'static)) + } } /// Conventional result alias for application code. /// -/// The alias defaults to [`AppError`] but accepts a custom error type when the +/// The alias defaults to [`Error`] but accepts a custom error type when the /// context requires a different domain error. /// /// # Examples /// /// ```rust -/// use std::io::Error; +/// use std::io::Error as IoError; /// /// use masterror::AppResult; /// @@ -38,20 +76,20 @@ pub struct AppError { /// Ok(7) /// } /// -/// fn io_logic() -> AppResult<(), Error> { +/// fn io_logic() -> AppResult<(), IoError> { /// Ok(()) /// } /// /// assert_eq!(app_logic().unwrap(), 7); /// assert!(io_logic().is_ok()); /// ``` -pub type AppResult = Result; +pub type AppResult = Result; -impl AppError { - /// Create a new [`AppError`] with a kind and message. +impl Error { + /// Create a new [`Error`] with a kind and message. /// - /// This is equivalent to [`AppError::with`], provided for API symmetry and - /// to keep doctests readable. + /// This is equivalent to [`Error::with`], provided for API symmetry and to + /// keep doctests readable. /// /// # Examples /// @@ -60,35 +98,55 @@ impl AppError { /// let err = AppError::new(AppErrorKind::BadRequest, "invalid payload"); /// assert!(err.message.is_some()); /// ``` + #[must_use] pub fn new(kind: AppErrorKind, msg: impl Into>) -> Self { Self::with(kind, msg) } /// Create an error with the given kind and message. /// - /// Prefer named helpers (e.g. [`AppError::not_found`]) where it clarifies + /// Prefer named helpers (e.g. [`Error::not_found`]) where it clarifies /// intent. + #[must_use] pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { Self { + code: AppCode::from(kind), kind, message: Some(msg.into()), + metadata: Metadata::new(), + edit_policy: MessageEditPolicy::Preserve, retry: None, - www_authenticate: None + www_authenticate: None, + source: None, + backtrace: None } } /// Create a message-less error with the given kind. /// /// Useful when the kind alone conveys sufficient information to the client. + #[must_use] pub fn bare(kind: AppErrorKind) -> Self { Self { + code: AppCode::from(kind), kind, message: None, + metadata: Metadata::new(), + edit_policy: MessageEditPolicy::Preserve, retry: None, - www_authenticate: None + www_authenticate: None, + source: None, + backtrace: None } } + /// Override the machine-readable [`AppCode`]. + #[must_use] + pub fn with_code(mut self, code: AppCode) -> Self { + self.code = code; + self + } + /// Attach retry advice to the error. /// /// When mapped to HTTP, this becomes the `Retry-After` header. @@ -107,24 +165,95 @@ impl AppError { self } + /// Attach additional metadata to the error. + #[must_use] + pub fn with_field(mut self, field: Field) -> Self { + self.metadata.insert(field); + self + } + + /// Extend metadata from an iterator of fields. + #[must_use] + pub fn with_fields(mut self, fields: impl IntoIterator) -> Self { + self.metadata.extend(fields); + self + } + + /// Replace metadata entirely. + #[must_use] + pub fn with_metadata(mut self, metadata: Metadata) -> Self { + self.metadata = metadata; + self + } + + /// Mark the message as redactable. + #[must_use] + pub fn redactable(mut self) -> Self { + self.edit_policy = MessageEditPolicy::Redact; + self + } + + /// Attach a source error for diagnostics. + #[must_use] + pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { + self.source = Some(Box::new(source)); + self + } + + /// Attach a captured backtrace. + #[must_use] + pub fn with_backtrace(mut self, backtrace: Backtrace) -> Self { + self.backtrace = Some(backtrace); + self + } + + /// Borrow the attached metadata. + #[must_use] + pub fn metadata(&self) -> &Metadata { + &self.metadata + } + + /// Borrow the backtrace if present. + #[must_use] + pub fn backtrace(&self) -> Option<&Backtrace> { + self.backtrace.as_ref() + } + + /// Borrow the source if present. + #[must_use] + pub fn source_ref(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> { + self.source.as_deref() + } + + /// Human-readable message or the kind fallback. + #[must_use] + pub fn render_message(&self) -> Cow<'_, str> { + match &self.message { + Some(msg) => Cow::Borrowed(msg.as_ref()), + None => Cow::Owned(self.kind.to_string()) + } + } + /// Log the error once at the boundary with stable fields. /// - /// Emits a `tracing::error!` with `kind`, `code` and optional `message`. - /// No internals or sources are leaked. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::internal("boom"); - /// // In production, call this at the boundary (e.g. HTTP mapping). - /// err.log(); - /// ``` + /// Emits a `tracing::error!` with `kind`, `code`, optional `message` and + /// metadata length. No internals or sources are leaked. pub fn log(&self) { - let code = AppCode::from(self.kind); match &self.message { - Some(m) => error!(kind = ?self.kind, code = %code, message = %m), - None => error!(kind = ?self.kind, code = %code) + Some(m) => error!( + kind = ?self.kind, + code = %self.code, + message = %m, + metadata_len = self.metadata.len() + ), + None => error!( + kind = ?self.kind, + code = %self.code, + metadata_len = self.metadata.len() + ) } } } + +/// Backwards-compatible export using the historical name. +pub use Error as AppError; diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs new file mode 100644 index 0000000..754793b --- /dev/null +++ b/src/app_error/metadata.rs @@ -0,0 +1,253 @@ +use std::{ + borrow::Cow, + collections::BTreeMap, + fmt::{Display, Formatter, Result as FmtResult} +}; + +use uuid::Uuid; + +/// Value stored inside [`Metadata`]. +/// +/// The enum keeps the most common telemetry-friendly primitives without forcing +/// callers to allocate temporary strings. Strings use [`Cow`] so `'static` +/// literals avoid allocation while owned [`String`]s are supported when +/// necessary. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldValue { + /// Human-readable string. + Str(Cow<'static, str>), + /// Signed 64-bit integer. + I64(i64), + /// Unsigned 64-bit integer. + U64(u64), + /// Boolean flag. + Bool(bool), + /// UUID represented with the canonical binary type. + Uuid(Uuid) +} + +impl Display for FieldValue { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Self::Str(value) => Display::fmt(value, f), + Self::I64(value) => Display::fmt(value, f), + Self::U64(value) => Display::fmt(value, f), + Self::Bool(value) => Display::fmt(value, f), + Self::Uuid(value) => Display::fmt(value, f) + } + } +} + +/// Single metadata field – name plus value. +#[derive(Clone, Debug, PartialEq)] +pub struct Field { + name: &'static str, + value: FieldValue +} + +impl Field { + /// Create a new [`Field`]. + #[must_use] + pub const fn new(name: &'static str, value: FieldValue) -> Self { + Self { + name, + value + } + } + + /// Field name. + #[must_use] + pub const fn name(&self) -> &'static str { + self.name + } + + /// Field value. + #[must_use] + pub const fn value(&self) -> &FieldValue { + &self.value + } + + /// Consume the field and return owned components. + #[must_use] + pub fn into_parts(self) -> (&'static str, FieldValue) { + (self.name, self.value) + } +} + +/// Structured metadata attached to [`crate::AppError`]. +/// +/// Internally backed by a deterministic [`BTreeMap`] keyed by `'static` field +/// names. Use the helpers in [`field`] to build [`Field`] values without manual +/// enum construction. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Metadata { + fields: BTreeMap<&'static str, FieldValue> +} + +impl Metadata { + /// Create an empty metadata container. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Build metadata from an iterator of [`Field`] values. + #[must_use] + pub fn from_fields(fields: impl IntoIterator) -> Self { + let mut map = BTreeMap::new(); + for Field { + name, + value + } in fields + { + map.insert(name, value); + } + Self { + fields: map + } + } + + /// Number of fields stored in the metadata. + #[must_use] + pub fn len(&self) -> usize { + self.fields.len() + } + + /// Whether the metadata is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } + + /// Insert or replace a field and return the previous value. + pub fn insert(&mut self, field: Field) -> Option { + let (name, value) = field.into_parts(); + self.fields.insert(name, value) + } + + /// Extend metadata with additional fields. + pub fn extend(&mut self, fields: impl IntoIterator) { + for field in fields { + self.insert(field); + } + } + + /// Borrow a field value by name. + #[must_use] + pub fn get(&self, name: &'static str) -> Option<&FieldValue> { + self.fields.get(name) + } + + /// Iterator over metadata fields in sorted order. + pub fn iter(&self) -> impl Iterator { + self.fields.iter().map(|(k, v)| (*k, v)) + } +} + +impl IntoIterator for Metadata { + type Item = Field; + type IntoIter = std::iter::Map< + std::collections::btree_map::IntoIter<&'static str, FieldValue>, + fn((&'static str, FieldValue)) -> Field + >; + + fn into_iter(self) -> Self::IntoIter { + fn into_field(entry: (&'static str, FieldValue)) -> Field { + Field::new(entry.0, entry.1) + } + self.fields + .into_iter() + .map(into_field as fn((&'static str, FieldValue)) -> Field) + } +} + +/// Factories for [`Field`] values. +pub mod field { + use std::borrow::Cow; + + use uuid::Uuid; + + use super::{Field, FieldValue}; + + /// Build a string metadata field. + #[must_use] + pub fn str(name: &'static str, value: impl Into>) -> Field { + Field::new(name, FieldValue::Str(value.into())) + } + + /// Build an `i64` metadata field. + #[must_use] + pub fn i64(name: &'static str, value: i64) -> Field { + Field::new(name, FieldValue::I64(value)) + } + + /// Build a `u64` metadata field. + #[must_use] + pub fn u64(name: &'static str, value: u64) -> Field { + Field::new(name, FieldValue::U64(value)) + } + + /// Build a boolean metadata field. + #[must_use] + pub fn bool(name: &'static str, value: bool) -> Field { + Field::new(name, FieldValue::Bool(value)) + } + + /// Build a UUID metadata field. + #[must_use] + pub fn uuid(name: &'static str, value: Uuid) -> Field { + Field::new(name, FieldValue::Uuid(value)) + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use uuid::Uuid; + + use super::{FieldValue, Metadata, field}; + + #[test] + fn metadata_roundtrip() { + let mut meta = Metadata::new(); + meta.insert(field::str("request_id", Cow::Borrowed("abc"))); + meta.insert(field::i64("count", 42)); + + assert_eq!( + meta.get("request_id"), + Some(&FieldValue::Str(Cow::Borrowed("abc"))) + ); + assert_eq!(meta.get("count"), Some(&FieldValue::I64(42))); + } + + #[test] + fn metadata_from_fields_is_deterministic() { + let uuid = Uuid::nil(); + let meta = + Metadata::from_fields([field::uuid("trace_id", uuid), field::bool("cached", true)]); + let collected: Vec<_> = meta.iter().collect(); + assert_eq!(collected.len(), 2); + assert_eq!(collected[0].0, "cached"); + assert_eq!(collected[1].0, "trace_id"); + } + + #[test] + fn inserting_field_replaces_previous_value() { + let mut meta = Metadata::from_fields([field::i64("count", 1)]); + let replaced = meta.insert(field::i64("count", 2)); + assert_eq!(replaced, Some(FieldValue::I64(1))); + assert_eq!(meta.get("count"), Some(&FieldValue::I64(2))); + } + + #[test] + fn field_into_parts_returns_components() { + let field = field::u64("elapsed_ms", 30); + let clone = field.clone(); + assert_eq!(clone.name(), field.name()); + assert_eq!(clone.value(), field.value()); + let (owned_name, owned_value) = clone.into_parts(); + assert_eq!(owned_name, field.name()); + assert_eq!(owned_value, field.value().clone()); + } +} diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 7d4d6c3..b18ae4f 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -1,7 +1,7 @@ -use std::borrow::Cow; +use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; -use super::{AppResult, core::AppError}; -use crate::AppErrorKind; +use super::{AppError, FieldValue, MessageEditPolicy, field}; +use crate::{AppCode, AppErrorKind}; // --- Helpers ------------------------------------------------------------- @@ -137,6 +137,62 @@ fn retry_and_www_authenticate_are_attached() { assert_eq!(err.www_authenticate.as_deref(), Some("Bearer")); } +#[test] +fn render_message_does_not_allocate_for_borrowed_str() { + let err = AppError::new(AppErrorKind::BadRequest, "borrowed"); + let rendered = err.render_message(); + assert!(matches!(rendered, Cow::Borrowed("borrowed"))); + assert!(std::ptr::eq(rendered.as_ref(), "borrowed")); +} + +#[test] +fn metadata_and_code_are_preserved() { + let err = AppError::service("downstream") + .with_field(field::str("request_id", "abc-123")) + .with_field(field::i64("attempt", 2)) + .with_code(AppCode::Service); + + assert_eq!(err.code, AppCode::Service); + let metadata = err.metadata(); + assert_eq!(metadata.len(), 2); + assert_eq!( + metadata.get("request_id"), + Some(&FieldValue::Str(Cow::Borrowed("abc-123"))) + ); + assert_eq!(metadata.get("attempt"), Some(&FieldValue::I64(2))); +} + +#[derive(Debug)] +struct DummyError; + +impl Display for DummyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("dummy") + } +} + +impl StdError for DummyError {} + +#[test] +fn source_is_preserved_without_extra_allocation() { + let source = Arc::new(DummyError); + let err = AppError::internal("boom").with_source(source.clone()); + + assert_eq!(Arc::strong_count(&source), 2); + + let stored = err.source_ref().expect("source"); + let stored_arc = stored + .downcast_ref::>() + .expect("arc should be preserved"); + assert!(Arc::ptr_eq(stored_arc, &source)); +} + +#[test] +fn redactable_policy_is_exposed() { + let err = AppError::internal("boom").redactable(); + assert!(matches!(err.edit_policy, MessageEditPolicy::Redact)); +} + #[test] fn log_uses_kind_and_code() { // Smoke test to ensure the method is callable; tracing output isn't asserted @@ -147,11 +203,11 @@ fn log_uses_kind_and_code() { #[test] fn result_alias_is_generic() { - fn app() -> AppResult { + fn app() -> super::AppResult { Ok(1) } - fn other() -> AppResult { + fn other() -> super::AppResult { Ok(2) } diff --git a/src/lib.rs b/src/lib.rs index ce7fe29..8834eed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,15 +7,19 @@ //! feature flags. //! //! Core types: -//! - [`AppError`] — thin wrapper around a semantic error kind and optional -//! message +//! - [`AppError`] — rich error capturing code, taxonomy, message, metadata and +//! transport hints //! - [`AppErrorKind`] — stable internal taxonomy of application errors //! - [`AppResult`] — convenience alias for returning [`AppError`] //! - [`ErrorResponse`] — stable wire-level JSON payload for HTTP APIs //! - [`AppCode`] — public, machine-readable error code for clients +//! - [`Metadata`] — structured telemetry attached to [`AppError`] +//! - [`field`] — helper functions to build [`Metadata`] without manual enums //! //! Key properties: //! - Stable, predictable error categories (`AppErrorKind`). +//! - Explicit, overridable machine-readable codes (`AppCode`). +//! - Structured metadata for observability without ad-hoc `String` maps. //! - Conservative and stable HTTP mappings. //! - Internal error sources are never serialized to clients (only logged). //! - Messages are safe to expose (human-oriented, non-sensitive). @@ -54,10 +58,11 @@ //! //! # Derive macros and telemetry //! -//! The [`masterror::Error`](crate::Error) derive mirrors `thiserror` while -//! adding `#[app_error]` and `#[provide]` attributes. Annotate your domain -//! errors once to surface structured telemetry via [`std::error::Request`] and -//! generate conversions into [`AppError`] / [`AppCode`]. +//! The [`masterror::Error`](derive@crate::Error) derive mirrors `thiserror` +//! while adding `#[app_error]` and `#[provide]` attributes. Annotate your +//! domain errors once to surface structured telemetry via +//! [`std::error::Request`] and generate conversions into [`AppError`] / +//! [`AppCode`]. //! //! ```rust //! use masterror::{AppCode, AppError, AppErrorKind, Error}; @@ -125,6 +130,16 @@ //! assert!(matches!(err.kind, AppErrorKind::BadRequest)); //! ``` //! +//! Attach structured metadata for telemetry and logging: +//! ```rust +//! use masterror::{AppError, AppErrorKind, field}; +//! +//! let err = AppError::service("downstream degraded") +//! .with_field(field::str("request_id", "abc123")) +//! .with_field(field::i64("attempt", 2)); +//! assert_eq!(err.metadata().len(), 2); +//! ``` +//! //! [`AppErrorKind`] controls the default HTTP status mapping. //! [`AppCode`] provides a stable machine-readable code for clients. //! Together, they form the wire contract in [`ErrorResponse`]. @@ -230,7 +245,9 @@ pub mod turnkey; /// Minimal prelude re-exporting core types for handler signatures. pub mod prelude; -pub use app_error::{AppError, AppResult}; +pub use app_error::{ + AppError, AppResult, Error, Field, FieldValue, MessageEditPolicy, Metadata, field +}; pub use code::AppCode; pub use kind::AppErrorKind; /// Re-export derive macros so users only depend on [`masterror`]. diff --git a/src/response/core.rs b/src/response/core.rs index f6bb5fe..ccd4768 100644 --- a/src/response/core.rs +++ b/src/response/core.rs @@ -62,6 +62,7 @@ impl ErrorResponse { /// # Errors /// /// Returns [`AppError`] if `status` is not a valid HTTP status code. + #[allow(clippy::result_large_err)] pub fn new(status: u16, code: AppCode, message: impl Into) -> AppResult { StatusCode::from_u16(status) .map_err(|_| AppError::bad_request(format!("invalid HTTP status: {status}")))?; diff --git a/src/response/mapping.rs b/src/response/mapping.rs index 36eaed3..e907581 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -1,10 +1,7 @@ -use std::{ - borrow::Cow, - fmt::{Display, Formatter, Result as FmtResult} -}; +use std::fmt::{Display, Formatter, Result as FmtResult}; use super::core::ErrorResponse; -use crate::{AppCode, AppError}; +use crate::AppError; impl Display for ErrorResponse { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { @@ -16,14 +13,15 @@ impl Display for ErrorResponse { impl From for ErrorResponse { fn from(err: AppError) -> Self { let AppError { + code, kind, message, retry, - www_authenticate + www_authenticate, + .. } = err; let status = kind.http_status(); - let code = AppCode::from(kind); let message = match message { Some(msg) => msg.into_owned(), None => kind.to_string() @@ -43,17 +41,11 @@ impl From for ErrorResponse { impl From<&AppError> for ErrorResponse { fn from(err: &AppError) -> Self { let status = err.kind.http_status(); - let code = AppCode::from(err.kind); - - let message = err - .message - .clone() - .unwrap_or_else(|| Cow::Owned(err.kind.to_string())) - .into_owned(); + let message = err.render_message().into_owned(); Self { status, - code, + code: err.code, message, details: None, retry: err.retry,