From a82193712f605753156f6c6f22f74aaf6bf24b97 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Thu, 25 Sep 2025 08:51:14 +0700 Subject: [PATCH 01/28] Enable no_std core and std feature flag --- CHANGELOG.md | 15 +++++ Cargo.lock | 2 +- Cargo.toml | 61 +++++++++-------- README.md | 16 ++--- src/app_error/constructors.rs | 2 +- src/app_error/context.rs | 5 +- src/app_error/core.rs | 56 +++++++++------- src/app_error/metadata.rs | 16 ++--- src/code/app_code.rs | 6 +- src/convert.rs | 3 + src/kind.rs | 65 +++++++++++-------- src/lib.rs | 22 ++++--- src/response/core.rs | 1 + src/response/details.rs | 3 + src/response/internal.rs | 2 +- src/response/legacy.rs | 1 + src/response/mapping.rs | 3 +- src/response/metadata.rs | 5 +- src/response/problem_json.rs | 8 ++- src/result_ext.rs | 6 +- .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 + .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 36 files changed, 211 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fd3cc..74cbea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.22.0] - 2025-10-11 + +### Added +- Introduced an explicit `std` feature (enabled by default) and made the core + crate compile in `no_std + alloc` environments, including metadata builders + and error helpers. + +### Changed +- Reworked `AppError` internals to rely on `core`/`alloc` primitives and + `core::error::Error`, providing `std::error::Error` only when the `std` + feature is active. +- Replaced `thiserror` derives on `AppErrorKind` with manual `Display`/error + implementations so the taxonomy remains available without the standard + library. + ## [0.21.2] - 2025-10-10 ### Added diff --git a/Cargo.lock b/Cargo.lock index a609bf8..6b697e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.21.2" +version = "0.22.0" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 00e286b..794a1be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.21.2" +version = "0.22.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -49,30 +49,34 @@ readme = "README.md" [features] -default = [] -tracing = ["dep:tracing", "dep:log", "dep:log-mdc"] -metrics = ["dep:metrics"] -backtrace = [] -axum = ["dep:axum", "dep:serde_json"] -actix = ["dep:actix-web", "dep:serde_json"] +default = ["std"] +std = [ + "uuid/std", + "serde/std" +] +tracing = ["dep:tracing", "dep:log", "dep:log-mdc", "std"] +metrics = ["dep:metrics", "std"] +backtrace = ["std"] +axum = ["dep:axum", "dep:serde_json", "std"] +actix = ["dep:actix-web", "dep:serde_json", "std"] # Разделили: лёгкая обработка ошибок (sqlx-core) и опциональные миграции (полный sqlx) sqlx = ["dep:sqlx-core"] # maps sqlx_core::Error sqlx-migrate = ["dep:sqlx"] # maps sqlx::migrate::MigrateError -redis = ["dep:redis"] -validator = ["dep:validator"] -serde_json = ["dep:serde_json"] -config = ["dep:config"] -multipart = ["axum"] -tokio = ["dep:tokio"] -reqwest = ["dep:reqwest"] -teloxide = ["dep:teloxide-core"] -telegram-webapp-sdk = ["dep:telegram-webapp-sdk"] -frontend = ["dep:wasm-bindgen", "dep:js-sys", "dep:serde-wasm-bindgen"] -turnkey = [] -tonic = ["dep:tonic"] -openapi = ["dep:utoipa"] +redis = ["dep:redis", "std"] +validator = ["dep:validator", "std"] +serde_json = ["dep:serde_json", "std"] +config = ["dep:config", "std"] +multipart = ["axum", "std"] +tokio = ["dep:tokio", "std"] +reqwest = ["dep:reqwest", "std"] +teloxide = ["dep:teloxide-core", "std"] +telegram-webapp-sdk = ["dep:telegram-webapp-sdk", "std"] +frontend = ["dep:wasm-bindgen", "dep:js-sys", "dep:serde-wasm-bindgen", "std"] +turnkey = ["std"] +tonic = ["dep:tonic", "std"] +openapi = ["dep:utoipa", "std"] [workspace.dependencies] masterror-derive = { version = "0.9.0" } @@ -81,13 +85,16 @@ masterror-template = { version = "0.3.6" } [dependencies] masterror-derive = { version = "0.9" } masterror-template = { workspace = true } -tracing = { version = "0.1", optional = true } +tracing = { version = "0.1", optional = true, default-features = false, features = [ + "attributes", + "std" +] } log = { version = "0.4", optional = true } log-mdc = { version = "0.1", optional = true } metrics = { version = "0.24", optional = true } -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", optional = true } +serde = { version = "1", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1", optional = true, default-features = false, features = ["std"] } http = "1" sha2 = "0.10" @@ -118,9 +125,7 @@ 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" -] } +uuid = { version = "1", default-features = false } tonic = { version = "0.12", optional = true } [dev-dependencies] @@ -142,6 +147,7 @@ toml = "0.9" [package.metadata.masterror.readme] feature_order = [ + "std", "axum", "actix", "openapi", @@ -184,6 +190,9 @@ description = "IntoResponse integration with structured JSON bodies" [package.metadata.masterror.readme.features.actix] description = "Actix Web ResponseError and Responder implementations" +[package.metadata.masterror.readme.features.std] +description = "Enable std support (default); required for runtime integrations" + [package.metadata.masterror.readme.features.openapi] description = "Generate utoipa OpenAPI schema for ErrorResponse" diff --git a/README.md b/README.md index 740f243..5829698 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,15 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.21.2", default-features = false } +masterror = { version = "0.22.0", default-features = false } # or with features: -# masterror = { version = "0.21.2", features = [ -# "axum", "actix", "openapi", "serde_json", -# "tracing", "metrics", "backtrace", "sqlx", -# "sqlx-migrate", "reqwest", "redis", "validator", -# "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "tonic", "frontend", "turnkey" +# masterror = { version = "0.22.0", features = [ +# "std", "axum", "actix", "openapi", +# "serde_json", "tracing", "metrics", "backtrace", +# "sqlx", "sqlx-migrate", "reqwest", "redis", +# "validator", "config", "tokio", "multipart", +# "teloxide", "telegram-webapp-sdk", "tonic", "frontend", +# "turnkey" # ] } ~~~ @@ -418,4 +419,3 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); --- MSRV: **1.90** · License: **MIT OR Apache-2.0** · No `unsafe` - diff --git a/src/app_error/constructors.rs b/src/app_error/constructors.rs index 30abece..aa6240c 100644 --- a/src/app_error/constructors.rs +++ b/src/app_error/constructors.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use alloc::borrow::Cow; use super::core::AppError; use crate::AppErrorKind; diff --git a/src/app_error/context.rs b/src/app_error/context.rs index a3ad334..64a6e82 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -1,4 +1,5 @@ -use std::{error::Error as StdError, panic::Location}; +use alloc::vec::Vec; +use core::{error::Error as CoreError, panic::Location}; use super::{ core::{AppError, Error, MessageEditPolicy}, @@ -133,7 +134,7 @@ impl Context { pub(crate) fn into_error(mut self, source: E) -> Error where - E: StdError + Send + Sync + 'static + E: CoreError + Send + Sync + 'static { if let Some(location) = self.caller_location { self.fields.push(Field::new( diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 3c791b9..a4b455f 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -1,3 +1,15 @@ +use alloc::{ + borrow::Cow, + boxed::Box, + string::{String, ToString}, + sync::Arc +}; +use core::{ + error::Error as CoreError, + fmt::{Display, Formatter, Result as FmtResult}, + ops::{Deref, DerefMut}, + sync::atomic::{AtomicBool, Ordering} +}; #[cfg(feature = "backtrace")] use std::{ backtrace::Backtrace, @@ -7,16 +19,6 @@ use std::{ atomic::{AtomicU8, Ordering as AtomicOrdering} } }; -use std::{ - borrow::Cow, - error::Error as StdError, - fmt::{Display, Formatter, Result as FmtResult}, - ops::{Deref, DerefMut}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering} - } -}; #[cfg(feature = "tracing")] use tracing::{Level, event}; @@ -24,6 +26,14 @@ use tracing::{Level, event}; use super::metadata::{Field, FieldRedaction, Metadata}; use crate::{AppCode, AppErrorKind, RetryAdvice}; +#[cfg(feature = "std")] +pub type CapturedBacktrace = std::backtrace::Backtrace; + +#[cfg(not(feature = "std"))] +#[allow(dead_code)] +#[derive(Debug)] +pub enum CapturedBacktrace {} + /// Controls whether the public message may be redacted before exposure. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum MessageEditPolicy { @@ -51,7 +61,7 @@ pub struct ErrorInner { pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. pub www_authenticate: Option, - pub source: Option>, + pub source: Option>, #[cfg(feature = "backtrace")] pub backtrace: Option, #[cfg(feature = "backtrace")] @@ -184,11 +194,11 @@ impl Display for Error { } } -impl StdError for Error { - fn source(&self) -> Option<&(dyn StdError + 'static)> { +impl CoreError for Error { + fn source(&self) -> Option<&(dyn CoreError + 'static)> { self.source .as_deref() - .map(|source| source as &(dyn StdError + 'static)) + .map(|source| source as &(dyn CoreError + 'static)) } } @@ -247,7 +257,7 @@ impl Error { } #[cfg(feature = "backtrace")] - fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { + fn capture_backtrace(&self) -> Option<&CapturedBacktrace> { if let Some(backtrace) = self.backtrace.as_ref() { return Some(backtrace); } @@ -258,18 +268,18 @@ impl Error { } #[cfg(not(feature = "backtrace"))] - fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { + fn capture_backtrace(&self) -> Option<&CapturedBacktrace> { None } #[cfg(feature = "backtrace")] - fn set_backtrace_slot(&mut self, backtrace: std::backtrace::Backtrace) { + fn set_backtrace_slot(&mut self, backtrace: CapturedBacktrace) { self.backtrace = Some(backtrace); self.captured_backtrace = OnceLock::new(); } #[cfg(not(feature = "backtrace"))] - fn set_backtrace_slot(&mut self, _backtrace: std::backtrace::Backtrace) {} + fn set_backtrace_slot(&mut self, _backtrace: CapturedBacktrace) {} pub(crate) fn emit_telemetry(&self) { if self.take_dirty() { @@ -416,7 +426,7 @@ impl Error { /// Attach a source error for diagnostics. #[must_use] - pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { + pub fn with_source(mut self, source: impl CoreError + Send + Sync + 'static) -> Self { self.source = Some(Arc::new(source)); self.mark_dirty(); self @@ -437,7 +447,7 @@ impl Error { /// assert_eq!(Arc::strong_count(&source), 2); /// ``` #[must_use] - pub fn with_source_arc(mut self, source: Arc) -> Self { + pub fn with_source_arc(mut self, source: Arc) -> Self { self.source = Some(source); self.mark_dirty(); self @@ -445,7 +455,7 @@ impl Error { /// Attach a captured backtrace. #[must_use] - pub fn with_backtrace(mut self, backtrace: std::backtrace::Backtrace) -> Self { + pub fn with_backtrace(mut self, backtrace: CapturedBacktrace) -> Self { self.set_backtrace_slot(backtrace); self.mark_dirty(); self @@ -460,13 +470,13 @@ impl Error { /// Borrow the backtrace, capturing it lazily when the `backtrace` feature /// is enabled. #[must_use] - pub fn backtrace(&self) -> Option<&std::backtrace::Backtrace> { + pub fn backtrace(&self) -> Option<&CapturedBacktrace> { self.capture_backtrace() } /// Borrow the source if present. #[must_use] - pub fn source_ref(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> { + pub fn source_ref(&self) -> Option<&(dyn CoreError + Send + Sync + 'static)> { self.source.as_deref() } diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs index 7735ee1..125f0de 100644 --- a/src/app_error/metadata.rs +++ b/src/app_error/metadata.rs @@ -1,6 +1,5 @@ -use std::{ - borrow::Cow, - collections::BTreeMap, +use alloc::{borrow::Cow, collections::BTreeMap, string::String}; +use core::{ fmt::{Display, Formatter, Result as FmtResult, Write}, net::IpAddr, time::Duration @@ -354,8 +353,8 @@ impl Metadata { impl IntoIterator for Metadata { type Item = Field; - type IntoIter = std::iter::Map< - std::collections::btree_map::IntoIter<&'static str, Field>, + type IntoIter = core::iter::Map< + alloc::collections::btree_map::IntoIter<&'static str, Field>, fn((&'static str, Field)) -> Field >; @@ -371,7 +370,8 @@ impl IntoIterator for Metadata { /// Factories for [`Field`] values. pub mod field { - use std::{borrow::Cow, net::IpAddr, time::Duration}; + use alloc::borrow::Cow; + use core::{net::IpAddr, time::Duration}; #[cfg(feature = "serde_json")] use serde_json::Value as JsonValue; @@ -425,7 +425,7 @@ pub mod field { /// Build a duration metadata field. /// /// ``` - /// use std::time::Duration; + /// use core::time::Duration; /// use masterror::{field, FieldValue}; /// /// let (_, value, _) = field::duration("elapsed", Duration::from_millis(1500)).into_parts(); @@ -439,7 +439,7 @@ pub mod field { /// Build an IP address metadata field. /// /// ``` - /// use std::net::{IpAddr, Ipv4Addr}; + /// use core::net::{IpAddr, Ipv4Addr}; /// use masterror::{field, FieldValue}; /// /// let (_, value, _) = field::ip("peer", IpAddr::from(Ipv4Addr::LOCALHOST)).into_parts(); diff --git a/src/code/app_code.rs b/src/code/app_code.rs index 9afc1f3..37ffc12 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,5 +1,5 @@ -use std::{ - error::Error as StdError, +use core::{ + error::Error as CoreError, fmt::{self, Display}, str::FromStr }; @@ -23,7 +23,7 @@ impl Display for ParseAppCodeError { } } -impl StdError for ParseAppCodeError {} +impl CoreError for ParseAppCodeError {} /// Stable machine-readable error code exposed to clients. /// diff --git a/src/convert.rs b/src/convert.rs index 1dc4966..3915860 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -74,6 +74,8 @@ //! assert!(matches!(err.kind, AppErrorKind::BadRequest)); //! ``` +use alloc::string::String; +#[cfg(feature = "std")] use std::io::Error as IoError; use crate::AppError; @@ -148,6 +150,7 @@ pub use self::tonic::StatusConversionError; /// let app_err: AppError = io_err.into(); /// assert!(matches!(app_err.kind, AppErrorKind::Internal)); /// ``` +#[cfg(feature = "std")] impl From for AppError { fn from(err: IoError) -> Self { AppError::internal(err.to_string()) diff --git a/src/kind.rs b/src/kind.rs index e025012..b0589f0 100644 --- a/src/kind.rs +++ b/src/kind.rs @@ -35,68 +35,63 @@ //! assert_eq!(kind.status_code().as_u16(), 404); //! ``` +use core::{ + error::Error as CoreError, + fmt::{self, Display, Formatter} +}; + #[cfg(feature = "axum")] use axum::http::StatusCode; -use crate::Error; - /// Canonical application error taxonomy. /// /// Keep it small, stable, and framework-agnostic. Each variant has a clear, /// documented meaning and a predictable mapping to an HTTP status code. -#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppErrorKind { // ── Generic, client-visible failures (4xx/5xx) ──────────────────────────── /// Resource does not exist or is not visible to the caller. /// /// Maps to **404 Not Found**. - #[error("Not found")] NotFound, /// Input failed validation (shape, constraints, business rules). /// /// Prefer this over `BadRequest` when you validate structured input. /// Maps to **422 Unprocessable Entity**. - #[error("Validation error")] Validation, /// State conflict with an existing resource or concurrent update. /// /// Typical cases: unique key violation, version mismatch (ETag). /// Maps to **409 Conflict**. - #[error("Conflict")] Conflict, /// Authentication required or failed (missing/invalid credentials). /// /// Maps to **401 Unauthorized**. - #[error("Unauthorized")] Unauthorized, /// Authenticated but not allowed to perform the operation. /// /// Maps to **403 Forbidden**. - #[error("Forbidden")] Forbidden, /// Operation is not implemented or not supported by this deployment. /// /// Maps to **501 Not Implemented**. - #[error("Not implemented")] NotImplemented, /// Unexpected server-side failure not captured by more specific kinds. /// /// Use sparingly; prefer a more precise category when possible. /// Maps to **500 Internal Server Error**. - #[error("Internal server error")] Internal, /// Malformed request or missing required parameters. /// /// Prefer `Validation` for structured input with field-level issues. /// Maps to **400 Bad Request**. - #[error("Bad request")] BadRequest, // ── Domain-specific categories (map conservatively) ─────────────────────── @@ -104,21 +99,18 @@ pub enum AppErrorKind { /// /// Treated as an authentication failure. /// Maps to **401 Unauthorized**. - #[error("Telegram authentication error")] TelegramAuth, /// Provided JWT is invalid (expired, malformed, wrong signature/claims). /// /// Treated as an authentication failure. /// Maps to **401 Unauthorized**. - #[error("Invalid JWT")] InvalidJwt, /// Database-related failure (query, connection, migration, etc.). /// /// Keep driver-specific details out of the public contract. /// Maps to **500 Internal Server Error**. - #[error("Database error")] Database, /// Generic service-layer failure (business logic or internal @@ -126,19 +118,16 @@ pub enum AppErrorKind { /// /// Use when no more specific category applies. /// Maps to **500 Internal Server Error**. - #[error("Service error")] Service, /// Configuration error (missing/invalid environment or runtime config). /// /// Maps to **500 Internal Server Error**. - #[error("Configuration error")] Config, /// Failure in the Turnkey subsystem/integration. /// /// Maps to **500 Internal Server Error**. - #[error("Turnkey error")] Turnkey, // ── Infrastructure / network ────────────────────────────────────────────── @@ -146,62 +135,86 @@ pub enum AppErrorKind { /// /// Typically returned by timeouts around I/O or remote calls. /// Maps to **504 Gateway Timeout**. - #[error("Operation timed out")] Timeout, /// Network-level error (DNS, connect, TLS, request build). /// /// For upstream HTTP status failures use `ExternalApi` instead. /// Maps to **503 Service Unavailable**. - #[error("Network error")] Network, /// Client exceeded rate limits or quota. /// /// Maps to **429 Too Many Requests**. - #[error("Rate limit exceeded")] RateLimited, /// External dependency is unavailable or degraded. /// /// Examples: cache down, message broker unreachable, third-party outage. /// Maps to **503 Service Unavailable**. - #[error("External dependency unavailable")] DependencyUnavailable, // ── Serialization / external API / infra subsystems ─────────────────────── /// Failed to serialize data (encode). /// /// Maps to **500 Internal Server Error**. - #[error("Serialization error")] Serialization, /// Failed to deserialize data (decode). /// /// Maps to **500 Internal Server Error**. - #[error("Deserialization error")] Deserialization, /// Upstream API returned an error or the call failed at protocol level. /// /// Use `Network` for connect/build failures; use this for HTTP status /// errors. Maps to **500 Internal Server Error** by default. - #[error("External API error")] ExternalApi, /// Queue processing failure (publish/consume/ack). /// /// Maps to **500 Internal Server Error**. - #[error("Queue processing error")] Queue, /// Cache subsystem failure (read/write/encoding). /// /// Maps to **500 Internal Server Error**. - #[error("Cache error")] Cache } +impl Display for AppErrorKind { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let label = match self { + Self::NotFound => "Not found", + Self::Validation => "Validation error", + Self::Conflict => "Conflict", + Self::Unauthorized => "Unauthorized", + Self::Forbidden => "Forbidden", + Self::NotImplemented => "Not implemented", + Self::Internal => "Internal server error", + Self::BadRequest => "Bad request", + Self::TelegramAuth => "Telegram authentication error", + Self::InvalidJwt => "Invalid JWT", + Self::Database => "Database error", + Self::Service => "Service error", + Self::Config => "Configuration error", + Self::Turnkey => "Turnkey error", + Self::Timeout => "Operation timed out", + Self::Network => "Network error", + Self::RateLimited => "Rate limit exceeded", + Self::DependencyUnavailable => "External dependency unavailable", + Self::Serialization => "Serialization error", + Self::Deserialization => "Deserialization error", + Self::ExternalApi => "External API error", + Self::Queue => "Queue processing error", + Self::Cache => "Cache error" + }; + f.write_str(label) + } +} + +impl CoreError for AppErrorKind {} + impl AppErrorKind { /// Framework-agnostic mapping to an HTTP status code (`u16`). /// diff --git a/src/lib.rs b/src/lib.rs index dff5e40..f98b7f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,14 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] +#![deny(rustdoc::broken_intra_doc_links)] +#![warn( + missing_docs, + missing_debug_implementations, + rust_2018_idioms, + clippy::all +)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + //! Framework-agnostic application error types for backend services. //! //! # Overview @@ -315,16 +326,7 @@ //! //! at your option. -#![forbid(unsafe_code)] -#![deny(rustdoc::broken_intra_doc_links)] -#![warn( - missing_docs, - missing_debug_implementations, - rust_2018_idioms, - clippy::all -)] -// Show feature-gated items on docs.rs -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +extern crate alloc; mod app_error; mod code; diff --git a/src/response/core.rs b/src/response/core.rs index 6b429f4..8240c37 100644 --- a/src/response/core.rs +++ b/src/response/core.rs @@ -99,3 +99,4 @@ impl ErrorResponse { crate::response::internal::ErrorResponseFormatter::new(self) } } +use alloc::{format, string::String}; diff --git a/src/response/details.rs b/src/response/details.rs index faa699e..3de1226 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -63,3 +63,6 @@ impl ErrorResponse { Ok(self.with_details_json(details)) } } +use alloc::string::String; +#[cfg(feature = "serde_json")] +use alloc::string::ToString; diff --git a/src/response/internal.rs b/src/response/internal.rs index 1cd90bd..cc30758 100644 --- a/src/response/internal.rs +++ b/src/response/internal.rs @@ -1,4 +1,4 @@ -use std::fmt::{self, Debug, Display, Formatter}; +use core::fmt::{self, Debug, Display, Formatter}; use super::{core::ErrorResponse, problem_json::ProblemJson}; diff --git a/src/response/legacy.rs b/src/response/legacy.rs index 98c3383..a11489a 100644 --- a/src/response/legacy.rs +++ b/src/response/legacy.rs @@ -23,3 +23,4 @@ impl ErrorResponse { }) } } +use alloc::string::String; diff --git a/src/response/mapping.rs b/src/response/mapping.rs index c0d2302..ecd3f4d 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -1,4 +1,5 @@ -use std::fmt::{Display, Formatter, Result as FmtResult}; +use alloc::string::ToString; +use core::fmt::{Display, Formatter, Result as FmtResult}; use super::core::ErrorResponse; use crate::AppError; diff --git a/src/response/metadata.rs b/src/response/metadata.rs index 56b6b74..cf05362 100644 --- a/src/response/metadata.rs +++ b/src/response/metadata.rs @@ -1,4 +1,5 @@ -use std::time::Duration; +use alloc::string::String; +use core::time::Duration; use super::core::{ErrorResponse, RetryAdvice}; @@ -24,7 +25,7 @@ impl ErrorResponse { /// # Examples /// /// ```rust - /// use std::time::Duration; + /// use core::time::Duration; /// /// use masterror::{AppCode, ErrorResponse}; /// diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index cb4fef0..9478f50 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -1,4 +1,10 @@ -use std::{borrow::Cow, collections::BTreeMap, fmt::Write, net::IpAddr}; +use alloc::{ + borrow::Cow, + collections::BTreeMap, + string::{String, ToString}, + vec::Vec +}; +use core::{fmt::Write, net::IpAddr}; use http::StatusCode; use serde::Serialize; diff --git a/src/result_ext.rs b/src/result_ext.rs index 31d5f6c..abf162e 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -1,4 +1,4 @@ -use std::error::Error as StdError; +use core::error::Error as CoreError; use crate::app_error::{Context, Error}; @@ -31,13 +31,13 @@ pub trait ResultExt { #[allow(clippy::result_large_err)] fn ctx(self, build: impl FnOnce() -> Context) -> Result where - E: StdError + Send + Sync + 'static; + E: CoreError + Send + Sync + 'static; } impl ResultExt for Result { fn ctx(self, build: impl FnOnce() -> Context) -> Result where - E: StdError + Send + Sync + 'static + E: CoreError + Send + Sync + 'static { self.map_err(|err| build().into_error(err)) } diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index c3fb86b..113a10d 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index b331baa..9ada290 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index b2658a1..fd151cc 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 83d517f..5a25e12 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | #[error("missing")] - | ^ +8 | / #[error("missing")] +9 | | Missing + | |___________^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index f929951..bdadf45 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 34abc91..037fac8 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index d579838..1822edf 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default From 12bd6777a2ba94f57fddca053130919bdfa27d80 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:06:53 +0700 Subject: [PATCH 02/28] Add AppError detail payload handling --- CHANGELOG.md | 13 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- docs/wiki/masterror-application-guide.md | 14 ++- docs/wiki/patterns-and-troubleshooting.md | 14 ++- src/app_error/core.rs | 97 ++++++++++++++++++ src/app_error/tests.rs | 39 ++++++++ src/response/internal.rs | 1 + src/response/mapping.rs | 28 +++++- src/response/problem_json.rs | 52 ++++++++++ src/response/tests.rs | 98 +++++++++++++++++++ .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 - .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 28 files changed, 371 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74cbea5..6da1abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 6b697e7..1718ae4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.22.0" +version = "0.23.0" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 794a1be..d335367 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 5829698..fa67970 100644 --- a/README.md +++ b/README.md @@ -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", diff --git a/docs/wiki/masterror-application-guide.md b/docs/wiki/masterror-application-guide.md index d936189..af9d833 100644 --- a/docs/wiki/masterror-application-guide.md +++ b/docs/wiki/masterror-application-guide.md @@ -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 @@ -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. diff --git a/docs/wiki/patterns-and-troubleshooting.md b/docs/wiki/patterns-and-troubleshooting.md index 87a2249..2c62db2 100644 --- a/docs/wiki/patterns-and-troubleshooting.md +++ b/docs/wiki/patterns-and-troubleshooting.md @@ -56,7 +56,8 @@ 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 @@ -64,10 +65,10 @@ 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, @@ -75,9 +76,6 @@ fn to_json(err: &masterror::AppError) -> serde_json::Value { } ``` -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`, @@ -109,7 +107,7 @@ reconstruct what happened. | Validation failures return HTTP 500 | Enable the `validator` feature and expose handlers as `AppResult`. | | 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 diff --git a/src/app_error/core.rs b/src/app_error/core.rs index a4b455f..613c2e8 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -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}; @@ -61,6 +65,12 @@ pub struct ErrorInner { pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. pub www_authenticate: Option, + /// Optional structured details exposed to clients. + #[cfg(feature = "serde_json")] + pub details: Option, + /// Optional textual details when JSON is unavailable. + #[cfg(not(feature = "serde_json"))] + pub details: Option, pub source: Option>, #[cfg(feature = "backtrace")] pub backtrace: Option, @@ -238,6 +248,7 @@ impl Error { edit_policy: MessageEditPolicy::Preserve, retry: None, www_authenticate: None, + details: None, source: None, #[cfg(feature = "backtrace")] backtrace: None, @@ -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(self, payload: T) -> crate::AppResult + 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) -> Self { + self.details = Some(details.into()); + self.mark_dirty(); + self + } + /// Borrow the attached metadata. #[must_use] pub fn metadata(&self) -> &Metadata { diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 592d911..942601a 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -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(&self, _: S) -> Result + 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) diff --git a/src/response/internal.rs b/src/response/internal.rs index cc30758..6f03b13 100644 --- a/src/response/internal.rs +++ b/src/response/internal.rs @@ -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) diff --git a/src/response/mapping.rs b/src/response/mapping.rs index ecd3f4d..fb4848f 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -24,12 +24,24 @@ impl From 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 } @@ -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() } diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 9478f50..76e9411 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -117,6 +117,14 @@ pub struct ProblemJson { /// Optional human-readable detail (redacted when marked private). #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option>, + /// Optional structured details emitted to clients. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg(feature = "serde_json")] + pub details: Option, + /// Optional textual details emitted to clients when JSON is disabled. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg(not(feature = "serde_json"))] + pub details: Option, /// Stable machine-readable code. pub code: AppCode, /// Optional gRPC mapping for multi-protocol clients. @@ -158,6 +166,7 @@ impl ProblemJson { let message = error.message.take(); let metadata = core::mem::take(&mut error.metadata); let edit_policy = error.edit_policy; + let details = sanitize_details_owned(error.details.take(), edit_policy); let retry = error.retry.take(); let www_authenticate = error.www_authenticate.take(); @@ -172,6 +181,7 @@ impl ProblemJson { title, status, detail, + details, code, grpc: Some(mapping.grpc()), metadata, @@ -201,6 +211,7 @@ impl ProblemJson { let status = error.kind.http_status(); let title = Cow::Owned(error.kind.to_string()); let detail = sanitize_detail_ref(error); + let details = sanitize_details_ref(error); let metadata = sanitize_metadata_ref(error.metadata(), error.edit_policy); Self { @@ -208,6 +219,7 @@ impl ProblemJson { title, status, detail, + details, code: error.code, grpc: Some(mapping.grpc()), metadata, @@ -244,6 +256,7 @@ impl ProblemJson { title: Cow::Owned(mapping.kind().to_string()), status: response.status, detail, + details: response.details, code: response.code, grpc: Some(mapping.grpc()), metadata: None, @@ -400,6 +413,45 @@ fn sanitize_detail_ref(error: &AppError) -> Option> { Some(Cow::Owned(error.render_message().into_owned())) } +#[cfg(feature = "serde_json")] +fn sanitize_details_owned( + details: Option, + policy: MessageEditPolicy +) -> Option { + if matches!(policy, MessageEditPolicy::Redact) { + None + } else { + details + } +} + +#[cfg(not(feature = "serde_json"))] +fn sanitize_details_owned(details: Option, policy: MessageEditPolicy) -> Option { + if matches!(policy, MessageEditPolicy::Redact) { + None + } else { + details + } +} + +#[cfg(feature = "serde_json")] +fn sanitize_details_ref(error: &AppError) -> Option { + if matches!(error.edit_policy, MessageEditPolicy::Redact) { + None + } else { + error.details.clone() + } +} + +#[cfg(not(feature = "serde_json"))] +fn sanitize_details_ref(error: &AppError) -> Option { + if matches!(error.edit_policy, MessageEditPolicy::Redact) { + None + } else { + error.details.clone() + } +} + fn sanitize_metadata_owned( metadata: Metadata, policy: MessageEditPolicy diff --git a/src/response/tests.rs b/src/response/tests.rs index d7f11b0..5eb8b74 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -126,6 +126,104 @@ fn details_text_are_attached() { assert_eq!(e.details.as_deref(), Some("retry later")); } +#[cfg(feature = "serde_json")] +#[test] +fn app_error_mappings_propagate_json_details() { + use serde_json::json; + + let payload = json!({"hint": "enable"}); + + let resp: ErrorResponse = AppError::validation("invalid") + .with_details_json(payload.clone()) + .into(); + assert_eq!(resp.details, Some(payload.clone())); + + let borrowed = AppError::validation("invalid").with_details_json(payload.clone()); + let resp_ref: ErrorResponse = (&borrowed).into(); + assert_eq!(resp_ref.details, Some(payload.clone())); + + let problem_owned = ProblemJson::from_app_error( + AppError::validation("invalid").with_details_json(payload.clone()) + ); + assert_eq!(problem_owned.details, Some(payload.clone())); + + let problem_ref = ProblemJson::from_ref(&borrowed); + assert_eq!(problem_ref.details, Some(payload)); +} + +#[cfg(feature = "serde_json")] +#[test] +fn redacted_app_error_strips_json_details() { + use serde_json::json; + + let resp: ErrorResponse = AppError::internal("boom") + .with_details_json(json!({"private": true})) + .redactable() + .into(); + assert!(resp.details.is_none()); + + let borrowed = AppError::internal("boom") + .with_details_json(json!({"private": true})) + .redactable(); + let resp_ref: ErrorResponse = (&borrowed).into(); + assert!(resp_ref.details.is_none()); + let problem = ProblemJson::from_ref(&borrowed); + assert!(problem.details.is_none()); + + let owned_problem = ProblemJson::from_app_error( + AppError::internal("boom") + .with_details_json(json!({"private": true})) + .redactable() + ); + assert!(owned_problem.details.is_none()); +} + +#[cfg(not(feature = "serde_json"))] +#[test] +fn app_error_mappings_propagate_text_details() { + let resp: ErrorResponse = AppError::validation("invalid") + .with_details_text("enable feature") + .into(); + assert_eq!(resp.details.as_deref(), Some("enable feature")); + + let borrowed = AppError::validation("invalid").with_details_text("enable feature"); + let resp_ref: ErrorResponse = (&borrowed).into(); + assert_eq!(resp_ref.details.as_deref(), Some("enable feature")); + + let problem_owned = ProblemJson::from_app_error( + AppError::validation("invalid").with_details_text("enable feature") + ); + assert_eq!(problem_owned.details.as_deref(), Some("enable feature")); + + let problem_ref = ProblemJson::from_ref(&borrowed); + assert_eq!(problem_ref.details.as_deref(), Some("enable feature")); +} + +#[cfg(not(feature = "serde_json"))] +#[test] +fn redacted_app_error_strips_text_details() { + let resp: ErrorResponse = AppError::internal("boom") + .with_details_text("private") + .redactable() + .into(); + assert!(resp.details.is_none()); + + let borrowed = AppError::internal("boom") + .with_details_text("private") + .redactable(); + let resp_ref: ErrorResponse = (&borrowed).into(); + assert!(resp_ref.details.is_none()); + let problem = ProblemJson::from_ref(&borrowed); + assert!(problem.details.is_none()); + + let owned_problem = ProblemJson::from_app_error( + AppError::internal("boom") + .with_details_text("private") + .redactable() + ); + assert!(owned_problem.details.is_none()); +} + // --- From<&AppError> mapping -------------------------------------------- #[test] diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index bbc297c..d000de1 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("without")] -9 | | Without, - | |___________^ +8 | #[error("without")] + | ^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 4f02301..70ccade 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index 021c135..c615e98 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^^^^^^^^^^^^^^^^^^^^^ + | ^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b8f363..5b08225 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index be76742..d416399 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,5 +8,4 @@ error: multiple unused formatting arguments | argument never used | argument never used | - = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index b8bf229..d7acdb1 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index a6a40c2..5869420 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 | 4 | #[error("{value:y}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index 3d332c7..bbe04b4 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 | 4 | #[error("{value:B}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 0bd10fa..2c30e71 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 | 4 | #[error("{value:P}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index 113a10d..c3fb86b 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index 9ada290..b331baa 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index fd151cc..b2658a1 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 5a25e12..83d517f 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("missing")] -9 | | Missing - | |___________^ +8 | #[error("missing")] + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index bdadf45..f929951 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 037fac8..34abc91 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index 1822edf..d579838 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default From 1924dc1bb7e5868216c59a14213ea9ed501edcdf Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:45:20 +0700 Subject: [PATCH 03/28] feat: restore AppError::with_context helper --- CHANGELOG.md | 7 +++ Cargo.lock | 9 +++- Cargo.toml | 3 +- README.md | 8 +++- README.template.md | 4 ++ docs/wiki/error-crate-comparison.md | 5 ++- docs/wiki/masterror-application-guide.md | 6 ++- docs/wiki/patterns-and-troubleshooting.md | 6 +-- src/app_error/context.rs | 2 +- src/app_error/core.rs | 50 ++++++++++++++++++++++ src/app_error/tests.rs | 52 ++++++++++++++++++++++- src/lib.rs | 9 ++++ 12 files changed, 148 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da1abf..dfcbfa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.23.1] - 2025-10-13 + +### Fixed +- Restored the `AppError::with_context` helper as an alias for `with_source`, + preserving the `Arc` fast-path, updating documentation and README templates, + and adding regression tests for plain and `anyhow::Error` diagnostics. + ## [0.23.0] - 2025-10-12 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1718ae4..1344528 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "arraydeque" version = "0.5.1" @@ -1727,9 +1733,10 @@ dependencies = [ [[package]] name = "masterror" -version = "0.23.0" +version = "0.23.1" dependencies = [ "actix-web", + "anyhow", "axum 0.8.4", "config", "http 1.3.1", diff --git a/Cargo.toml b/Cargo.toml index d335367..641c99d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.23.0" +version = "0.23.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -129,6 +129,7 @@ uuid = { version = "1", default-features = false } tonic = { version = "0.12", optional = true } [dev-dependencies] +anyhow = { version = "1", default-features = false, features = ["std"] } serde_json = "1" tokio = { version = "1", features = [ "macros", diff --git a/README.md b/README.md index fa67970..87ce51f 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.23.0", default-features = false } +masterror = { version = "0.23.1", default-features = false } # or with features: -# masterror = { version = "0.23.0", features = [ +# masterror = { version = "0.23.1", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", @@ -101,6 +101,10 @@ 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); + +let err_with_context = AppError::internal("db down") + .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); +assert!(err_with_context.source_ref().is_some()); ~~~ With prelude: diff --git a/README.template.md b/README.template.md index 8d5482d..03bbd4f 100644 --- a/README.template.md +++ b/README.template.md @@ -96,6 +96,10 @@ 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); + +let err_with_context = AppError::internal("db down") + .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); +assert!(err_with_context.source_ref().is_some()); ~~~ With prelude: diff --git a/docs/wiki/error-crate-comparison.md b/docs/wiki/error-crate-comparison.md index 2fb3c37..b5bf6ba 100644 --- a/docs/wiki/error-crate-comparison.md +++ b/docs/wiki/error-crate-comparison.md @@ -123,8 +123,9 @@ fn load_configuration(path: &std::path::Path) -> masterror::AppResult { ``` `AppError` stores the `anyhow::Error` internally without exposing it to clients. -You still emit clean JSON responses, while logs retain the full diagnostic -payload. +`with_context` reuses any shared `Arc` handles provided by upstream crates, so +you preserve pointer identity without extra allocations. You still emit clean +JSON responses, while logs retain the full diagnostic payload. ## Why choose `masterror` diff --git a/docs/wiki/masterror-application-guide.md b/docs/wiki/masterror-application-guide.md index af9d833..7d854b5 100644 --- a/docs/wiki/masterror-application-guide.md +++ b/docs/wiki/masterror-application-guide.md @@ -66,8 +66,10 @@ 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. Enable the `serde_json` +`with_context` stores the original `serde_json::Error` for logging while reusing +any shared `Arc` the upstream library hands you, avoiding extra reference-count +allocations. Clients only 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. diff --git a/docs/wiki/patterns-and-troubleshooting.md b/docs/wiki/patterns-and-troubleshooting.md index 2c62db2..c42978d 100644 --- a/docs/wiki/patterns-and-troubleshooting.md +++ b/docs/wiki/patterns-and-troubleshooting.md @@ -80,9 +80,9 @@ fn to_json(err: masterror::AppError) -> serde_json::Value { 1. Log errors at the boundary with `tracing::error!`, including `kind`, `code`, and `retry` metadata. -2. Attach upstream errors via `with_context`. When you need additional metadata, - derive your error type with fields annotated using `#[provide]` from - `masterror::Error`. +2. Attach upstream errors via `with_context` to preserve shared `Arc` handles and + reuse upstream diagnostics. When you need additional metadata, derive your + error type with fields annotated using `#[provide]` from `masterror::Error`. ```rust #[tracing::instrument(skip(err))] diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 64a6e82..96bf40c 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -163,7 +163,7 @@ impl Context { if matches!(self.edit_policy, MessageEditPolicy::Redact) { error.edit_policy = MessageEditPolicy::Redact; } - let error = error.with_source(source); + let error = error.with_context(source); error.emit_telemetry(); error } diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 613c2e8..70a6f97 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -30,6 +30,23 @@ use tracing::{Level, event}; use super::metadata::{Field, FieldRedaction, Metadata}; use crate::{AppCode, AppErrorKind, RetryAdvice}; +/// Attachments accepted by [`Error::with_context`]. +#[derive(Debug)] +#[doc(hidden)] +pub enum ContextAttachment { + Owned(Box), + Shared(Arc) +} + +impl From for ContextAttachment +where + E: CoreError + Send + Sync + 'static +{ + fn from(source: E) -> Self { + Self::Owned(Box::new(source)) + } +} + #[cfg(feature = "std")] pub type CapturedBacktrace = std::backtrace::Backtrace; @@ -435,7 +452,40 @@ impl Error { self } + /// Attach upstream diagnostics using [`with_source`](Self::with_source) or + /// an existing [`Arc`]. + /// + /// This is the preferred alias for capturing upstream errors. It accepts + /// either an owned error implementing [`core::error::Error`] or a + /// shared [`Arc`] produced by other APIs, reusing the allocation when + /// possible. + /// + /// # Examples + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::service("downstream degraded") + /// .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); + /// assert!(err.source_ref().is_some()); + /// ``` + #[must_use] + pub fn with_context(self, context: impl Into) -> Self { + match context.into() { + ContextAttachment::Owned(source) => { + match source.downcast::>() { + Ok(shared) => self.with_source_arc(*shared), + Err(source) => self.with_source_arc(Arc::from(source)) + } + } + ContextAttachment::Shared(source) => self.with_source_arc(source) + } + } + /// Attach a source error for diagnostics. + /// + /// Prefer [`with_context`](Self::with_context) when capturing upstream + /// diagnostics without additional `Arc` allocations. #[must_use] pub fn with_source(mut self, source: impl CoreError + Send + Sync + 'static) -> Self { self.source = Some(Arc::new(source)); diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 942601a..81962a3 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -1,6 +1,33 @@ #[cfg(any(feature = "backtrace", feature = "tracing"))] use std::sync::Mutex; -use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; +use std::{ + borrow::Cow, + error::Error as StdError, + fmt::{Display, Formatter, Result as FmtResult}, + io::{Error as IoError, ErrorKind as IoErrorKind}, + sync::Arc +}; + +#[cfg(feature = "std")] +use anyhow::Error as AnyhowError; + +#[cfg(feature = "std")] +#[derive(Debug)] +struct AnyhowSource(AnyhowError); + +#[cfg(feature = "std")] +impl Display for AnyhowSource { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&self.0, f) + } +} + +#[cfg(feature = "std")] +impl StdError for AnyhowSource { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.0.source() + } +} #[cfg(feature = "backtrace")] use super::core::{reset_backtrace_preference, set_backtrace_preference_override}; @@ -119,6 +146,29 @@ fn constructors_match_kinds() { assert_err_with_msg(AppError::cache("cache"), AppErrorKind::Cache, "cache"); } +#[cfg(feature = "std")] +#[test] +fn with_context_attaches_plain_source() { + let err = AppError::internal("boom").with_context(IoError::from(IoErrorKind::Other)); + + let source = err.source_ref().expect("stored source"); + assert!(source.is::()); + assert_eq!(source.to_string(), IoErrorKind::Other.to_string()); +} + +#[cfg(feature = "std")] +#[test] +fn with_context_accepts_anyhow_error() { + let upstream: AnyhowError = anyhow::anyhow!("context failed"); + let err = AppError::service("downstream").with_context(AnyhowSource(upstream)); + + let source = err.source_ref().expect("stored source"); + let stored = source + .downcast_ref::() + .expect("anyhow source"); + assert_eq!(stored.0.to_string(), "context failed"); +} + #[test] fn database_accepts_optional_message() { let with_msg = AppError::database_with_message("db down"); diff --git a/src/lib.rs b/src/lib.rs index f98b7f4..83fbd13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -224,6 +224,15 @@ //! assert_eq!(err.metadata().len(), 2); //! ``` //! +//! Attach upstream diagnostics without cloning existing `Arc`s: +//! ```rust +//! use masterror::AppError; +//! +//! let err = AppError::internal("db down") +//! .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); +//! assert!(err.source_ref().is_some()); +//! ``` +//! //! [`AppErrorKind`] controls the default HTTP status mapping. //! [`AppCode`] provides a stable machine-readable code for clients. //! Together, they form the wire contract in [`ErrorResponse`]. From 412d358ae0615068e6d3d59ef7bca07b0a133e23 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Fri, 26 Sep 2025 16:00:41 +0700 Subject: [PATCH 04/28] up deps --- Cargo.lock | 311 ++++++++++++++-------------------------- Cargo.toml | 20 +-- src/response/details.rs | 1 - 3 files changed, 119 insertions(+), 213 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1344528..90ad12a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,9 +180,9 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] @@ -241,28 +241,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -295,47 +273,20 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http 1.3.1", - "http-body", - "http-body-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower 0.5.2", - "tower-layer", - "tower-service", -] - [[package]] name = "axum" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ - "axum-core 0.5.2", + "axum-core", "bytes", "futures-util", "http 1.3.1", "http-body", "http-body-util", "itoa", - "matchit 0.8.4", + "matchit", "memchr", "mime", "multer", @@ -346,27 +297,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "sync_wrapper", - "tower 0.5.2", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.3.1", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", + "tower", "tower-layer", "tower-service", ] @@ -392,9 +323,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -402,7 +333,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -476,9 +407,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.38" +version = "1.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ "find-msvc-tools", "shlex", @@ -505,7 +436,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -529,9 +460,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.16" +version = "0.15.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef036f0ecf99baef11555578630e2cca559909b4c50822dbba828c252d21c49" +checksum = "680d3ac2fe066c43300ec831c978871e50113a708d58ab13d231bd92deca5adb" dependencies = [ "async-trait", "convert_case", @@ -763,12 +694,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -933,7 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -1147,9 +1078,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" @@ -1594,9 +1525,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -1630,9 +1561,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libm" @@ -1720,13 +1651,14 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "masterror" -version = "0.5.0" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969456c81bd3bd6b7f2c468ee95d5d6c5070050746e56f9ab4dd2a161b4ea55" +checksum = "1a8b870429788ecb76b1070b592135bff852829e34db09e237e848a231048535" dependencies = [ "http 1.3.1", + "masterror-derive 0.6.6", + "masterror-template", "serde", - "thiserror", "toml", "tracing", ] @@ -1737,13 +1669,13 @@ version = "0.23.1" dependencies = [ "actix-web", "anyhow", - "axum 0.8.4", + "axum", "config", "http 1.3.1", "js-sys", "log", "log-mdc", - "masterror-derive", + "masterror-derive 0.9.0", "masterror-template", "metrics", "redis", @@ -1769,6 +1701,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "masterror-derive" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef013bb3e4e26e81cb15c88cc405833054faa4cc1005bc06778fddbd074d8973" +dependencies = [ + "masterror-template", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "masterror-derive" version = "0.9.0" @@ -1783,12 +1727,6 @@ dependencies = [ name = "masterror-template" version = "0.3.6" -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "matchit" version = "0.8.4" @@ -1807,9 +1745,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "metrics" @@ -1949,9 +1887,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -2192,15 +2130,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes", -] - [[package]] name = "psm" version = "0.1.26" @@ -2394,9 +2323,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", @@ -2406,9 +2335,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", @@ -2460,7 +2389,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower 0.5.2", + "tower", "tower-http", "tower-service", "url", @@ -2566,7 +2495,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -2634,7 +2563,7 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -2669,9 +2598,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" +checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" dependencies = [ "bitflags", "core-foundation", @@ -2698,9 +2627,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.225" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "80ece43fc6fbed4eb5392ab50c07334d3e577cbf40997ee896fe7af40bba4245" dependencies = [ "serde_core", "serde_derive", @@ -2731,18 +2660,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.225" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +checksum = "7a576275b607a2c86ea29e410193df32bc680303c82f31e275bbfcafe8b33be5" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "51e694923b8824cf0e9b382adf0f60d4e05f348f357b38833a3fa5ed7c2ede04" dependencies = [ "proc-macro2", "quote", @@ -3209,16 +3138,16 @@ checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790" [[package]] name = "telegram-webapp-sdk" -version = "0.2.5" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd3a15d59612e51af250f2d3919997146565ac77fed922dcc11aa8323c4d5d3" +checksum = "69839faab15cf21dd9ef839cc415e546ea8f92d1d8371f433d049aa30bd57483" dependencies = [ "base64 0.22.1", "ed25519-dalek", "hex", "hmac-sha256", "js-sys", - "masterror 0.5.0", + "masterror 0.10.8", "once_cell", "percent-encoding", "regex", @@ -3227,7 +3156,6 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", - "thiserror", "toml", "wasm-bindgen", "wasm-bindgen-futures", @@ -3268,15 +3196,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -3488,13 +3416,12 @@ checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" [[package]] name = "tonic" -version = "0.12.3" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ - "async-stream", "async-trait", - "axum 0.7.9", + "axum", "base64 0.22.1", "bytes", "h2", @@ -3506,31 +3433,11 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", - "socket2 0.5.10", + "socket2 0.6.0", + "sync_wrapper", "tokio", "tokio-stream", - "tower 0.4.13", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.5", - "slab", - "tokio", - "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -3544,11 +3451,15 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", + "indexmap 2.11.4", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3564,7 +3475,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", ] @@ -3870,9 +3781,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", @@ -3883,9 +3794,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", @@ -3897,9 +3808,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.53" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -3910,9 +3821,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3920,9 +3831,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", @@ -3933,9 +3844,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -3955,9 +3866,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.80" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -3989,27 +3900,27 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] name = "windows-core" -version = "0.62.0" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.0", + "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" dependencies = [ "proc-macro2", "quote", @@ -4018,21 +3929,15 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" dependencies = [ "proc-macro2", "quote", "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.0" @@ -4045,7 +3950,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -4054,7 +3959,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -4090,16 +3995,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.4", ] [[package]] name = "windows-sys" -version = "0.61.0" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" dependencies = [ - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -4135,11 +4040,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ - "windows-link 0.1.3", + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", diff --git a/Cargo.toml b/Cargo.toml index 641c99d..5e8e6be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ include = [ "masterror-derive/**", "masterror-template/**", ".cargo/audit.toml", - ".cargo/config.toml" + ".cargo/config.toml", ] [workspace] @@ -50,10 +50,7 @@ readme = "README.md" [features] default = ["std"] -std = [ - "uuid/std", - "serde/std" -] +std = ["uuid/std", "serde/std"] tracing = ["dep:tracing", "dep:log", "dep:log-mdc", "std"] metrics = ["dep:metrics", "std"] backtrace = ["std"] @@ -87,14 +84,19 @@ masterror-derive = { version = "0.9" } masterror-template = { workspace = true } tracing = { version = "0.1", optional = true, default-features = false, features = [ "attributes", - "std" + "std", ] } log = { version = "0.4", optional = true } log-mdc = { version = "0.1", optional = true } metrics = { version = "0.24", optional = true } -serde = { version = "1", default-features = false, features = ["derive", "alloc"] } -serde_json = { version = "1", optional = true, default-features = false, features = ["std"] } +serde = { version = "1", default-features = false, features = [ + "derive", + "alloc", +] } +serde_json = { version = "1", optional = true, default-features = false, features = [ + "std", +] } http = "1" sha2 = "0.10" @@ -126,7 +128,7 @@ 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 } -tonic = { version = "0.12", optional = true } +tonic = { version = "0.14", optional = true } [dev-dependencies] anyhow = { version = "1", default-features = false, features = ["std"] } diff --git a/src/response/details.rs b/src/response/details.rs index 3de1226..f1a05c4 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -63,6 +63,5 @@ impl ErrorResponse { Ok(self.with_details_json(details)) } } -use alloc::string::String; #[cfg(feature = "serde_json")] use alloc::string::ToString; From 8ba7832895240714dc3374a81851517b661deb8a Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:11:41 +0700 Subject: [PATCH 05/28] chore: fix lint warning --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/response/details.rs | 1 - 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfcbfa5..8fea720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.23.2] - 2025-10-14 + +### Fixed +- Removed an unused `String` import from the response details module to keep + builds warning-free under `-D warnings`. + ## [0.23.1] - 2025-10-13 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 1344528..2b860df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1733,7 +1733,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.23.1" +version = "0.23.2" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 641c99d..368d247 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.23.1" +version = "0.23.2" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/src/response/details.rs b/src/response/details.rs index 3de1226..f1a05c4 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -63,6 +63,5 @@ impl ErrorResponse { Ok(self.with_details_json(details)) } } -use alloc::string::String; #[cfg(feature = "serde_json")] use alloc::string::ToString; From 3d104d80ed18bd48a67980139a0c46e4991b4680 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 26 Sep 2025 17:14:41 +0700 Subject: [PATCH 06/28] Optimize last4 masking iterator --- src/response/problem_json.rs | 43 +++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 76e9411..3842a8d 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -1,8 +1,7 @@ use alloc::{ borrow::Cow, collections::BTreeMap, - string::{String, ToString}, - vec::Vec + string::{String, ToString} }; use core::{fmt::Write, net::IpAddr}; @@ -598,8 +597,8 @@ fn mask_last4_field_value(value: &FieldValue) -> Option { } fn mask_last4(value: &str) -> String { - let chars: Vec = value.chars().collect(); - let total = chars.len(); + let chars = value.chars(); + let total = chars.clone().count(); if total == 0 { return String::new(); } @@ -607,12 +606,8 @@ fn mask_last4(value: &str) -> String { let keep = if total <= 4 { 1 } else { 4 }; let mask_len = total.saturating_sub(keep); let mut masked = String::with_capacity(value.len()); - for _ in 0..mask_len { - masked.push('*'); - } - for ch in chars.iter().skip(mask_len) { - masked.push(*ch); - } + masked.extend(core::iter::repeat_n('*', mask_len)); + masked.extend(chars.skip(mask_len)); masked } @@ -1080,6 +1075,34 @@ mod tests { } } + #[test] + fn last4_metadata_handles_multibyte_suffix() { + let multibyte = "💳💳💳💳💳💳"; + let err = AppError::internal("oops").with_field( + crate::field::str("emoji", multibyte).with_redaction(FieldRedaction::Last4) + ); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("emoji").expect("emoji field"); + match value { + ProblemMetadataValue::String(text) => { + let total = multibyte.chars().count(); + let keep = if total <= 4 { 1 } else { 4 }; + let expected_mask_len = total.saturating_sub(keep); + let expected_suffix: String = multibyte.chars().skip(expected_mask_len).collect(); + + assert!(text.ends_with(&expected_suffix)); + assert!(text.chars().take(expected_mask_len).all(|c| c == '*')); + assert_eq!( + text.chars().filter(|c| *c == '*').count(), + expected_mask_len + ); + assert_eq!(text.chars().count(), multibyte.chars().count()); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + #[test] fn problem_json_serialization_masks_sensitive_metadata() { let secret = "super-secret"; From 0041cf0a61cefc9e1b3949adac9926a4c5898de6 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:22:21 +0700 Subject: [PATCH 07/28] Use stack buffers for metadata redaction --- CHANGELOG.md | 11 ++ Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 4 +- src/response/problem_json.rs | 211 ++++++++++++++++++++++++++++++++--- 5 files changed, 215 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fea720..ce93848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.23.3] - 2025-10-15 + +### Changed +- Replaced temporary `String` allocations in RFC7807 metadata hashing and masking + with stack buffers to keep the textual representations and digests stable + while avoiding heap usage. + +### Added +- Regression tests covering hashed and last-four redaction paths for numeric, + UUID, and IP metadata to guarantee the legacy formatting remains unchanged. + ## [0.23.2] - 2025-10-14 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index e0f796a..9164e4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,13 +1665,14 @@ dependencies = [ [[package]] name = "masterror" -version = "0.23.2" +version = "0.23.3" dependencies = [ "actix-web", "anyhow", "axum", "config", "http 1.3.1", + "itoa", "js-sys", "log", "log-mdc", @@ -1680,6 +1681,7 @@ dependencies = [ "metrics", "redis", "reqwest", + "ryu", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2dc3c9b..4143887 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.23.2" +version = "0.23.3" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -99,6 +99,8 @@ serde_json = { version = "1", optional = true, default-features = false, feature ] } http = "1" sha2 = "0.10" +itoa = "1" +ryu = "1" # optional integrations axum = { version = "0.8", optional = true, default-features = false, features = [ diff --git a/README.md b/README.md index 87ce51f..3b93967 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.23.1", default-features = false } +masterror = { version = "0.23.3", default-features = false } # or with features: -# masterror = { version = "0.23.1", features = [ +# masterror = { version = "0.23.3", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 3842a8d..d6eaa03 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -6,6 +6,8 @@ use alloc::{ use core::{fmt::Write, net::IpAddr}; use http::StatusCode; +use itoa::Buffer as IntegerBuffer; +use ryu::Buffer as FloatBuffer; use serde::Serialize; #[cfg(feature = "serde_json")] use serde_json::Value as JsonValue; @@ -532,17 +534,51 @@ fn sanitize_problem_metadata_value_ref( } } +struct StackBuffer { + buf: [u8; N], + len: usize +} + +impl StackBuffer { + const fn new() -> Self { + Self { + buf: [0; N], + len: 0 + } + } + + fn as_bytes(&self) -> &[u8] { + &self.buf[..self.len] + } + + fn as_str(&self) -> Option<&str> { + core::str::from_utf8(self.as_bytes()).ok() + } +} + +impl Write for StackBuffer { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + let remaining = N.saturating_sub(self.len); + if s.len() > remaining { + return Err(core::fmt::Error); + } + self.buf[self.len..self.len + s.len()].copy_from_slice(s.as_bytes()); + self.len += s.len(); + Ok(()) + } +} + fn hash_field_value(value: &FieldValue) -> String { let mut hasher = Sha256::new(); match value { FieldValue::Str(value) => hasher.update(value.as_ref().as_bytes()), FieldValue::I64(value) => { - let string = value.to_string(); - hasher.update(string.as_bytes()); + let mut buffer = IntegerBuffer::new(); + hasher.update(buffer.format(*value).as_bytes()); } FieldValue::U64(value) => { - let string = value.to_string(); - hasher.update(string.as_bytes()); + let mut buffer = IntegerBuffer::new(); + hasher.update(buffer.format(*value).as_bytes()); } FieldValue::F64(value) => hasher.update(value.to_le_bytes()), FieldValue::Bool(value) => { @@ -553,17 +589,25 @@ fn hash_field_value(value: &FieldValue) -> String { } } FieldValue::Uuid(value) => { - let string = value.to_string(); - hasher.update(string.as_bytes()); + // `Uuid::to_string()` produces a lowercase hyphenated representation; we + // keep the same bytes to preserve the hash output that clients rely on. + let mut repr = [0u8; 36]; + let text = value.hyphenated().encode_lower(&mut repr); + hasher.update(text.as_bytes()); } FieldValue::Duration(value) => { hasher.update(value.as_secs().to_le_bytes()); hasher.update(value.subsec_nanos().to_le_bytes()); } - FieldValue::Ip(value) => match value { - IpAddr::V4(addr) => hasher.update(addr.octets()), - IpAddr::V6(addr) => hasher.update(addr.octets()) - }, + FieldValue::Ip(value) => { + let mut buffer = StackBuffer::<46>::new(); + if write!(&mut buffer, "{value}").is_ok() { + hasher.update(buffer.as_bytes()); + } else { + let fallback = value.to_string(); + hasher.update(fallback.as_bytes()); + } + } #[cfg(feature = "serde_json")] FieldValue::Json(value) => { if let Ok(serialized) = serde_json::to_vec(value) { @@ -582,12 +626,31 @@ fn hash_field_value(value: &FieldValue) -> String { fn mask_last4_field_value(value: &FieldValue) -> Option { match value { FieldValue::Str(value) => Some(mask_last4(value.as_ref())), - FieldValue::I64(value) => Some(mask_last4(&value.to_string())), - FieldValue::U64(value) => Some(mask_last4(&value.to_string())), - FieldValue::F64(value) => Some(mask_last4(&value.to_string())), - FieldValue::Uuid(value) => Some(mask_last4(&value.to_string())), + FieldValue::I64(value) => { + let mut buffer = IntegerBuffer::new(); + Some(mask_last4(buffer.format(*value))) + } + FieldValue::U64(value) => { + let mut buffer = IntegerBuffer::new(); + Some(mask_last4(buffer.format(*value))) + } + FieldValue::F64(value) => { + let mut buffer = FloatBuffer::new(); + Some(mask_last4(buffer.format(*value))) + } + FieldValue::Uuid(value) => { + let mut repr = [0u8; 36]; + let text = value.hyphenated().encode_lower(&mut repr); + Some(mask_last4(text)) + } FieldValue::Duration(value) => Some(mask_last4(&duration_to_string(*value))), - FieldValue::Ip(value) => Some(mask_last4(&value.to_string())), + FieldValue::Ip(value) => { + let mut buffer = StackBuffer::<46>::new(); + if write!(&mut buffer, "{value}").is_err() { + return Some(mask_last4(&value.to_string())); + } + buffer.as_str().map(mask_last4) + } #[cfg(feature = "serde_json")] FieldValue::Json(value) => serde_json::to_string(value) .ok() @@ -959,10 +1022,23 @@ mod tests { use serde_json::Value; use sha2::{Digest, Sha256}; + use uuid::Uuid; use super::*; use crate::AppError; + fn sha256_hex(input: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(input); + hasher + .finalize() + .iter() + .fold(String::with_capacity(64), |mut acc, byte| { + let _ = write!(&mut acc, "{:02x}", byte); + acc + }) + } + #[test] fn metadata_is_skipped_when_redacted() { let err = AppError::internal("secret") @@ -1059,6 +1135,58 @@ mod tests { } } + #[test] + fn hashed_numeric_metadata_uses_decimal_text() { + let err = AppError::internal("oops") + .with_field(crate::field::u64("attempt", 42).with_redaction(FieldRedaction::Hash)); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("attempt").expect("attempt field"); + match value { + ProblemMetadataValue::String(text) => { + let expected = sha256_hex(b"42"); + assert_eq!(text.as_ref(), expected); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn hashed_uuid_metadata_preserves_hyphenated_text() { + let uuid = Uuid::from_u128(0x1234_5678_9abc_def0_1234_5678_9abc_def0); + let err = AppError::internal("oops") + .with_field(crate::field::uuid("trace", uuid).with_redaction(FieldRedaction::Hash)); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("trace").expect("trace field"); + match value { + ProblemMetadataValue::String(text) => { + let mut repr = [0u8; 36]; + let expected_repr = uuid.hyphenated().encode_lower(&mut repr); + let expected = sha256_hex(expected_repr.as_bytes()); + assert_eq!(text.as_ref(), expected); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn hashed_ip_metadata_preserves_display_text() { + let ip = IpAddr::from(Ipv4Addr::new(10, 10, 10, 10)); + let err = AppError::internal("oops") + .with_field(crate::field::ip("peer", ip).with_redaction(FieldRedaction::Hash)); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("peer").expect("peer field"); + match value { + ProblemMetadataValue::String(text) => { + let expected = sha256_hex(ip.to_string().as_bytes()); + assert_eq!(text.as_ref(), expected); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + #[test] fn last4_metadata_preserves_suffix() { let err = AppError::internal("oops") @@ -1103,6 +1231,59 @@ mod tests { } } + #[test] + fn last4_numeric_metadata_matches_decimal_format() { + let number = 123456789u64; + let err = AppError::internal("oops").with_field( + crate::field::u64("invoice", number).with_redaction(FieldRedaction::Last4) + ); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let metadata_value = metadata.0.get("invoice").expect("invoice field"); + let expected_suffix = mask_last4(&number.to_string()); + match metadata_value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.as_ref(), expected_suffix); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn last4_uuid_metadata_matches_previous_format() { + let uuid = Uuid::from_u128(0x4321_8765_cba9_0fed_cba9_8765_4321_0fed); + let err = AppError::internal("oops") + .with_field(crate::field::uuid("trace", uuid).with_redaction(FieldRedaction::Last4)); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let metadata_value = metadata.0.get("trace").expect("trace field"); + let expected_repr = uuid.to_string(); + let expected_suffix = mask_last4(&expected_repr); + match metadata_value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.as_ref(), expected_suffix); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn last4_ip_metadata_matches_previous_format() { + let ip = IpAddr::from(Ipv4Addr::new(172, 16, 10, 1)); + let err = AppError::internal("oops") + .with_field(crate::field::ip("peer", ip).with_redaction(FieldRedaction::Last4)); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let metadata_value = metadata.0.get("peer").expect("peer field"); + let expected_suffix = mask_last4(&ip.to_string()); + match metadata_value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.as_ref(), expected_suffix); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + #[test] fn problem_json_serialization_masks_sensitive_metadata() { let secret = "super-secret"; From 48db55da6b13cb029425e6451562114fdf34a7f1 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:18:05 +0700 Subject: [PATCH 08/28] Refactor AppCode newtype and update docs --- CHANGELOG.md | 17 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- docs/wiki/error-crate-comparison.md | 4 + docs/wiki/masterror-application-guide.md | 4 + docs/wiki/patterns-and-troubleshooting.md | 4 + src/app_error/context.rs | 2 +- src/app_error/tests.rs | 22 +- src/code.rs | 49 +- src/code/app_code.rs | 460 ++++++++++-------- src/convert/sqlx.rs | 2 +- src/convert/tonic.rs | 2 +- src/mapping.rs | 18 +- src/response/core.rs | 2 +- src/response/mapping.rs | 4 +- src/response/problem_json.rs | 43 +- src/response/tests.rs | 13 + .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 + .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 34 files changed, 433 insertions(+), 276 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce93848..22b5dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.0] - 2025-10-16 + +### Added +- Introduced `AppCode::new` and `AppCode::try_new` constructors with strict + SCREAMING_SNAKE_CASE validation, plus regression tests covering custom codes + flowing through `AppError` and `ErrorResponse` JSON serialization. +- Documented runtime-defined codes across the wiki pages to highlight + `AppCode::try_new` usage. + +### Changed +- Replaced the closed `AppCode` enum with a string-backed newtype supporting + caller-defined codes while preserving built-in constants. +- Updated mapping helpers and generated tables to work with the new representation + by returning references instead of copying codes. +- Adjusted serde parsing to validate custom codes and report + `ParseAppCodeError` on invalid payloads. + ## [0.23.3] - 2025-10-15 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 9164e4d..5102aca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.23.3" +version = "0.24.0" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4143887..e28329d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.23.3" +version = "0.24.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 3b93967..855543d 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.23.3", default-features = false } +masterror = { version = "0.24.0", default-features = false } # or with features: -# masterror = { version = "0.23.3", features = [ +# masterror = { version = "0.24.0", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", diff --git a/docs/wiki/error-crate-comparison.md b/docs/wiki/error-crate-comparison.md index b5bf6ba..10394eb 100644 --- a/docs/wiki/error-crate-comparison.md +++ b/docs/wiki/error-crate-comparison.md @@ -122,6 +122,10 @@ fn load_configuration(path: &std::path::Path) -> masterror::AppResult { } ``` +If the configuration source must encode per-environment values in the code, use +`AppCode::try_new` to build the identifier dynamically and bubble up +`ParseAppCodeError` when validation fails. + `AppError` stores the `anyhow::Error` internally without exposing it to clients. `with_context` reuses any shared `Arc` handles provided by upstream crates, so you preserve pointer identity without extra allocations. You still emit clean diff --git a/docs/wiki/masterror-application-guide.md b/docs/wiki/masterror-application-guide.md index 7d854b5..95aec11 100644 --- a/docs/wiki/masterror-application-guide.md +++ b/docs/wiki/masterror-application-guide.md @@ -73,6 +73,10 @@ Enable the `serde_json` feature to use `.with_details(..)`; without it, fall back to `AppError::with_details_text` for plain-text payloads. +Need to generate codes dynamically (e.g., include partner identifiers)? Call +[`AppCode::try_new`](https://docs.rs/masterror/latest/masterror/struct.AppCode.html#method.try_new) +with a runtime string and propagate [`ParseAppCodeError`] when validation fails. + ## Deriving domain errors Combine `masterror::Error` derive macros with `#[app_error]` to convert domain diff --git a/docs/wiki/patterns-and-troubleshooting.md b/docs/wiki/patterns-and-troubleshooting.md index c42978d..2f76ca2 100644 --- a/docs/wiki/patterns-and-troubleshooting.md +++ b/docs/wiki/patterns-and-troubleshooting.md @@ -24,6 +24,10 @@ pub async fn fetch_user(client: &reqwest::Client) -> masterror::AppResult = Mutex::new(()); static TELEMETRY_GUARD: Mutex<()> = Mutex::new(()); use super::{AppError, FieldRedaction, FieldValue, MessageEditPolicy, field}; -use crate::{AppCode, AppErrorKind}; +use crate::{AppCode, AppErrorKind, ErrorResponse}; // --- Helpers ------------------------------------------------------------- @@ -223,6 +223,26 @@ fn metadata_and_code_are_preserved() { assert_eq!(metadata.get("attempt"), Some(&FieldValue::I64(2))); } +#[test] +fn custom_literal_codes_flow_into_responses() { + let custom = AppCode::new("INVALID_JSON"); + let err = AppError::bad_request("invalid").with_code(custom.clone()); + assert_eq!(err.code, custom); + + let response: ErrorResponse = err.into(); + assert_eq!(response.code, custom); +} + +#[test] +fn dynamic_codes_flow_into_responses() { + let custom = AppCode::try_new(String::from("THIRD_PARTY_FAILURE")).expect("valid code"); + let err = AppError::service("down").with_code(custom.clone()); + assert_eq!(err.code, custom); + + let response: ErrorResponse = err.into(); + assert_eq!(response.code, custom); +} + #[cfg(feature = "serde_json")] #[test] fn with_details_json_attaches_payload() { diff --git a/src/code.rs b/src/code.rs index b9dc44a..1d3b3c5 100644 --- a/src/code.rs +++ b/src/code.rs @@ -12,9 +12,11 @@ //! which remains stable even if your transport mapping changes. //! //! ## Stability and SemVer -//! - New variants **may be added in minor releases** (non-breaking). -//! - The enum is marked `#[non_exhaustive]` so downstream users must include a -//! wildcard arm (`_`) when matching, which keeps them forward-compatible. +//! - New built-in constants **may be added in minor releases** (non-breaking). +//! - The type is marked `#[non_exhaustive]` to allow future metadata additions +//! without breaking downstream code. +//! - Custom codes can be defined at compile time with [`AppCode::new`] or at +//! runtime with [`AppCode::try_new`]. //! //! ## Typical usage //! Construct an `ErrorResponse` with a code and return it to clients: @@ -45,27 +47,38 @@ //! # } //! ``` //! -//! Match codes safely (note the wildcard arm due to `#[non_exhaustive]`): +//! Match codes safely: //! //! ```rust //! use masterror::AppCode; //! -//! fn is_client_error(code: AppCode) -> bool { -//! match code { -//! AppCode::NotFound -//! | AppCode::Validation -//! | AppCode::Conflict -//! | AppCode::Unauthorized -//! | AppCode::Forbidden -//! | AppCode::NotImplemented -//! | AppCode::BadRequest -//! | AppCode::RateLimited -//! | AppCode::TelegramAuth -//! | AppCode::InvalidJwt => true, -//! _ => false // future-proof: treat unknown as not client error -//! } +//! fn is_client_error(code: &AppCode) -> bool { +//! matches!( +//! code.as_str(), +//! "NOT_FOUND" +//! | "VALIDATION" +//! | "CONFLICT" +//! | "UNAUTHORIZED" +//! | "FORBIDDEN" +//! | "NOT_IMPLEMENTED" +//! | "BAD_REQUEST" +//! | "RATE_LIMITED" +//! | "TELEGRAM_AUTH" +//! | "INVALID_JWT" +//! ) //! } //! ``` +//! +//! Define custom codes: +//! +//! ```rust +//! use masterror::AppCode; +//! +//! const INVALID_JSON: AppCode = AppCode::new("INVALID_JSON"); +//! let third_party = AppCode::try_new(String::from("THIRD_PARTY_FAILURE")).expect("valid code"); +//! assert_eq!(INVALID_JSON.as_str(), "INVALID_JSON"); +//! assert_eq!(third_party.as_str(), "THIRD_PARTY_FAILURE"); +//! ``` mod app_code; diff --git a/src/code/app_code.rs b/src/code/app_code.rs index 37ffc12..b6236c5 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,10 +1,11 @@ +use alloc::{boxed::Box, string::String}; use core::{ error::Error as CoreError, fmt::{self, Display}, str::FromStr }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[cfg(feature = "openapi")] use utoipa::ToSchema; @@ -12,8 +13,9 @@ use crate::kind::AppErrorKind; /// Error returned when parsing [`AppCode`] from a string fails. /// -/// The parser only accepts the canonical SCREAMING_SNAKE_CASE representations -/// emitted by [`AppCode::as_str`]. Any other value results in this error. +/// The parser only accepts SCREAMING_SNAKE_CASE values accepted by +/// [`AppCode::new`] and [`AppCode::try_new`]. Any other value results in this +/// error. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ParseAppCodeError; @@ -28,174 +30,146 @@ impl CoreError for ParseAppCodeError {} /// Stable machine-readable error code exposed to clients. /// /// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g., -/// `"NOT_FOUND"`). This type is part of the public wire contract. +/// `"NOT_FOUND"`). This type is part of the public wire contract and supports +/// both built-in constants and caller-defined codes created via +/// [`AppCode::new`] or [`AppCode::try_new`]. /// /// Design rules: /// - Keep the set small and meaningful. /// - Prefer adding new variants over overloading existing ones. /// - Do not encode private/internal details in codes. +/// - Validate custom codes using [`AppCode::try_new`] before exposing them +/// publicly. #[cfg_attr(feature = "openapi", derive(ToSchema))] #[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AppCode { - // ───────────── 4xx family (client-visible categories) ───────────── - /// Resource does not exist or is not visible to the caller. - /// - /// Typically mapped to HTTP **404 Not Found**. - NotFound, - - /// Input failed validation (shape, constraints, business rules). - /// - /// Typically mapped to HTTP **422 Unprocessable Entity**. - Validation, - - /// State conflict with an existing resource or concurrent update. - /// - /// Typically mapped to HTTP **409 Conflict**. - Conflict, - - /// Attempted to create a user that already exists (unique constraint). - /// - /// Typically mapped to HTTP **409 Conflict**. - UserAlreadyExists, - - /// Authentication required or failed (missing/invalid credentials). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - Unauthorized, - - /// Authenticated but not allowed to perform the operation. - /// - /// Typically mapped to HTTP **403 Forbidden**. - Forbidden, - - /// Operation is not implemented or not supported by this deployment. - /// - /// Typically mapped to HTTP **501 Not Implemented**. - NotImplemented, - - /// Malformed request or missing required parameters. - /// - /// Typically mapped to HTTP **400 Bad Request**. - BadRequest, - - /// Client exceeded rate limits or quota. - /// - /// Typically mapped to HTTP **429 Too Many Requests**. - RateLimited, - - /// Telegram authentication flow failed (signature, timestamp, or payload). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - TelegramAuth, - - /// Provided JWT is invalid (expired, malformed, wrong signature/claims). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - InvalidJwt, - - // ───────────── 5xx family (server/infra categories) ───────────── - /// Unexpected server-side failure not captured by more specific kinds. - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Internal, +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AppCode { + repr: CodeRepr +} - /// Database-related failure (query, connection, migration, etc.). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Database, +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum CodeRepr { + Static(&'static str), + Owned(Box) +} - /// Generic service-layer failure (business logic or orchestration). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Service, +#[allow(non_upper_case_globals)] +impl AppCode { + /// Machine code emitted when a resource is not found. + pub const NotFound: Self = Self::from_static("NOT_FOUND"); + /// Machine code emitted when validation fails. + pub const Validation: Self = Self::from_static("VALIDATION"); + /// Machine code emitted when a conflict is detected. + pub const Conflict: Self = Self::from_static("CONFLICT"); + /// Machine code emitted when attempting to create an existing user. + pub const UserAlreadyExists: Self = Self::from_static("USER_ALREADY_EXISTS"); + /// Machine code emitted when authentication fails or is required. + pub const Unauthorized: Self = Self::from_static("UNAUTHORIZED"); + /// Machine code emitted when an operation is not permitted. + pub const Forbidden: Self = Self::from_static("FORBIDDEN"); + /// Machine code emitted when functionality is missing. + pub const NotImplemented: Self = Self::from_static("NOT_IMPLEMENTED"); + /// Machine code emitted when a request is malformed. + pub const BadRequest: Self = Self::from_static("BAD_REQUEST"); + /// Machine code emitted when a caller is throttled. + pub const RateLimited: Self = Self::from_static("RATE_LIMITED"); + /// Machine code emitted when Telegram authentication fails. + pub const TelegramAuth: Self = Self::from_static("TELEGRAM_AUTH"); + /// Machine code emitted when a JWT token is invalid. + pub const InvalidJwt: Self = Self::from_static("INVALID_JWT"); + /// Machine code emitted for internal server failures. + pub const Internal: Self = Self::from_static("INTERNAL"); + /// Machine code emitted for database-related issues. + pub const Database: Self = Self::from_static("DATABASE"); + /// Machine code emitted for service-layer failures. + pub const Service: Self = Self::from_static("SERVICE"); + /// Machine code emitted for configuration issues. + pub const Config: Self = Self::from_static("CONFIG"); + /// Machine code emitted for Turnkey integration failures. + pub const Turnkey: Self = Self::from_static("TURNKEY"); + /// Machine code emitted for timeout failures. + pub const Timeout: Self = Self::from_static("TIMEOUT"); + /// Machine code emitted for network issues. + pub const Network: Self = Self::from_static("NETWORK"); + /// Machine code emitted when dependencies are unavailable. + pub const DependencyUnavailable: Self = Self::from_static("DEPENDENCY_UNAVAILABLE"); + /// Machine code emitted for serialization failures. + pub const Serialization: Self = Self::from_static("SERIALIZATION"); + /// Machine code emitted for deserialization failures. + pub const Deserialization: Self = Self::from_static("DESERIALIZATION"); + /// Machine code emitted when an external API fails. + pub const ExternalApi: Self = Self::from_static("EXTERNAL_API"); + /// Machine code emitted for queue processing errors. + pub const Queue: Self = Self::from_static("QUEUE"); + /// Machine code emitted for cache subsystem failures. + pub const Cache: Self = Self::from_static("CACHE"); + + const fn from_static(code: &'static str) -> Self { + Self { + repr: CodeRepr::Static(code) + } + } - /// Configuration error (missing/invalid environment or runtime config). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Config, + fn from_owned(code: String) -> Self { + Self { + repr: CodeRepr::Owned(code.into_boxed_str()) + } + } - /// Failure in the Turnkey subsystem/integration. + /// Construct an [`AppCode`] from a compile-time string literal. /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Turnkey, - - /// Operation did not complete within the allotted time. + /// # Examples + /// ``` + /// use masterror::AppCode; /// - /// Typically mapped to HTTP **504 Gateway Timeout**. - Timeout, - - /// Network-level error (DNS, connect, TLS, request build). + /// let code = AppCode::new("INVALID_JSON"); + /// assert_eq!(code.as_str(), "INVALID_JSON"); + /// ``` /// - /// Typically mapped to HTTP **503 Service Unavailable**. - Network, - - /// External dependency is unavailable or degraded (cache, broker, - /// third-party). + /// # Panics /// - /// Typically mapped to HTTP **503 Service Unavailable**. - DependencyUnavailable, + /// Panics when the literal is not SCREAMING_SNAKE_CASE. Use + /// [`AppCode::try_new`] to validate dynamic strings at runtime. + #[must_use] + pub const fn new(code: &'static str) -> Self { + if !is_valid_literal(code) { + panic!("AppCode literals must be SCREAMING_SNAKE_CASE"); + } + Self::from_static(code) + } - /// Failed to serialize data (encode). + /// Construct an [`AppCode`] from a dynamically provided string. /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Serialization, - - /// Failed to deserialize data (decode). + /// The input must be SCREAMING_SNAKE_CASE. This constructor allocates to + /// own the string, making it suitable for runtime-defined codes. /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Deserialization, - - /// Upstream API returned an error or protocol-level failure. + /// # Errors /// - /// Typically mapped to HTTP **500 Internal Server Error**. - ExternalApi, - - /// Queue processing failure (publish/consume/ack). + /// Returns [`ParseAppCodeError`] when the string is empty or contains + /// characters outside of `A-Z`, `0-9`, and `_`. /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Queue, - - /// Cache subsystem failure (read/write/encoding). + /// # Examples + /// ``` + /// use masterror::AppCode; /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Cache -} + /// let code = AppCode::try_new(String::from("THIRD_PARTY_FAILURE"))?; + /// assert_eq!(code.as_str(), "THIRD_PARTY_FAILURE"); + /// # Ok::<(), masterror::ParseAppCodeError>(()) + /// ``` + pub fn try_new(code: impl Into) -> Result { + let code = code.into(); + validate_code(&code)?; + Ok(Self::from_owned(code)) + } -impl AppCode { /// Get the canonical string form of this code (SCREAMING_SNAKE_CASE). /// - /// This is equivalent to how the code is serialized to JSON. - pub const fn as_str(&self) -> &'static str { - match self { - // 4xx - AppCode::NotFound => "NOT_FOUND", - AppCode::Validation => "VALIDATION", - AppCode::Conflict => "CONFLICT", - AppCode::UserAlreadyExists => "USER_ALREADY_EXISTS", - AppCode::Unauthorized => "UNAUTHORIZED", - AppCode::Forbidden => "FORBIDDEN", - AppCode::NotImplemented => "NOT_IMPLEMENTED", - AppCode::BadRequest => "BAD_REQUEST", - AppCode::RateLimited => "RATE_LIMITED", - AppCode::TelegramAuth => "TELEGRAM_AUTH", - AppCode::InvalidJwt => "INVALID_JWT", - - // 5xx - AppCode::Internal => "INTERNAL", - AppCode::Database => "DATABASE", - AppCode::Service => "SERVICE", - AppCode::Config => "CONFIG", - AppCode::Turnkey => "TURNKEY", - AppCode::Timeout => "TIMEOUT", - AppCode::Network => "NETWORK", - AppCode::DependencyUnavailable => "DEPENDENCY_UNAVAILABLE", - AppCode::Serialization => "SERIALIZATION", - AppCode::Deserialization => "DESERIALIZATION", - AppCode::ExternalApi => "EXTERNAL_API", - AppCode::Queue => "QUEUE", - AppCode::Cache => "CACHE" + /// This matches the JSON serialization. + #[must_use] + pub fn as_str(&self) -> &str { + match &self.repr { + CodeRepr::Static(value) => value, + CodeRepr::Owned(value) => value } } } @@ -211,7 +185,7 @@ impl Display for AppCode { /// /// # Errors /// -/// Returns [`ParseAppCodeError`] when the input does not match any known code. +/// Returns [`ParseAppCodeError`] when the input is not SCREAMING_SNAKE_CASE. /// /// # Examples /// ``` @@ -227,36 +201,11 @@ impl FromStr for AppCode { type Err = ParseAppCodeError; fn from_str(s: &str) -> Result { - match s { - // 4xx - "NOT_FOUND" => Ok(Self::NotFound), - "VALIDATION" => Ok(Self::Validation), - "CONFLICT" => Ok(Self::Conflict), - "USER_ALREADY_EXISTS" => Ok(Self::UserAlreadyExists), - "UNAUTHORIZED" => Ok(Self::Unauthorized), - "FORBIDDEN" => Ok(Self::Forbidden), - "NOT_IMPLEMENTED" => Ok(Self::NotImplemented), - "BAD_REQUEST" => Ok(Self::BadRequest), - "RATE_LIMITED" => Ok(Self::RateLimited), - "TELEGRAM_AUTH" => Ok(Self::TelegramAuth), - "INVALID_JWT" => Ok(Self::InvalidJwt), - - // 5xx - "INTERNAL" => Ok(Self::Internal), - "DATABASE" => Ok(Self::Database), - "SERVICE" => Ok(Self::Service), - "CONFIG" => Ok(Self::Config), - "TURNKEY" => Ok(Self::Turnkey), - "TIMEOUT" => Ok(Self::Timeout), - "NETWORK" => Ok(Self::Network), - "DEPENDENCY_UNAVAILABLE" => Ok(Self::DependencyUnavailable), - "SERIALIZATION" => Ok(Self::Serialization), - "DESERIALIZATION" => Ok(Self::Deserialization), - "EXTERNAL_API" => Ok(Self::ExternalApi), - "QUEUE" => Ok(Self::Queue), - "CACHE" => Ok(Self::Cache), - _ => Err(ParseAppCodeError) + if let Some(code) = match_static(s) { + return Ok(code); } + + Self::try_new(s.to_owned()) } } @@ -297,6 +246,119 @@ impl From for AppCode { } } +impl Serialize for AppCode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AppCode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de> + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = AppCode; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a SCREAMING_SNAKE_CASE code") + } + + fn visit_borrowed_str(self, value: &'de str) -> Result + where + E: serde::de::Error + { + AppCode::from_str(value).map_err(E::custom) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error + { + AppCode::from_str(value).map_err(E::custom) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error + { + AppCode::try_new(value).map_err(E::custom) + } + } + + deserializer.deserialize_str(Visitor) + } +} + +fn validate_code(value: &str) -> Result<(), ParseAppCodeError> { + if !is_valid_literal(value) { + return Err(ParseAppCodeError); + } + + Ok(()) +} + +fn match_static(value: &str) -> Option { + match value { + "NOT_FOUND" => Some(AppCode::NotFound), + "VALIDATION" => Some(AppCode::Validation), + "CONFLICT" => Some(AppCode::Conflict), + "USER_ALREADY_EXISTS" => Some(AppCode::UserAlreadyExists), + "UNAUTHORIZED" => Some(AppCode::Unauthorized), + "FORBIDDEN" => Some(AppCode::Forbidden), + "NOT_IMPLEMENTED" => Some(AppCode::NotImplemented), + "BAD_REQUEST" => Some(AppCode::BadRequest), + "RATE_LIMITED" => Some(AppCode::RateLimited), + "TELEGRAM_AUTH" => Some(AppCode::TelegramAuth), + "INVALID_JWT" => Some(AppCode::InvalidJwt), + "INTERNAL" => Some(AppCode::Internal), + "DATABASE" => Some(AppCode::Database), + "SERVICE" => Some(AppCode::Service), + "CONFIG" => Some(AppCode::Config), + "TURNKEY" => Some(AppCode::Turnkey), + "TIMEOUT" => Some(AppCode::Timeout), + "NETWORK" => Some(AppCode::Network), + "DEPENDENCY_UNAVAILABLE" => Some(AppCode::DependencyUnavailable), + "SERIALIZATION" => Some(AppCode::Serialization), + "DESERIALIZATION" => Some(AppCode::Deserialization), + "EXTERNAL_API" => Some(AppCode::ExternalApi), + "QUEUE" => Some(AppCode::Queue), + "CACHE" => Some(AppCode::Cache), + _ => None + } +} + +const fn is_valid_literal(value: &str) -> bool { + let bytes = value.as_bytes(); + let len = bytes.len(); + if len == 0 { + return false; + } + + if bytes[0] == b'_' || bytes[len - 1] == b'_' { + return false; + } + + let mut index = 0; + while index < len { + let byte = bytes[index]; + if !matches!(byte, b'A'..=b'Z' | b'0'..=b'9' | b'_') { + return false; + } + if byte == b'_' && index + 1 < len && bytes[index + 1] == b'_' { + return false; + } + index += 1; + } + + true +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -316,22 +378,10 @@ mod tests { #[test] fn mapping_from_kind_is_stable() { // Spot checks to guard against accidental remaps. - assert!(matches!( - AppCode::from(AppErrorKind::NotFound), - AppCode::NotFound - )); - assert!(matches!( - AppCode::from(AppErrorKind::Validation), - AppCode::Validation - )); - assert!(matches!( - AppCode::from(AppErrorKind::Internal), - AppCode::Internal - )); - assert!(matches!( - AppCode::from(AppErrorKind::Timeout), - AppCode::Timeout - )); + assert_eq!(AppCode::from(AppErrorKind::NotFound), AppCode::NotFound); + assert_eq!(AppCode::from(AppErrorKind::Validation), AppCode::Validation); + assert_eq!(AppCode::from(AppErrorKind::Internal), AppCode::Internal); + assert_eq!(AppCode::from(AppErrorKind::Timeout), AppCode::Timeout); } #[test] @@ -339,6 +389,20 @@ mod tests { assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST"); } + #[test] + fn new_and_try_new_validate_input() { + let code = AppCode::new("CUSTOM_CODE"); + assert_eq!(code.as_str(), "CUSTOM_CODE"); + assert!(AppCode::try_new(String::from("ANOTHER_CODE")).is_ok()); + assert!(AppCode::try_new(String::from("lower")).is_err()); + } + + #[test] + #[should_panic] + fn new_panics_on_invalid_literal() { + let _ = AppCode::new("not_snake"); + } + #[test] fn from_str_parses_known_codes() { for code in [ @@ -354,8 +418,14 @@ mod tests { } #[test] - fn from_str_rejects_unknown_code() { - let err = AppCode::from_str("NOT_A_REAL_CODE").unwrap_err(); + fn from_str_allows_dynamic_codes() { + let parsed = AppCode::from_str("THIRD_PARTY_FAILURE").expect("parse"); + assert_eq!(parsed.as_str(), "THIRD_PARTY_FAILURE"); + } + + #[test] + fn from_str_rejects_unknown_code_shape() { + let err = AppCode::from_str("NOT-A-REAL-CODE").unwrap_err(); assert_eq!(err, ParseAppCodeError); } } diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index d16f549..33c8695 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -231,7 +231,7 @@ fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, O .iter() .find(|(state, _)| *state == sqlstate.as_str()) { - code_override = Some(*app_code); + code_override = Some(app_code.clone()); } } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index f0058db..a7bb30e 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -62,7 +62,7 @@ impl From for Status { fn status_from_error(error: &Error) -> Status { error.emit_telemetry(); - let mapping = mapping_for_code(error.code); + let mapping = mapping_for_code(&error.code); let grpc_code = Code::from_i32(mapping.grpc().value); let detail = sanitize_detail(error.message.as_ref(), error.kind, error.edit_policy); let mut meta = MetadataMap::new(); diff --git a/src/mapping.rs b/src/mapping.rs index 8b56f3d..0223eb2 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -12,7 +12,7 @@ use crate::{AppCode, AppErrorKind}; /// Stores the stable public [`AppCode`] and semantic [`AppErrorKind`]. The /// HTTP status code can be derived from the kind via /// [`AppErrorKind::http_status`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct HttpMapping { code: AppCode, kind: AppErrorKind @@ -30,8 +30,8 @@ impl HttpMapping { /// Stable machine-readable error code. #[must_use] - pub const fn code(&self) -> AppCode { - self.code + pub fn code(&self) -> &AppCode { + &self.code } /// Semantic application error category. @@ -50,7 +50,7 @@ impl HttpMapping { /// gRPC mapping for a domain error. /// /// Stores the [`AppCode`], [`AppErrorKind`] and a gRPC status code (as `i32`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct GrpcMapping { code: AppCode, kind: AppErrorKind, @@ -70,8 +70,8 @@ impl GrpcMapping { /// Stable machine-readable error code. #[must_use] - pub const fn code(&self) -> AppCode { - self.code + pub fn code(&self) -> &AppCode { + &self.code } /// Semantic application error category. @@ -91,7 +91,7 @@ impl GrpcMapping { /// /// Associates an error with the [`AppCode`], [`AppErrorKind`] and a canonical /// problem `type` URI. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct ProblemMapping { code: AppCode, kind: AppErrorKind, @@ -111,8 +111,8 @@ impl ProblemMapping { /// Stable machine-readable error code. #[must_use] - pub const fn code(&self) -> AppCode { - self.code + pub fn code(&self) -> &AppCode { + &self.code } /// Semantic application error category. diff --git a/src/response/core.rs b/src/response/core.rs index 8240c37..76dabcf 100644 --- a/src/response/core.rs +++ b/src/response/core.rs @@ -27,7 +27,7 @@ pub struct RetryAdvice { pub struct ErrorResponse { /// HTTP status code (e.g. 404, 422, 500). pub status: u16, - /// Stable machine-readable error code (enum). + /// Stable machine-readable error code. pub code: AppCode, /// Human-oriented, non-sensitive message. pub message: String, diff --git a/src/response/mapping.rs b/src/response/mapping.rs index fb4848f..9addbbb 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -14,7 +14,7 @@ impl Display for ErrorResponse { impl From for ErrorResponse { fn from(mut err: AppError) -> Self { let kind = err.kind; - let code = err.code; + let code = err.code.clone(); let retry = err.retry.take(); let www_authenticate = err.www_authenticate.take(); let policy = err.edit_policy; @@ -71,7 +71,7 @@ impl From<&AppError> for ErrorResponse { Self { status, - code: err.code, + code: err.code.clone(), message, details, retry: err.retry, diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index d6eaa03..73ced8f 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -26,7 +26,7 @@ use crate::{ /// ```rust /// use masterror::{AppCode, mapping_for_code}; /// -/// let mapping = mapping_for_code(AppCode::NotFound); +/// let mapping = mapping_for_code(&AppCode::NotFound); /// assert_eq!(mapping.http_status(), 404); /// assert_eq!( /// mapping.problem_type(), @@ -78,7 +78,7 @@ impl CodeMapping { /// ```rust /// use masterror::{AppCode, mapping_for_code}; /// -/// let grpc = mapping_for_code(AppCode::Internal).grpc(); +/// let grpc = mapping_for_code(&AppCode::Internal).grpc(); /// assert_eq!(grpc.name, "INTERNAL"); /// assert_eq!(grpc.value, 13); /// ``` @@ -162,7 +162,7 @@ impl ProblemJson { pub fn from_app_error(mut error: AppError) -> Self { error.emit_telemetry(); - let code = error.code; + let code = error.code.clone(); let kind = error.kind; let message = error.message.take(); let metadata = core::mem::take(&mut error.metadata); @@ -171,7 +171,7 @@ impl ProblemJson { let retry = error.retry.take(); let www_authenticate = error.www_authenticate.take(); - let mapping = mapping_for_code(code); + let mapping = mapping_for_code(&code); let status = kind.http_status(); let title = Cow::Owned(kind.to_string()); let detail = sanitize_detail(message, kind, edit_policy); @@ -208,7 +208,7 @@ impl ProblemJson { /// ``` #[must_use] pub fn from_ref(error: &AppError) -> Self { - let mapping = mapping_for_code(error.code); + let mapping = mapping_for_code(&error.code); let status = error.kind.http_status(); let title = Cow::Owned(error.kind.to_string()); let detail = sanitize_detail_ref(error); @@ -221,7 +221,7 @@ impl ProblemJson { status, detail, details, - code: error.code, + code: error.code.clone(), grpc: Some(mapping.grpc()), metadata, retry_after: error.retry.map(|value| value.after_seconds), @@ -245,24 +245,33 @@ impl ProblemJson { /// ``` #[must_use] pub fn from_error_response(response: ErrorResponse) -> Self { - let mapping = mapping_for_code(response.code); - let detail = if response.message.is_empty() { + let ErrorResponse { + status, + code, + message, + details, + retry, + www_authenticate + } = response; + + let mapping = mapping_for_code(&code); + let detail = if message.is_empty() { None } else { - Some(Cow::Owned(response.message)) + Some(Cow::Owned(message)) }; Self { type_uri: Some(Cow::Borrowed(mapping.problem_type())), title: Cow::Owned(mapping.kind().to_string()), - status: response.status, + status, detail, - details: response.details, - code: response.code, + details, + code, grpc: Some(mapping.grpc()), metadata: None, - retry_after: response.retry.map(|value| value.after_seconds), - www_authenticate: response.www_authenticate + retry_after: retry.map(|value| value.after_seconds), + www_authenticate } } @@ -995,15 +1004,15 @@ const DEFAULT_MAPPING: CodeMapping = CodeMapping { /// ```rust /// use masterror::{AppCode, mapping_for_code}; /// -/// let mapping = mapping_for_code(AppCode::Timeout); +/// let mapping = mapping_for_code(&AppCode::Timeout); /// assert_eq!(mapping.grpc().name, "DEADLINE_EXCEEDED"); /// ``` #[must_use] -pub fn mapping_for_code(code: AppCode) -> CodeMapping { +pub fn mapping_for_code(code: &AppCode) -> CodeMapping { CODE_MAPPINGS .iter() .find_map(|(candidate, mapping)| { - if *candidate == code { + if candidate == code { Some(*mapping) } else { None diff --git a/src/response/tests.rs b/src/response/tests.rs index 5eb8b74..ddd59cc 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -72,6 +72,19 @@ fn details_json_are_attached() { assert_eq!(e.details.unwrap(), payload); } +#[cfg(feature = "serde_json")] +#[test] +fn custom_codes_roundtrip_via_json() { + let custom = AppCode::new("INVALID_JSON"); + let response = ErrorResponse::new(400, custom.clone(), "invalid body").expect("status"); + + let json = serde_json::to_string(&response).expect("serialize"); + let decoded: ErrorResponse = serde_json::from_str(&json).expect("decode"); + + assert_eq!(decoded.code, custom); + assert_eq!(decoded.code.as_str(), "INVALID_JSON"); +} + #[cfg(feature = "serde_json")] #[test] fn with_details_serializes_custom_struct() { diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index c3fb86b..113a10d 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index b331baa..9ada290 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index b2658a1..fd151cc 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 83d517f..5a25e12 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | #[error("missing")] - | ^ +8 | / #[error("missing")] +9 | | Missing + | |___________^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index f929951..bdadf45 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 34abc91..037fac8 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index d579838..1822edf 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default From 42bfae35f1c33df520f73205cfdfcc05504ca0d0 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 06:19:09 +0700 Subject: [PATCH 09/28] Move context AppCode into promoted errors --- CHANGELOG.md | 8 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- src/app_error/context.rs | 48 ++++++++++++------- src/app_error/tests.rs | 15 +++++- .../fail/enum_missing_variant.stderr | 7 ++- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 - .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 ++- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 22 files changed, 82 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b5dad..cf9c07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.1] - 2025-10-17 + +### Fixed +- Updated `Context::into_error` to move dynamic `AppCode` values into the + resulting `AppError`, reworking field redaction plumbing to avoid clones and + preserve custom code ownership. Added a regression test covering pointer + identity for context-promoted errors. + ## [0.24.0] - 2025-10-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5102aca..e4f108c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.0" +version = "0.24.1" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index e28329d..801b6f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.24.0" +version = "0.24.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 855543d..dcfe183 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.0", default-features = false } +masterror = { version = "0.24.1", default-features = false } # or with features: -# masterror = { version = "0.24.0", features = [ +# masterror = { version = "0.24.1", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 5a5fcb3..38e75fd 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -91,7 +91,7 @@ impl Context { #[must_use] pub fn with(mut self, field: Field) -> Self { self.fields.push(field); - self.apply_field_redactions(); + Self::apply_field_redactions(&mut self.fields, &self.field_policies); self } @@ -132,35 +132,45 @@ impl Context { self } - pub(crate) fn into_error(mut self, source: E) -> Error + pub(crate) fn into_error(self, source: E) -> Error where E: CoreError + Send + Sync + 'static { - if let Some(location) = self.caller_location { - self.fields.push(Field::new( + let Context { + mut fields, + field_policies, + edit_policy, + caller_location, + code, + category, + .. + } = self; + + if let Some(location) = caller_location { + fields.push(Field::new( "caller.file", FieldValue::Str(location.file().into()) )); - self.fields.push(Field::new( + fields.push(Field::new( "caller.line", FieldValue::U64(u64::from(location.line())) )); - self.fields.push(Field::new( + fields.push(Field::new( "caller.column", FieldValue::U64(u64::from(location.column())) )); } - let mut error = AppError::new_raw(self.category, None); - error.code = self.code.clone(); - if !self.fields.is_empty() { - self.apply_field_redactions(); - error.metadata.extend(self.fields); + let mut error = AppError::new_raw(category, None); + error.code = code; + if !fields.is_empty() { + Self::apply_field_redactions(&mut fields, &field_policies); + error.metadata.extend(fields); } - for &(name, redaction) in &self.field_policies { + for &(name, redaction) in &field_policies { error = error.redact_field(name, redaction); } - if matches!(self.edit_policy, MessageEditPolicy::Redact) { + if matches!(edit_policy, MessageEditPolicy::Redact) { error.edit_policy = MessageEditPolicy::Redact; } let error = error.with_context(source); @@ -170,13 +180,15 @@ impl Context { } impl Context { - fn apply_field_redactions(&mut self) { - if self.field_policies.is_empty() { + fn apply_field_redactions( + fields: &mut Vec, + policies: &[(&'static str, FieldRedaction)] + ) { + if policies.is_empty() { return; } - for field in &mut self.fields { - if let Some((_, policy)) = self - .field_policies + for field in fields { + if let Some((_, policy)) = policies .iter() .rev() .find(|(name, _)| *name == field.name()) diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 4c54786..6f0b0da 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -39,7 +39,7 @@ static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); static TELEMETRY_GUARD: Mutex<()> = Mutex::new(()); use super::{AppError, FieldRedaction, FieldValue, MessageEditPolicy, field}; -use crate::{AppCode, AppErrorKind, ErrorResponse}; +use crate::{AppCode, AppErrorKind, Context, ErrorResponse, ResultExt}; // --- Helpers ------------------------------------------------------------- @@ -198,6 +198,19 @@ fn retry_and_www_authenticate_are_attached() { assert_eq!(err.www_authenticate.as_deref(), Some("Bearer")); } +#[test] +fn context_moves_dynamic_code_without_cloning() { + let dynamic_code = + AppCode::try_new(String::from("THIRD_PARTY_FAILURE")).expect("valid dynamic code"); + let expected_ptr = dynamic_code.as_str().as_ptr(); + + let err = Result::<(), IoError>::Err(IoError::from(IoErrorKind::Other)) + .ctx(|| Context::new(AppErrorKind::Service).code(dynamic_code)) + .unwrap_err(); + + assert_eq!(err.code.as_str().as_ptr(), expected_ptr); +} + #[test] fn render_message_does_not_allocate_for_borrowed_str() { let err = AppError::new(AppErrorKind::BadRequest, "borrowed"); diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index bbc297c..d000de1 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("without")] -9 | | Without, - | |___________^ +8 | #[error("without")] + | ^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 4f02301..70ccade 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index 021c135..c615e98 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^^^^^^^^^^^^^^^^^^^^^ + | ^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b8f363..5b08225 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index be76742..d416399 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,5 +8,4 @@ error: multiple unused formatting arguments | argument never used | argument never used | - = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index b8bf229..d7acdb1 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index a6a40c2..5869420 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 | 4 | #[error("{value:y}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index 3d332c7..bbe04b4 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 | 4 | #[error("{value:B}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 0bd10fa..2c30e71 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 | 4 | #[error("{value:P}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index 113a10d..c3fb86b 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index 9ada290..b331baa 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index fd151cc..b2658a1 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 5a25e12..83d517f 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("missing")] -9 | | Missing - | |___________^ +8 | #[error("missing")] + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index bdadf45..f929951 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 037fac8..34abc91 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index 1822edf..d579838 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default From 8dc421a4ba9460f817073ad9fe5d4f402604d902 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 06:37:05 +0700 Subject: [PATCH 10/28] Optimize context field redactions --- src/app_error/context.rs | 11 +++++++++-- src/app_error/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 38e75fd..160127e 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -89,9 +89,16 @@ impl Context { /// Attach a metadata [`Field`]. #[must_use] - pub fn with(mut self, field: Field) -> Self { + pub fn with(mut self, mut field: Field) -> Self { + if let Some((_, policy)) = self + .field_policies + .iter() + .rev() + .find(|(name, _)| *name == field.name()) + { + field.set_redaction(*policy); + } self.fields.push(field); - Self::apply_field_redactions(&mut self.fields, &self.field_policies); self } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 6f0b0da..7e74863 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -295,6 +295,21 @@ fn with_details_text_attaches_payload() { assert_eq!(err.details.as_deref(), Some("retry later")); } +#[test] +fn context_with_preserves_default_redaction() { + let err = super::Context::new(AppErrorKind::Service) + .with(field::str("request_id", "abc-123")) + .into_error(DummyError); + + let metadata = err.metadata(); + assert_eq!(metadata.len(), 1); + assert_eq!( + metadata.get("request_id"), + Some(&FieldValue::Str(Cow::Borrowed("abc-123"))) + ); + assert_eq!(metadata.redaction("request_id"), Some(FieldRedaction::None)); +} + #[test] fn context_redact_field_overrides_policy() { let err = super::Context::new(AppErrorKind::Service) @@ -325,6 +340,22 @@ fn context_redact_field_mut_applies_policies() { assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Hash)); } +#[test] +fn context_with_uses_latest_matching_policy() { + let err = super::Context::new(AppErrorKind::Service) + .redact_field("token", FieldRedaction::Hash) + .redact_field("token", FieldRedaction::Redact) + .with(field::str("token", "super-secret")) + .into_error(DummyError); + + let metadata = err.metadata(); + assert_eq!( + metadata.get("token"), + Some(&FieldValue::Str(Cow::Borrowed("super-secret"))) + ); + assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Redact)); +} + #[test] fn app_error_redact_field_updates_metadata() { let err = AppError::internal("boom") From a9f4844b509108d5c586bb8880599461ce66b776 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 07:05:50 +0700 Subject: [PATCH 11/28] Refactor metadata redaction to avoid lowercase allocations --- src/app_error/metadata.rs | 86 +++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs index 125f0de..8590ab7 100644 --- a/src/app_error/metadata.rs +++ b/src/app_error/metadata.rs @@ -198,36 +198,36 @@ impl Field { } fn infer_default_redaction(name: &str) -> FieldRedaction { - let lowered = name.to_ascii_lowercase(); - - if lowered.contains("password") - || lowered.contains("passphrase") - || lowered.contains("secret") - || lowered.contains("authorization") - || lowered.contains("cookie") - || lowered.contains("session") - || lowered.contains("jwt") - || lowered.contains("bearer") - || lowered.contains("otp") - || lowered.contains("pin") + if contains_ascii_case_insensitive(name, "password") + || contains_ascii_case_insensitive(name, "passphrase") + || contains_ascii_case_insensitive(name, "secret") + || contains_ascii_case_insensitive(name, "authorization") + || contains_ascii_case_insensitive(name, "cookie") + || contains_ascii_case_insensitive(name, "session") + || contains_ascii_case_insensitive(name, "jwt") + || contains_ascii_case_insensitive(name, "bearer") + || contains_ascii_case_insensitive(name, "otp") + || contains_ascii_case_insensitive(name, "pin") { return FieldRedaction::Redact; } let mut card_like = false; let mut number_like = false; + let has_token = contains_ascii_case_insensitive(name, "token"); + let has_key = contains_ascii_case_insensitive(name, "key"); - for segment in lowered.split(['.', '_', '-', ':', '/']) { + for segment in name.split(['.', '_', '-', ':', '/']) { if segment.is_empty() { continue; } if segment.eq_ignore_ascii_case("token") || segment.eq_ignore_ascii_case("apikey") - || segment.eq_ignore_ascii_case("api") && lowered.contains("key") - || segment.ends_with("token") + || segment.eq_ignore_ascii_case("api") && has_key + || ends_with_ascii_case_insensitive(segment, "token") || segment.eq_ignore_ascii_case("key") - || segment.eq_ignore_ascii_case("access") && lowered.contains("token") - || segment.eq_ignore_ascii_case("refresh") && lowered.contains("token") + || segment.eq_ignore_ascii_case("access") && has_token + || segment.eq_ignore_ascii_case("refresh") && has_token { return FieldRedaction::Hash; } @@ -256,6 +256,38 @@ fn infer_default_redaction(name: &str) -> FieldRedaction { } } +fn ends_with_ascii_case_insensitive(value: &str, suffix: &str) -> bool { + let value_bytes = value.as_bytes(); + let suffix_bytes = suffix.as_bytes(); + value_bytes.len() >= suffix_bytes.len() + && eq_ascii_case_insensitive_bytes( + &value_bytes[value_bytes.len() - suffix_bytes.len()..], + suffix_bytes + ) +} + +fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + + let haystack_bytes = haystack.as_bytes(); + let needle_bytes = needle.as_bytes(); + + haystack_bytes.len() >= needle_bytes.len() + && haystack_bytes + .windows(needle_bytes.len()) + .any(|window| eq_ascii_case_insensitive_bytes(window, needle_bytes)) +} + +fn eq_ascii_case_insensitive_bytes(left: &[u8], right: &[u8]) -> bool { + left.len() == right.len() + && left + .iter() + .zip(right) + .all(|(&lhs, &rhs)| lhs.eq_ignore_ascii_case(&rhs)) +} + /// Structured metadata attached to [`crate::AppError`]. /// /// Internally backed by a deterministic [`BTreeMap`] keyed by `'static` field @@ -558,6 +590,26 @@ mod tests { assert!(matches!(card.redaction(), FieldRedaction::Last4)); } + #[test] + fn default_redaction_remains_case_insensitive() { + let cases = [ + ("Password", FieldRedaction::Redact), + ("SESSION_ID", FieldRedaction::Redact), + ("X-API-Token", FieldRedaction::Hash), + ("RefreshToken", FieldRedaction::Hash), + ("CARD_NUMBER", FieldRedaction::Last4) + ]; + + for (name, expected) in cases { + let field = field::str(name, Cow::Borrowed("value")); + assert!( + matches!(field.redaction(), policy if policy == expected), + "expected {:?} for {name}", + expected + ); + } + } + #[test] fn field_into_parts_returns_components() { let field = field::u64("elapsed_ms", 30); From f5e4083952d203c70e9746ed4e0afb656504a569 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 07:28:17 +0700 Subject: [PATCH 12/28] Use AppErrorKind labels without extra allocations --- src/app_error/core.rs | 9 ++------ src/app_error/tests.rs | 10 +++++++++ src/kind.rs | 19 ++++++++++------- src/response/mapping.rs | 6 +++--- src/response/problem_json.rs | 14 ++++++++----- src/response/tests.rs | 40 +++++++++++++++++++++++++++++------- 6 files changed, 69 insertions(+), 29 deletions(-) diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 70a6f97..cc743c0 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -1,9 +1,4 @@ -use alloc::{ - borrow::Cow, - boxed::Box, - string::{String, ToString}, - sync::Arc -}; +use alloc::{borrow::Cow, boxed::Box, string::String, sync::Arc}; use core::{ error::Error as CoreError, fmt::{Display, Formatter, Result as FmtResult}, @@ -632,7 +627,7 @@ impl Error { pub fn render_message(&self) -> Cow<'_, str> { match &self.message { Some(msg) => Cow::Borrowed(msg.as_ref()), - None => Cow::Owned(self.kind.to_string()) + None => Cow::Borrowed(self.kind.label()) } } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 7e74863..92ed1e6 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -189,6 +189,16 @@ fn bare_sets_kind_without_message() { ); } +#[test] +fn render_message_returns_borrowed_label_for_bare_errors() { + let err = AppError::bare(AppErrorKind::Forbidden); + let rendered = err.render_message(); + assert!(matches!( + rendered, + Cow::Borrowed(label) if label == AppErrorKind::Forbidden.label() + )); +} + #[test] fn retry_and_www_authenticate_are_attached() { let err = AppError::internal("boom") diff --git a/src/kind.rs b/src/kind.rs index b0589f0..e66cc4b 100644 --- a/src/kind.rs +++ b/src/kind.rs @@ -184,7 +184,17 @@ pub enum AppErrorKind { impl Display for AppErrorKind { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let label = match self { + f.write_str(self.label()) + } +} + +impl CoreError for AppErrorKind {} + +impl AppErrorKind { + /// Human-readable label exposed in HTTP and telemetry payloads. + #[must_use] + pub const fn label(&self) -> &'static str { + match self { Self::NotFound => "Not found", Self::Validation => "Validation error", Self::Conflict => "Conflict", @@ -208,14 +218,9 @@ impl Display for AppErrorKind { Self::ExternalApi => "External API error", Self::Queue => "Queue processing error", Self::Cache => "Cache error" - }; - f.write_str(label) + } } -} -impl CoreError for AppErrorKind {} - -impl AppErrorKind { /// Framework-agnostic mapping to an HTTP status code (`u16`). /// /// This mapping is intentionally conservative and stable. It should **not** diff --git a/src/response/mapping.rs b/src/response/mapping.rs index 9addbbb..c43616d 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -1,4 +1,4 @@ -use alloc::string::ToString; +use alloc::string::String; use core::fmt::{Display, Formatter, Result as FmtResult}; use super::core::ErrorResponse; @@ -22,7 +22,7 @@ impl From for ErrorResponse { let status = kind.http_status(); let message = match err.message.take() { Some(msg) if !matches!(policy, crate::MessageEditPolicy::Redact) => msg.into_owned(), - _ => kind.to_string() + _ => String::from(kind.label()) }; #[cfg(feature = "serde_json")] let details = if matches!(policy, crate::MessageEditPolicy::Redact) { @@ -52,7 +52,7 @@ impl From<&AppError> for ErrorResponse { fn from(err: &AppError) -> Self { let status = err.kind.http_status(); let message = if matches!(err.edit_policy, crate::MessageEditPolicy::Redact) { - err.kind.to_string() + String::from(err.kind.label()) } else { err.render_message().into_owned() }; diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 73ced8f..8c9e1f1 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -173,7 +173,7 @@ impl ProblemJson { let mapping = mapping_for_code(&code); let status = kind.http_status(); - let title = Cow::Owned(kind.to_string()); + let title = Cow::Borrowed(kind.label()); let detail = sanitize_detail(message, kind, edit_policy); let metadata = sanitize_metadata_owned(metadata, edit_policy); @@ -210,7 +210,7 @@ impl ProblemJson { pub fn from_ref(error: &AppError) -> Self { let mapping = mapping_for_code(&error.code); let status = error.kind.http_status(); - let title = Cow::Owned(error.kind.to_string()); + let title = Cow::Borrowed(error.kind.label()); let detail = sanitize_detail_ref(error); let details = sanitize_details_ref(error); let metadata = sanitize_metadata_ref(error.metadata(), error.edit_policy); @@ -263,7 +263,7 @@ impl ProblemJson { Self { type_uri: Some(Cow::Borrowed(mapping.problem_type())), - title: Cow::Owned(mapping.kind().to_string()), + title: Cow::Borrowed(mapping.kind().label()), status, detail, details, @@ -412,7 +412,7 @@ fn sanitize_detail( return None; } - Some(message.unwrap_or_else(|| Cow::Owned(kind.to_string()))) + Some(message.unwrap_or_else(|| Cow::Borrowed(kind.label()))) } fn sanitize_detail_ref(error: &AppError) -> Option> { @@ -420,7 +420,11 @@ fn sanitize_detail_ref(error: &AppError) -> Option> { return None; } - Some(Cow::Owned(error.render_message().into_owned())) + match error.message.as_ref() { + Some(Cow::Borrowed(msg)) => Some(Cow::Borrowed(*msg)), + Some(Cow::Owned(msg)) => Some(Cow::Owned(msg.clone())), + None => Some(Cow::Borrowed(error.kind.label())) + } } #[cfg(feature = "serde_json")] diff --git a/src/response/tests.rs b/src/response/tests.rs index ddd59cc..5213fb7 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use super::ErrorResponse; use crate::{AppCode, AppError, AppErrorKind, ProblemJson}; @@ -255,7 +257,7 @@ fn from_app_error_uses_default_message_when_none() { let e: ErrorResponse = (&app).into(); assert_eq!(e.status, 500); assert!(matches!(e.code, AppCode::Internal)); - assert_eq!(e.message, AppErrorKind::Internal.to_string()); + assert_eq!(e.message, AppErrorKind::Internal.label()); } #[test] @@ -279,7 +281,7 @@ fn from_owned_app_error_defaults_message_when_absent() { assert_eq!(resp.status, 500); assert!(matches!(resp.code, AppCode::Internal)); - assert_eq!(resp.message, AppErrorKind::Internal.to_string()); + assert_eq!(resp.message, AppErrorKind::Internal.label()); } #[test] @@ -289,7 +291,31 @@ fn from_app_error_bare_uses_kind_display_as_message() { assert_eq!(resp.status, 504); assert!(matches!(resp.code, AppCode::Timeout)); - assert_eq!(resp.message, AppErrorKind::Timeout.to_string()); + assert_eq!(resp.message, AppErrorKind::Timeout.label()); +} + +#[test] +fn problem_json_fallbacks_borrow_bare_labels() { + let owned = ProblemJson::from_app_error(AppError::bare(AppErrorKind::Internal)); + assert!(matches!( + owned.title, + Cow::Borrowed(label) if label == AppErrorKind::Internal.label() + )); + assert!(matches!( + owned.detail, + Some(Cow::Borrowed(label)) if label == AppErrorKind::Internal.label() + )); + + let borrowed_error = AppError::bare(AppErrorKind::Timeout); + let borrowed_problem = ProblemJson::from_ref(&borrowed_error); + assert!(matches!( + borrowed_problem.title, + Cow::Borrowed(label) if label == AppErrorKind::Timeout.label() + )); + assert!(matches!( + borrowed_problem.detail, + Some(Cow::Borrowed(label)) if label == AppErrorKind::Timeout.label() + )); } #[test] @@ -297,11 +323,11 @@ fn from_app_error_redacts_message_when_policy_allows() { let app = AppError::internal("sensitive").redactable(); let resp: ErrorResponse = app.into(); - assert_eq!(resp.message, AppErrorKind::Internal.to_string()); + assert_eq!(resp.message, AppErrorKind::Internal.label()); let borrowed = AppError::internal("private").redactable(); let resp_ref: ErrorResponse = (&borrowed).into(); - assert_eq!(resp_ref.message, AppErrorKind::Internal.to_string()); + assert_eq!(resp_ref.message, AppErrorKind::Internal.label()); } #[test] @@ -310,10 +336,10 @@ fn error_response_serialization_hides_redacted_message() { let resp: ErrorResponse = AppError::internal(secret).redactable().into(); let json = serde_json::to_value(&resp).expect("serialize response"); - let fallback = AppErrorKind::Internal.to_string(); + let fallback = AppErrorKind::Internal.label(); assert_eq!( json.get("message").and_then(|value| value.as_str()), - Some(fallback.as_str()) + Some(fallback) ); assert!(!json.to_string().contains(secret)); } From dce3de477d0df34ef32218d67015ec3b21f60558 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 07:57:09 +0700 Subject: [PATCH 13/28] Add benchmarks for error conversion hot paths --- CHANGELOG.md | 9 ++ Cargo.lock | 247 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 7 +- README.md | 27 ++++- README.template.md | 23 ++++ benches/error_paths.rs | 101 +++++++++++++++++ 6 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 benches/error_paths.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9c07f..45e96b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.2] - 2025-10-18 + +### Added +- Introduced a Criterion benchmark (`benches/error_paths.rs`) covering + `Context::into_error` redaction scenarios and `ProblemJson::from_app_error` + conversions to track serialization hot paths. +- Documented the benchmarking workflow in the README and exposed the suite via + `cargo bench --bench error_paths` with the default harness disabled. + ## [0.24.1] - 2025-10-17 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index e4f108c..9a2c043 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + [[package]] name = "anyhow" version = "1.0.100" @@ -405,6 +417,12 @@ dependencies = [ "bytes", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.39" @@ -439,6 +457,58 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + [[package]] name = "combine" version = "4.6.7" @@ -553,6 +623,61 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1107,6 +1232,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1151,6 +1286,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1517,6 +1658,26 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1665,12 +1826,13 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.1" +version = "0.24.2" dependencies = [ "actix-web", "anyhow", "axum", "config", + "criterion", "http 1.3.1", "itoa", "js-sys", @@ -1902,6 +2064,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl-probe" version = "0.1.6" @@ -2071,6 +2239,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -2270,6 +2466,26 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rc-box" version = "1.3.0" @@ -2559,6 +2775,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3297,6 +3522,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -3742,6 +3977,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 801b6f2..ca7a6c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.24.1" +version = "0.24.2" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -134,6 +134,7 @@ tonic = { version = "0.14", optional = true } [dev-dependencies] anyhow = { version = "1", default-features = false, features = ["std"] } +criterion = "0.5" serde_json = "1" tokio = { version = "1", features = [ "macros", @@ -146,6 +147,10 @@ toml = "0.9" tempfile = "3" tracing-subscriber = { version = "0.3", features = ["registry"] } +[[bench]] +name = "error_paths" +harness = false + [build-dependencies] serde = { version = "1", features = ["derive"] } toml = "0.9" diff --git a/README.md b/README.md index dcfe183..8a5ca8c 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.1", default-features = false } +masterror = { version = "0.24.2", default-features = false } # or with features: -# masterror = { version = "0.24.1", features = [ +# masterror = { version = "0.24.2", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", @@ -88,6 +88,29 @@ masterror = { version = "0.24.1", default-features = false } --- +### Benchmarks + +Criterion benchmarks cover the hottest conversion paths so regressions are +visible before shipping. Run them locally with: + +~~~sh +cargo bench --bench error_paths +~~~ + +The suite emits two groups: + +- `context_into_error/*` promotes a dummy source error with representative + metadata (strings, counters, durations, IPs) through `Context::into_error` in + both redacted and non-redacted modes. +- `problem_json_from_app_error/*` consumes the resulting `AppError` values to + build RFC 7807 payloads via `ProblemJson::from_app_error`, showing how message + redaction and field policies impact serialization. + +Adjust Criterion CLI flags (for example `--sample-size 200`) after `--` to trade +throughput for tighter confidence intervals when investigating changes. + +--- +
Quick start diff --git a/README.template.md b/README.template.md index 03bbd4f..cf34c7f 100644 --- a/README.template.md +++ b/README.template.md @@ -83,6 +83,29 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } --- +### Benchmarks + +Criterion benchmarks cover the hottest conversion paths so regressions are +visible before shipping. Run them locally with: + +~~~sh +cargo bench --bench error_paths +~~~ + +The suite emits two groups: + +- `context_into_error/*` promotes a dummy source error with representative + metadata (strings, counters, durations, IPs) through `Context::into_error` in + both redacted and non-redacted modes. +- `problem_json_from_app_error/*` consumes the resulting `AppError` values to + build RFC 7807 payloads via `ProblemJson::from_app_error`, showing how message + redaction and field policies impact serialization. + +Adjust Criterion CLI flags (for example `--sample-size 200`) after `--` to trade +throughput for tighter confidence intervals when investigating changes. + +--- +
Quick start diff --git a/benches/error_paths.rs b/benches/error_paths.rs new file mode 100644 index 0000000..be8e1e4 --- /dev/null +++ b/benches/error_paths.rs @@ -0,0 +1,101 @@ +use core::{ + net::{IpAddr, Ipv4Addr}, + time::Duration +}; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use criterion::{BatchSize, Criterion, black_box, criterion_group, criterion_main}; +use masterror::{AppError, AppErrorKind, Context, FieldRedaction, ProblemJson, ResultExt, field}; + +#[derive(Debug)] +struct DummyError; + +impl Display for DummyError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + f.write_str("dummy") + } +} + +impl std::error::Error for DummyError {} + +fn context_into_error(c: &mut Criterion) { + let mut group = c.benchmark_group("context_into_error"); + + group.bench_function("non_redacted", |b| { + b.iter(|| { + let context = build_context(false); + let err = promote_error(context); + black_box(err) + }); + }); + + group.bench_function("redacted", |b| { + b.iter(|| { + let context = build_context(true); + let err = promote_error(context); + black_box(err) + }); + }); + + group.finish(); +} + +fn problem_json_from_app_error(c: &mut Criterion) { + let mut group = c.benchmark_group("problem_json_from_app_error"); + + group.bench_function("non_redacted", |b| { + b.iter_batched( + || promote_error(build_context(false)), + |error| { + let problem = ProblemJson::from_app_error(error); + black_box(problem); + }, + BatchSize::SmallInput + ); + }); + + group.bench_function("redacted", |b| { + b.iter_batched( + || promote_error(build_context(true)), + |error| { + let problem = ProblemJson::from_app_error(error); + black_box(problem); + }, + BatchSize::SmallInput + ); + }); + + group.finish(); +} + +fn build_context(redacted: bool) -> Context { + let mut context = Context::new(AppErrorKind::Service) + .with(field::str("operation", "sync_job")) + .with(field::u64("attempt", 3)) + .with(field::duration("elapsed", Duration::from_millis(275))) + .with(field::bool("idempotent", true)) + .with(field::ip("peer", IpAddr::from(Ipv4Addr::LOCALHOST))); + + if redacted { + context = context + .with(field::str("token", "secret-token")) + .redact_field("token", FieldRedaction::Hash) + .redact(true) + .track_caller(); + } else { + context = context.with(field::str("token", "secret-token")); + } + + context +} + +fn promote_error(context: Context) -> AppError { + let failing: Result<(), DummyError> = Err(DummyError); + match failing.ctx(|| context) { + Err(err) => err, + Ok(_) => AppError::internal("benchmark expected error") + } +} + +criterion_group!(benches, context_into_error, problem_json_from_app_error); +criterion_main!(benches); From e3cefad7bd204a30ccb9a4aa35fed5eb868e218e Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 09:05:12 +0700 Subject: [PATCH 14/28] Avoid cloning AppCode in response conversions --- src/response/mapping.rs | 4 ++-- src/response/problem_json.rs | 2 +- tests/app_code_reuse.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 tests/app_code_reuse.rs diff --git a/src/response/mapping.rs b/src/response/mapping.rs index c43616d..631e842 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -2,7 +2,7 @@ use alloc::string::String; use core::fmt::{Display, Formatter, Result as FmtResult}; use super::core::ErrorResponse; -use crate::AppError; +use crate::{AppCode, AppError}; impl Display for ErrorResponse { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { @@ -14,7 +14,7 @@ impl Display for ErrorResponse { impl From for ErrorResponse { fn from(mut err: AppError) -> Self { let kind = err.kind; - let code = err.code.clone(); + let code = core::mem::replace(&mut err.code, AppCode::from(kind)); let retry = err.retry.take(); let www_authenticate = err.www_authenticate.take(); let policy = err.edit_policy; diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 8c9e1f1..21e2003 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -162,8 +162,8 @@ impl ProblemJson { pub fn from_app_error(mut error: AppError) -> Self { error.emit_telemetry(); - let code = error.code.clone(); let kind = error.kind; + let code = core::mem::replace(&mut error.code, AppCode::from(kind)); let message = error.message.take(); let metadata = core::mem::take(&mut error.metadata); let edit_policy = error.edit_policy; diff --git a/tests/app_code_reuse.rs b/tests/app_code_reuse.rs new file mode 100644 index 0000000..46e4e4f --- /dev/null +++ b/tests/app_code_reuse.rs @@ -0,0 +1,27 @@ +use masterror::{AppCode, AppError, ErrorResponse, ProblemJson}; + +fn error_with_dynamic_code() -> AppError { + let code = AppCode::try_new("DYNAMIC_REGRESSION_CODE".to_owned()) + .expect("valid SCREAMING_SNAKE_CASE code"); + AppError::internal("boom").with_code(code) +} + +#[test] +fn problem_json_reuses_app_code_allocation() { + let error = error_with_dynamic_code(); + let expected_ptr = error.code.as_str().as_ptr(); + + let problem = ProblemJson::from_app_error(error); + + assert_eq!(problem.code.as_str().as_ptr(), expected_ptr); +} + +#[test] +fn error_response_reuses_app_code_allocation() { + let error = error_with_dynamic_code(); + let expected_ptr = error.code.as_str().as_ptr(); + + let response = ErrorResponse::from(error); + + assert_eq!(response.code.as_str().as_ptr(), expected_ptr); +} From d1ea1f30d6dd2004a8659104e83c6e1a3de7341c Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:17:52 +0700 Subject: [PATCH 15/28] Optimize tonic metadata formatting --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- src/convert/tonic.rs | 31 +++++++++++++++++++++++++------ 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e96b5..19f8d77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.3] - 2025-10-19 + +### Fixed +- Reused stack-allocated format buffers when emitting gRPC metadata for HTTP + status codes and retry hints, and added regression coverage to ensure metadata + strings remain ASCII encoded. + ## [0.24.2] - 2025-10-18 ### Added diff --git a/Cargo.lock b/Cargo.lock index 9a2c043..9e4de6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1826,7 +1826,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.2" +version = "0.24.3" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index ca7a6c8..7965c7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.24.2" +version = "0.24.3" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 8a5ca8c..c9279af 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.2", default-features = false } +masterror = { version = "0.24.3", default-features = false } # or with features: -# masterror = { version = "0.24.2", features = [ +# masterror = { version = "0.24.3", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index a7bb30e..5302514 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -21,6 +21,7 @@ use core::convert::Infallible; use std::borrow::Cow; +use itoa::Buffer as IntegerBuffer; use tonic::{ Code, Status, metadata::{MetadataMap, MetadataValue} @@ -68,11 +69,9 @@ fn status_from_error(error: &Error) -> Status { let mut meta = MetadataMap::new(); insert_ascii(&mut meta, "app-code", error.code.as_str()); - insert_ascii( - &mut meta, - "app-http-status", - mapping.http_status().to_string() - ); + let mut http_status_buffer = IntegerBuffer::new(); + let http_status = http_status_buffer.format(mapping.http_status()); + insert_ascii(&mut meta, "app-http-status", http_status); insert_ascii(&mut meta, "app-problem-type", mapping.problem_type()); if let Some(advice) = error.retry { @@ -104,7 +103,9 @@ fn sanitize_detail( } fn insert_retry(meta: &mut MetadataMap, retry: RetryAdvice) { - insert_ascii(meta, "retry-after", retry.after_seconds.to_string()); + let mut retry_after_buffer = IntegerBuffer::new(); + let retry_after = retry_after_buffer.format(retry.after_seconds); + insert_ascii(meta, "retry-after", retry_after); } fn attach_metadata(meta: &mut MetadataMap, metadata: &Metadata) { @@ -215,4 +216,22 @@ mod tests { Some("2") ); } + + #[test] + fn timeout_status_carries_ascii_metadata() { + let status = Status::from(AppError::timeout("deadline exceeded").with_retry_after_secs(7)); + let metadata = status.metadata(); + assert_eq!( + metadata + .get("app-http-status") + .and_then(|value| value.to_str().ok()), + Some("504") + ); + assert_eq!( + metadata + .get("retry-after") + .and_then(|value| value.to_str().ok()), + Some("7") + ); + } } From 9d874469114f8b16921ed30bd9d111e790947b5e Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:39:51 +0700 Subject: [PATCH 16/28] Refactor tonic metadata formatting --- src/convert/tonic.rs | 82 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 5302514..7193e1c 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -22,6 +22,7 @@ use core::convert::Infallible; use std::borrow::Cow; use itoa::Buffer as IntegerBuffer; +use ryu::Buffer as FloatBuffer; use tonic::{ Code, Status, metadata::{MetadataMap, MetadataValue} @@ -109,6 +110,7 @@ fn insert_retry(meta: &mut MetadataMap, retry: RetryAdvice) { } fn attach_metadata(meta: &mut MetadataMap, metadata: &Metadata) { + let mut formatter = MetadataValueFormatter::new(); for (name, value, redaction) in metadata.iter_with_redaction() { if !matches!(redaction, FieldRedaction::None) { continue; @@ -116,7 +118,7 @@ fn attach_metadata(meta: &mut MetadataMap, metadata: &Metadata) { if !is_safe_metadata_key(name) { continue; } - if let Some(serialized) = metadata_value_to_ascii(value) { + if let Some(serialized) = metadata_value_to_ascii(value, &mut formatter) { insert_ascii(meta, name, serialized); } } @@ -135,19 +137,55 @@ fn insert_ascii(meta: &mut MetadataMap, key: &'static str, value: impl AsRef Option> { +#[derive(Debug)] +enum MetadataAscii<'a> { + Static(&'static str), + Buffer(&'a str), + Owned(String) +} + +impl AsRef for MetadataAscii<'_> { + fn as_ref(&self) -> &str { + match self { + Self::Static(text) | Self::Buffer(text) => text, + Self::Owned(text) => text.as_str() + } + } +} + +#[derive(Default)] +struct MetadataValueFormatter { + integers: IntegerBuffer, + floats: FloatBuffer +} + +impl MetadataValueFormatter { + fn new() -> Self { + Self { + integers: IntegerBuffer::new(), + floats: FloatBuffer::new() + } + } +} + +fn metadata_value_to_ascii<'a>( + value: &FieldValue, + formatter: &'a mut MetadataValueFormatter +) -> Option> { match value { FieldValue::Str(value) => { let text = value.as_ref(); - is_ascii_metadata_value(text).then_some(Cow::Borrowed(text)) + is_ascii_metadata_value(text).then_some(MetadataAscii::Static(text)) + } + FieldValue::I64(value) => Some(MetadataAscii::Buffer(formatter.integers.format(*value))), + FieldValue::U64(value) => Some(MetadataAscii::Buffer(formatter.integers.format(*value))), + FieldValue::F64(value) => Some(MetadataAscii::Buffer(formatter.floats.format(*value))), + FieldValue::Bool(value) => { + Some(MetadataAscii::Static(if *value { "true" } else { "false" })) } - FieldValue::I64(value) => Some(Cow::Owned(value.to_string())), - FieldValue::U64(value) => Some(Cow::Owned(value.to_string())), - FieldValue::F64(value) => Some(Cow::Owned(value.to_string())), - FieldValue::Bool(value) => Some(Cow::Borrowed(if *value { "true" } else { "false" })), - FieldValue::Uuid(value) => Some(Cow::Owned(value.to_string())), - FieldValue::Duration(value) => Some(Cow::Owned(duration_to_string(*value))), - FieldValue::Ip(value) => Some(Cow::Owned(value.to_string())), + FieldValue::Uuid(value) => Some(MetadataAscii::Owned(value.to_string())), + FieldValue::Duration(value) => Some(MetadataAscii::Owned(duration_to_string(*value))), + FieldValue::Ip(value) => Some(MetadataAscii::Owned(value.to_string())), #[cfg(feature = "serde_json")] FieldValue::Json(_) => None } @@ -217,6 +255,30 @@ mod tests { ); } + #[test] + fn numeric_metadata_is_rendered_consistently() { + let err = AppError::service("numbers") + .with_field(field::i64("signed", -42)) + .with_field(field::u64("unsigned", 9000)) + .with_field(field::f64("ratio", 1.25)); + let status = Status::from(err); + let metadata = status.metadata(); + assert_eq!( + metadata.get("signed").and_then(|value| value.to_str().ok()), + Some("-42") + ); + assert_eq!( + metadata + .get("unsigned") + .and_then(|value| value.to_str().ok()), + Some("9000") + ); + assert_eq!( + metadata.get("ratio").and_then(|value| value.to_str().ok()), + Some("1.25") + ); + } + #[test] fn timeout_status_carries_ascii_metadata() { let status = Status::from(AppError::timeout("deadline exceeded").with_retry_after_secs(7)); From 6bede37ef9bd4eeb9ab24d4435a71181f790e821 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 13:01:51 +0700 Subject: [PATCH 17/28] Use stack buffers for Retry-After headers --- src/response/actix_impl.rs | 5 ++++- src/response/axum_impl.rs | 11 +++++++---- src/response/tests.rs | 19 +++++++++++++++---- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/response/actix_impl.rs b/src/response/actix_impl.rs index a3a338a..1776dc1 100644 --- a/src/response/actix_impl.rs +++ b/src/response/actix_impl.rs @@ -12,6 +12,7 @@ use actix_web::{ body::BoxBody, http::header::{CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE} }; +use itoa::Buffer as IntegerBuffer; use super::{ErrorResponse, ProblemJson}; @@ -26,7 +27,9 @@ pub(crate) fn respond_with_problem_json(mut problem: ProblemJson) -> HttpRespons builder.insert_header((CONTENT_TYPE, "application/problem+json")); if let Some(retry) = retry_after { - builder.insert_header((RETRY_AFTER, retry.to_string())); + let mut buffer = IntegerBuffer::new(); + let retry_str = buffer.format(retry); + builder.insert_header((RETRY_AFTER, retry_str)); } if let Some(challenge) = www_authenticate { builder.insert_header((WWW_AUTHENTICATE, challenge)); diff --git a/src/response/axum_impl.rs b/src/response/axum_impl.rs index 69fe5f0..59d2296 100644 --- a/src/response/axum_impl.rs +++ b/src/response/axum_impl.rs @@ -16,6 +16,7 @@ use axum::{ }, response::{IntoResponse, Response} }; +use itoa::Buffer as IntegerBuffer; use super::{ErrorResponse, ProblemJson}; @@ -32,10 +33,12 @@ impl IntoResponse for ProblemJson { HeaderValue::from_static("application/problem+json") ); - if let Some(retry) = retry_after - && let Ok(hv) = HeaderValue::from_str(&retry.to_string()) - { - response.headers_mut().insert(RETRY_AFTER, hv); + if let Some(retry) = retry_after { + let mut buffer = IntegerBuffer::new(); + let retry_str = buffer.format(retry); + if let Ok(hv) = HeaderValue::from_str(retry_str) { + response.headers_mut().insert(RETRY_AFTER, hv); + } } if let Some(challenge) = www_authenticate && let Ok(hv) = HeaderValue::from_str(&challenge) diff --git a/src/response/tests.rs b/src/response/tests.rs index 5213fb7..68c8117 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -389,9 +389,13 @@ fn axum_into_response_sets_headers_and_status() { assert_eq!(resp.status(), 401); let headers = resp.headers(); - assert_eq!(headers.get(RETRY_AFTER).unwrap(), "7"); + let retry_after = headers.get(RETRY_AFTER).expect("Retry-After"); + assert_eq!(retry_after.to_str().expect("ASCII value"), "7"); + let www_authenticate = headers + .get(WWW_AUTHENTICATE) + .expect("WWW-Authenticate header"); assert_eq!( - headers.get(WWW_AUTHENTICATE).unwrap(), + www_authenticate.to_str().expect("ASCII challenge"), r#"Bearer realm="api", error="invalid_token""# ); } @@ -424,8 +428,15 @@ fn actix_responder_sets_headers_and_status() { assert_eq!(http.status(), StatusCode::TOO_MANY_REQUESTS); let headers = http.headers(); - assert_eq!(headers.get(RETRY_AFTER).unwrap(), "42"); - assert_eq!(headers.get(WWW_AUTHENTICATE).unwrap(), "Bearer"); + let retry_after = headers.get(RETRY_AFTER).expect("Retry-After"); + assert_eq!(retry_after.to_str().expect("ASCII value"), "42"); + let www_authenticate = headers + .get(WWW_AUTHENTICATE) + .expect("WWW-Authenticate header"); + assert_eq!( + www_authenticate.to_str().expect("ASCII challenge"), + "Bearer" + ); } #[cfg(feature = "actix")] From a1119ad313670bccc1477070a7ad8b112f15f4e7 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 13:17:44 +0700 Subject: [PATCH 18/28] Validate legacy error response statuses --- src/response/legacy.rs | 36 ++++++++++++++++++++++++++---------- src/response/tests.rs | 11 ++++++++++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/response/legacy.rs b/src/response/legacy.rs index a11489a..217e6fd 100644 --- a/src/response/legacy.rs +++ b/src/response/legacy.rs @@ -1,3 +1,5 @@ +use http::StatusCode; + use super::core::ErrorResponse; use crate::AppCode; @@ -12,15 +14,29 @@ impl ErrorResponse { /// ease migration from versions prior to 0.3.0. #[must_use] pub fn new_legacy(status: u16, message: impl Into) -> Self { - let msg = message.into(); - Self::new(status, AppCode::Internal, msg.clone()).unwrap_or(Self { - status: 500, - code: AppCode::Internal, - message: msg, - details: None, - retry: None, - www_authenticate: None - }) + match StatusCode::from_u16(status) { + Ok(_) => { + let message = message.into(); + Self { + status, + code: AppCode::Internal, + message, + details: None, + retry: None, + www_authenticate: None + } + } + Err(_) => { + let message = message.into(); + Self { + status: 500, + code: AppCode::Internal, + message, + details: None, + retry: None, + www_authenticate: None + } + } + } } } -use alloc::string::String; diff --git a/src/response/tests.rs b/src/response/tests.rs index 68c8117..bd9cbdb 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -365,7 +365,16 @@ fn display_is_concise_and_does_not_leak_details() { #[allow(deprecated)] #[test] fn new_legacy_defaults_to_internal_code() { - let e = ErrorResponse::new_legacy(500, "boom"); + let e = ErrorResponse::new_legacy(404, "boom"); + assert_eq!(e.status, 404); + assert!(matches!(e.code, AppCode::Internal)); + assert_eq!(e.message, "boom"); +} + +#[allow(deprecated)] +#[test] +fn new_legacy_invalid_status_falls_back_to_internal_error() { + let e = ErrorResponse::new_legacy(0, "boom"); assert_eq!(e.status, 500); assert!(matches!(e.code, AppCode::Internal)); assert_eq!(e.message, "boom"); From e4761084940abd47dc23bacb193357d77bec7d14 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 17:00:21 +0700 Subject: [PATCH 19/28] Optimize context metadata redaction handling --- src/app_error/context.rs | 7 ++++--- src/app_error/tests.rs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 160127e..7d20c15 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -173,9 +173,10 @@ impl Context { if !fields.is_empty() { Self::apply_field_redactions(&mut fields, &field_policies); error.metadata.extend(fields); - } - for &(name, redaction) in &field_policies { - error = error.redact_field(name, redaction); + } else if !field_policies.is_empty() { + for &(name, redaction) in &field_policies { + error = error.redact_field(name, redaction); + } } if matches!(edit_policy, MessageEditPolicy::Redact) { error.edit_policy = MessageEditPolicy::Redact; diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 92ed1e6..6938bae 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -335,6 +335,21 @@ fn context_redact_field_overrides_policy() { assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Redact)); } +#[test] +fn context_redact_field_before_insertion_applies_policy() { + let err = super::Context::new(AppErrorKind::Service) + .redact_field("token", FieldRedaction::Hash) + .with(field::str("token", "super-secret")) + .into_error(DummyError); + + let metadata = err.metadata(); + assert_eq!( + metadata.get("token"), + Some(&FieldValue::Str(Cow::Borrowed("super-secret"))) + ); + assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Hash)); +} + #[test] fn context_redact_field_mut_applies_policies() { let mut context = super::Context::new(AppErrorKind::Service); From 70bdeb959413401f79718692c0ea0a48a7cee802 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 17:56:39 +0700 Subject: [PATCH 20/28] Fix OpenAPI schema and telemetry for dependency updates --- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- src/app_error/core.rs | 6 ++++-- src/code/app_code.rs | 23 +++++++++++++++++++++-- src/convert/tonic.rs | 12 ++++++++++-- 7 files changed, 51 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f8d77..c382531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.4] - 2025-10-20 + +### Fixed +- Implemented a manual OpenAPI schema for `AppCode`, restoring `utoipa` + compatibility and documenting the SCREAMING_SNAKE_CASE contract in generated + specs. +- Emitted owned label values when incrementing `error_total` telemetry metrics + so the updated `metrics` crate no longer requires `'static` lifetimes. +- Relaxed gRPC metadata serialization to avoid `'static` lifetime requirements + introduced by recent compiler changes, preserving zero-copy formatting where + possible. + ## [0.24.3] - 2025-10-19 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 9e4de6f..96b7deb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1826,7 +1826,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.3" +version = "0.24.4" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 7965c7c..534ef7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.24.3" +version = "0.24.4" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index c9279af..b73e739 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.3", default-features = false } +masterror = { version = "0.24.4", default-features = false } # or with features: -# masterror = { version = "0.24.3", features = [ +# masterror = { version = "0.24.4", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", diff --git a/src/app_error/core.rs b/src/app_error/core.rs index cc743c0..b329d72 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -311,10 +311,12 @@ impl Error { #[cfg(feature = "metrics")] { + let code_label = self.code.as_str().to_owned(); + let category_label = kind_label(self.kind).to_owned(); metrics::counter!( "error_total", - "code" => self.code.as_str(), - "category" => kind_label(self.kind) + "code" => code_label, + "category" => category_label ) .increment(1); } diff --git a/src/code/app_code.rs b/src/code/app_code.rs index b6236c5..280d545 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -7,7 +7,10 @@ use core::{ use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[cfg(feature = "openapi")] -use utoipa::ToSchema; +use utoipa::{ + PartialSchema, ToSchema, + openapi::schema::{ObjectBuilder, Type} +}; use crate::kind::AppErrorKind; @@ -40,7 +43,6 @@ impl CoreError for ParseAppCodeError {} /// - Do not encode private/internal details in codes. /// - Validate custom codes using [`AppCode::try_new`] before exposing them /// publicly. -#[cfg_attr(feature = "openapi", derive(ToSchema))] #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct AppCode { @@ -295,6 +297,23 @@ impl<'de> Deserialize<'de> for AppCode { } } +#[cfg(feature = "openapi")] +impl PartialSchema for AppCode { + fn schema() -> utoipa::openapi::RefOr { + ObjectBuilder::new() + .schema_type(Type::String) + .description(Some( + "Stable machine-readable error code in SCREAMING_SNAKE_CASE.".to_owned() + )) + .pattern(Some("^[A-Z0-9_]+$".to_owned())) + .build() + .into() + } +} + +#[cfg(feature = "openapi")] +impl ToSchema for AppCode {} + fn validate_code(value: &str) -> Result<(), ParseAppCodeError> { if !is_valid_literal(value) { return Err(ParseAppCodeError); diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 7193e1c..c9cb656 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -147,7 +147,8 @@ enum MetadataAscii<'a> { impl AsRef for MetadataAscii<'_> { fn as_ref(&self) -> &str { match self { - Self::Static(text) | Self::Buffer(text) => text, + Self::Static(text) => text, + Self::Buffer(text) => text, Self::Owned(text) => text.as_str() } } @@ -175,7 +176,14 @@ fn metadata_value_to_ascii<'a>( match value { FieldValue::Str(value) => { let text = value.as_ref(); - is_ascii_metadata_value(text).then_some(MetadataAscii::Static(text)) + if !is_ascii_metadata_value(text) { + return None; + } + + match value { + Cow::Borrowed(borrowed) => Some(MetadataAscii::Static(borrowed)), + Cow::Owned(owned) => Some(MetadataAscii::Owned(owned.clone())) + } } FieldValue::I64(value) => Some(MetadataAscii::Buffer(formatter.integers.format(*value))), FieldValue::U64(value) => Some(MetadataAscii::Buffer(formatter.integers.format(*value))), From 2b168d0d94f1b5160fb37b7dc1aebadffad93611 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sat, 27 Sep 2025 18:02:48 +0700 Subject: [PATCH 21/28] up deps --- Cargo.lock | 40 ++++++++++------------------------------ Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96b7deb..bfe689a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -625,25 +625,22 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "criterion" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" dependencies = [ "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", "itertools", "num-traits", - "once_cell", "oorandom", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -651,9 +648,9 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" dependencies = [ "cast", "itertools", @@ -1286,12 +1283,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -1658,22 +1649,11 @@ dependencies = [ "serde", ] -[[package]] -name = "is-terminal" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -2497,9 +2477,9 @@ dependencies = [ [[package]] name = "redis" -version = "0.32.5" +version = "0.32.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd3650deebc68526b304898b192fa4102a4ef0b9ada24da096559cb60e0eef8" +checksum = "15965fbccb975c38a08a68beca6bdb57da9081cd0859417c5975a160d968c3cb" dependencies = [ "combine", "itoa", @@ -3580,9 +3560,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.3" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 534ef7c..4e39a76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,7 +134,7 @@ tonic = { version = "0.14", optional = true } [dev-dependencies] anyhow = { version = "1", default-features = false, features = ["std"] } -criterion = "0.5" +criterion = "0.7" serde_json = "1" tokio = { version = "1", features = [ "macros", From 3d50db1387112600d70a483e45f36a47c4c7846b Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 18:48:28 +0700 Subject: [PATCH 22/28] Fix benchmark black_box usage --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- benches/error_paths.rs | 7 +++++-- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c382531..bdf5f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.5] - 2025-10-21 + +### Fixed +- Replaced deprecated `criterion::black_box` usage in the error path benchmarks + with `std::hint::black_box` so benches compile cleanly under `-D warnings`. + ## [0.24.4] - 2025-10-20 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index bfe689a..caabbbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1806,7 +1806,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.4" +version = "0.24.5" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4e39a76..9ebc1dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.24.4" +version = "0.24.5" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index b73e739..e5c5538 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.4", default-features = false } +masterror = { version = "0.24.5", default-features = false } # or with features: -# masterror = { version = "0.24.4", features = [ +# masterror = { version = "0.24.5", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", diff --git a/benches/error_paths.rs b/benches/error_paths.rs index be8e1e4..9723f9b 100644 --- a/benches/error_paths.rs +++ b/benches/error_paths.rs @@ -2,9 +2,12 @@ use core::{ net::{IpAddr, Ipv4Addr}, time::Duration }; -use std::fmt::{Display, Formatter, Result as FmtResult}; +use std::{ + fmt::{Display, Formatter, Result as FmtResult}, + hint::black_box +}; -use criterion::{BatchSize, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; use masterror::{AppError, AppErrorKind, Context, FieldRedaction, ProblemJson, ResultExt, field}; #[derive(Debug)] From aa1a22b4dd1df2bf90656b46c3af22b086c2cca6 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 19:32:34 +0700 Subject: [PATCH 23/28] Fix no_std doc build regressions --- CHANGELOG.md | 10 ++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- src/code/app_code.rs | 2 +- src/convert.rs | 3 +++ src/response/details.rs | 3 +++ src/response/legacy.rs | 2 ++ 8 files changed, 23 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf5f4f..605fc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.6] - 2025-10-22 + +### Fixed +- Restored `no_std` builds by importing `alloc::String` for response helpers and + the legacy constructor, keeping textual detail setters available without the + `std` feature. +- Ensured `AppCode::from_str` remains available in `no_std` mode by explicitly + bringing `ToOwned` into scope and gated the `std::io::Error` conversion example + so doctests compile without the standard library. + ## [0.24.5] - 2025-10-21 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index caabbbb..a838efe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1806,7 +1806,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.5" +version = "0.24.6" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 9ebc1dd..3726d53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.24.5" +version = "0.24.6" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index e5c5538..07b5c3a 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.5", default-features = false } +masterror = { version = "0.24.6", default-features = false } # or with features: -# masterror = { version = "0.24.5", features = [ +# masterror = { version = "0.24.6", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", diff --git a/src/code/app_code.rs b/src/code/app_code.rs index 280d545..5971a3f 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,4 +1,4 @@ -use alloc::{boxed::Box, string::String}; +use alloc::{borrow::ToOwned, boxed::Box, string::String}; use core::{ error::Error as CoreError, fmt::{self, Display}, diff --git a/src/convert.rs b/src/convert.rs index 3915860..374b66a 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -44,6 +44,8 @@ //! `std::io::Error` mapping: //! //! ```rust +//! # #[cfg(feature = "std")] +//! # { //! use std::io::{self, ErrorKind}; //! //! use masterror::{AppError, AppErrorKind, AppResult}; @@ -55,6 +57,7 @@ //! //! let err = open().unwrap_err(); //! assert!(matches!(err.kind, AppErrorKind::Internal)); +//! # } //! ``` //! //! `String` mapping (useful for ad-hoc validation without the `validator` diff --git a/src/response/details.rs b/src/response/details.rs index f1a05c4..7a9b0f8 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -1,3 +1,6 @@ +#[cfg(not(feature = "serde_json"))] +use alloc::string::String; + #[cfg(feature = "serde_json")] use serde::Serialize; #[cfg(feature = "serde_json")] diff --git a/src/response/legacy.rs b/src/response/legacy.rs index 217e6fd..ce9e3e7 100644 --- a/src/response/legacy.rs +++ b/src/response/legacy.rs @@ -1,3 +1,5 @@ +use alloc::string::String; + use http::StatusCode; use super::core::ErrorResponse; From fa948d1d8092147635397923976512d8ac39ba86 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 20:18:13 +0700 Subject: [PATCH 24/28] Restore Rust 1.89 MSRV --- CHANGELOG.md | 17 +++++++++++++++++ Cargo.lock | 8 ++++---- Cargo.toml | 10 +++++----- README.md | 8 ++++---- README.ru.md | 4 ++-- masterror-derive/Cargo.toml | 4 ++-- masterror-template/Cargo.toml | 4 ++-- masterror-template/README.md | 4 ++-- src/code/app_code.rs | 2 +- src/convert.rs | 3 +++ src/convert/tonic.rs | 8 ++++---- src/lib.rs | 2 +- src/response/details.rs | 3 +++ src/response/legacy.rs | 2 ++ src/turnkey/classifier.rs | 2 +- tests/readme_sync.rs | 2 +- 16 files changed, 54 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf5f4f..5f08546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.7] - 2025-10-23 + +### Fixed +- Restored the documented MSRV of Rust 1.89 across the workspace so crates + compile on stable 1.89 again, updating metadata, READMEs and regression tests + to match. + +## [0.24.6] - 2025-10-22 + +### Fixed +- Restored `no_std` builds by importing `alloc::String` for response helpers and + the legacy constructor, keeping textual detail setters available without the + `std` feature. +- Ensured `AppCode::from_str` remains available in `no_std` mode by explicitly + bringing `ToOwned` into scope and gated the `std::io::Error` conversion example + so doctests compile without the standard library. + ## [0.24.5] - 2025-10-21 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index caabbbb..1c55e0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1806,7 +1806,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.5" +version = "0.24.7" dependencies = [ "actix-web", "anyhow", @@ -1818,7 +1818,7 @@ dependencies = [ "js-sys", "log", "log-mdc", - "masterror-derive 0.9.0", + "masterror-derive 0.9.1", "masterror-template", "metrics", "redis", @@ -1859,7 +1859,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.9.0" +version = "0.9.1" dependencies = [ "masterror-template", "proc-macro2", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "masterror-template" -version = "0.3.6" +version = "0.3.7" [[package]] name = "matchit" diff --git a/Cargo.toml b/Cargo.toml index 9ebc1dd..63eac07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror" -version = "0.24.5" -rust-version = "1.90" +version = "0.24.7" +rust-version = "1.89" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" @@ -42,7 +42,7 @@ resolver = "3" # Defaults for members (root объявлен строками выше, потому что build.rs парсит его как строки) [workspace.package] edition = "2024" -rust-version = "1.90" +rust-version = "1.89" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" readme = "README.md" @@ -76,8 +76,8 @@ tonic = ["dep:tonic", "std"] openapi = ["dep:utoipa", "std"] [workspace.dependencies] -masterror-derive = { version = "0.9.0" } -masterror-template = { version = "0.3.6" } +masterror-derive = { version = "0.9.1" } +masterror-template = { version = "0.3.7" } [dependencies] masterror-derive = { version = "0.9" } diff --git a/README.md b/README.md index e5c5538..6910cc4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) [![Downloads](https://img.shields.io/crates/d/masterror)](https://crates.io/crates/masterror) -![MSRV](https://img.shields.io/badge/MSRV-1.90-blue) +![MSRV](https://img.shields.io/badge/MSRV-1.89-blue) ![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) [![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) [![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.5", default-features = false } +masterror = { version = "0.24.7", default-features = false } # or with features: -# masterror = { version = "0.24.5", features = [ +# masterror = { version = "0.24.7", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", @@ -445,4 +445,4 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); --- -MSRV: **1.90** · License: **MIT OR Apache-2.0** · No `unsafe` +MSRV: **1.89** · License: **MIT OR Apache-2.0** · No `unsafe` diff --git a/README.ru.md b/README.ru.md index a301b06..8a62664 100644 --- a/README.ru.md +++ b/README.ru.md @@ -5,7 +5,7 @@ [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) [![Downloads](https://img.shields.io/crates/d/masterror)](https://crates.io/crates/masterror) -![MSRV](https://img.shields.io/badge/MSRV-1.90-blue) +![MSRV](https://img.shields.io/badge/MSRV-1.89-blue) ![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) [![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) [![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) @@ -364,4 +364,4 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); --- -MSRV: **1.90** · Лицензия: **MIT OR Apache-2.0** · Без `unsafe` +MSRV: **1.89** · Лицензия: **MIT OR Apache-2.0** · Без `unsafe` diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index b757e0b..127262e 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" -rust-version = "1.90" -version = "0.9.0" +rust-version = "1.89" +version = "0.9.1" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-template/Cargo.toml b/masterror-template/Cargo.toml index 7ceec57..8b5ffeb 100644 --- a/masterror-template/Cargo.toml +++ b/masterror-template/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-template" -version = "0.3.6" -rust-version = "1.90" +version = "0.3.7" +rust-version = "1.89" edition = "2024" repository = "https://github.com/RAprogramm/masterror" readme = "README.md" diff --git a/masterror-template/README.md b/masterror-template/README.md index e1c15ff..e1f7227 100644 --- a/masterror-template/README.md +++ b/masterror-template/README.md @@ -10,10 +10,10 @@ Add the crate alongside `masterror` if you need direct access to the parser: ```toml [dependencies] -masterror-template = { version = "0.3.6" } +masterror-template = { version = "0.3.7" } ``` -`masterror-template` targets Rust 1.90 and builds on stable and nightly toolchains alike. +`masterror-template` targets Rust 1.89 and builds on stable and nightly toolchains alike. ## Parsing templates diff --git a/src/code/app_code.rs b/src/code/app_code.rs index 280d545..5971a3f 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,4 +1,4 @@ -use alloc::{boxed::Box, string::String}; +use alloc::{borrow::ToOwned, boxed::Box, string::String}; use core::{ error::Error as CoreError, fmt::{self, Display}, diff --git a/src/convert.rs b/src/convert.rs index 3915860..374b66a 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -44,6 +44,8 @@ //! `std::io::Error` mapping: //! //! ```rust +//! # #[cfg(feature = "std")] +//! # { //! use std::io::{self, ErrorKind}; //! //! use masterror::{AppError, AppErrorKind, AppResult}; @@ -55,6 +57,7 @@ //! //! let err = open().unwrap_err(); //! assert!(matches!(err.kind, AppErrorKind::Internal)); +//! # } //! ``` //! //! `String` mapping (useful for ad-hoc validation without the `validator` diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index c9cb656..f36e5c9 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -37,10 +37,10 @@ use crate::{ /// Error alias retained for backwards compatibility with 0.20 conversions. /// -/// Since Rust 1.90 the standard library implements [`TryFrom`] for every -/// [`Into`] conversion with [`core::convert::Infallible`] as the error type. -/// Tonic conversions are therefore guaranteed to succeed, and this alias keeps -/// the historic [`StatusConversionError`] name available for downstream APIs. +/// The standard library implements [`TryFrom`] for every [`Into`] conversion +/// with [`core::convert::Infallible`] as the error type, so tonic conversions +/// are guaranteed to succeed. This alias keeps the historic +/// [`StatusConversionError`] name available for downstream APIs. /// /// # Examples /// ```rust,ignore diff --git a/src/lib.rs b/src/lib.rs index 83fbd13..23b2e5e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,7 @@ //! //! # Minimum Supported Rust Version (MSRV) //! -//! MSRV is **1.90**. New minor releases may increase MSRV with a changelog +//! MSRV is **1.89**. New minor releases may increase MSRV with a changelog //! note, but never in a patch release. //! //! # Feature flags diff --git a/src/response/details.rs b/src/response/details.rs index f1a05c4..7a9b0f8 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -1,3 +1,6 @@ +#[cfg(not(feature = "serde_json"))] +use alloc::string::String; + #[cfg(feature = "serde_json")] use serde::Serialize; #[cfg(feature = "serde_json")] diff --git a/src/response/legacy.rs b/src/response/legacy.rs index 217e6fd..ce9e3e7 100644 --- a/src/response/legacy.rs +++ b/src/response/legacy.rs @@ -1,3 +1,5 @@ +use alloc::string::String; + use http::StatusCode; use super::core::ErrorResponse; diff --git a/src/turnkey/classifier.rs b/src/turnkey/classifier.rs index 97e06d2..3cda78c 100644 --- a/src/turnkey/classifier.rs +++ b/src/turnkey/classifier.rs @@ -193,7 +193,7 @@ const fn is_ascii_alphanumeric(byte: u8) -> bool { /// Converts ASCII letters to lowercase and leaves other bytes unchanged. #[inline] const fn ascii_lower(b: u8) -> u8 { - // ASCII-only fold without RangeInclusive to keep const-friendly on MSRV 1.90 + // ASCII-only fold without RangeInclusive to keep const-friendly on MSRV 1.89 if b >= b'A' && b <= b'Z' { b + 32 } else { b } } diff --git a/tests/readme_sync.rs b/tests/readme_sync.rs index 9aae4e0..3117d2a 100644 --- a/tests/readme_sync.rs +++ b/tests/readme_sync.rs @@ -8,7 +8,7 @@ use tempfile::tempdir; const MINIMAL_MANIFEST: &str = r#"[package] name = "demo" version = "1.2.3" -rust-version = "1.90" +rust-version = "1.89" edition = "2024" [features] From ddd9200c7da9e14f2f0367678835f3017422ccae Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sun, 28 Sep 2025 06:51:40 +0700 Subject: [PATCH 25/28] Raise MSRV to Rust 1.90 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 8 ++++---- Cargo.toml | 10 +++++----- README.md | 8 ++++---- README.ru.md | 4 ++-- masterror-derive/Cargo.toml | 4 ++-- masterror-template/Cargo.toml | 4 ++-- masterror-template/README.md | 4 ++-- src/lib.rs | 2 +- src/turnkey/classifier.rs | 2 +- tests/readme_sync.rs | 2 +- 11 files changed, 30 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f08546..1f9c9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.8] - 2025-10-24 + +### Changed +- Raised the documented and enforced MSRV to Rust 1.90 across the workspace to + satisfy dependencies that no longer compile on Rust 1.89. + ## [0.24.7] - 2025-10-23 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 1c55e0e..cbb43d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1806,7 +1806,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.7" +version = "0.24.8" dependencies = [ "actix-web", "anyhow", @@ -1818,7 +1818,7 @@ dependencies = [ "js-sys", "log", "log-mdc", - "masterror-derive 0.9.1", + "masterror-derive 0.9.2", "masterror-template", "metrics", "redis", @@ -1859,7 +1859,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.9.1" +version = "0.9.2" dependencies = [ "masterror-template", "proc-macro2", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "masterror-template" -version = "0.3.7" +version = "0.3.8" [[package]] name = "matchit" diff --git a/Cargo.toml b/Cargo.toml index 63eac07..eb9d659 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror" -version = "0.24.7" -rust-version = "1.89" +version = "0.24.8" +rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" @@ -42,7 +42,7 @@ resolver = "3" # Defaults for members (root объявлен строками выше, потому что build.rs парсит его как строки) [workspace.package] edition = "2024" -rust-version = "1.89" +rust-version = "1.90" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" readme = "README.md" @@ -76,8 +76,8 @@ tonic = ["dep:tonic", "std"] openapi = ["dep:utoipa", "std"] [workspace.dependencies] -masterror-derive = { version = "0.9.1" } -masterror-template = { version = "0.3.7" } +masterror-derive = { version = "0.9.2" } +masterror-template = { version = "0.3.8" } [dependencies] masterror-derive = { version = "0.9" } diff --git a/README.md b/README.md index 6910cc4..93f3380 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) [![Downloads](https://img.shields.io/crates/d/masterror)](https://crates.io/crates/masterror) -![MSRV](https://img.shields.io/badge/MSRV-1.89-blue) +![MSRV](https://img.shields.io/badge/MSRV-1.90-blue) ![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) [![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) [![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.7", default-features = false } +masterror = { version = "0.24.8", default-features = false } # or with features: -# masterror = { version = "0.24.7", features = [ +# masterror = { version = "0.24.8", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", @@ -445,4 +445,4 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); --- -MSRV: **1.89** · License: **MIT OR Apache-2.0** · No `unsafe` +MSRV: **1.90** · License: **MIT OR Apache-2.0** · No `unsafe` diff --git a/README.ru.md b/README.ru.md index 8a62664..a301b06 100644 --- a/README.ru.md +++ b/README.ru.md @@ -5,7 +5,7 @@ [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) [![Downloads](https://img.shields.io/crates/d/masterror)](https://crates.io/crates/masterror) -![MSRV](https://img.shields.io/badge/MSRV-1.89-blue) +![MSRV](https://img.shields.io/badge/MSRV-1.90-blue) ![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) [![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) [![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) @@ -364,4 +364,4 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); --- -MSRV: **1.89** · Лицензия: **MIT OR Apache-2.0** · Без `unsafe` +MSRV: **1.90** · Лицензия: **MIT OR Apache-2.0** · Без `unsafe` diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index 127262e..66b00d8 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" -rust-version = "1.89" -version = "0.9.1" +rust-version = "1.90" +version = "0.9.2" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-template/Cargo.toml b/masterror-template/Cargo.toml index 8b5ffeb..e14b550 100644 --- a/masterror-template/Cargo.toml +++ b/masterror-template/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-template" -version = "0.3.7" -rust-version = "1.89" +version = "0.3.8" +rust-version = "1.90" edition = "2024" repository = "https://github.com/RAprogramm/masterror" readme = "README.md" diff --git a/masterror-template/README.md b/masterror-template/README.md index e1f7227..989f8c8 100644 --- a/masterror-template/README.md +++ b/masterror-template/README.md @@ -10,10 +10,10 @@ Add the crate alongside `masterror` if you need direct access to the parser: ```toml [dependencies] -masterror-template = { version = "0.3.7" } +masterror-template = { version = "0.3.8" } ``` -`masterror-template` targets Rust 1.89 and builds on stable and nightly toolchains alike. +`masterror-template` targets Rust 1.90 and builds on stable and nightly toolchains alike. ## Parsing templates diff --git a/src/lib.rs b/src/lib.rs index 23b2e5e..83fbd13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,7 @@ //! //! # Minimum Supported Rust Version (MSRV) //! -//! MSRV is **1.89**. New minor releases may increase MSRV with a changelog +//! MSRV is **1.90**. New minor releases may increase MSRV with a changelog //! note, but never in a patch release. //! //! # Feature flags diff --git a/src/turnkey/classifier.rs b/src/turnkey/classifier.rs index 3cda78c..97e06d2 100644 --- a/src/turnkey/classifier.rs +++ b/src/turnkey/classifier.rs @@ -193,7 +193,7 @@ const fn is_ascii_alphanumeric(byte: u8) -> bool { /// Converts ASCII letters to lowercase and leaves other bytes unchanged. #[inline] const fn ascii_lower(b: u8) -> u8 { - // ASCII-only fold without RangeInclusive to keep const-friendly on MSRV 1.89 + // ASCII-only fold without RangeInclusive to keep const-friendly on MSRV 1.90 if b >= b'A' && b <= b'Z' { b + 32 } else { b } } diff --git a/tests/readme_sync.rs b/tests/readme_sync.rs index 3117d2a..9aae4e0 100644 --- a/tests/readme_sync.rs +++ b/tests/readme_sync.rs @@ -8,7 +8,7 @@ use tempfile::tempdir; const MINIMAL_MANIFEST: &str = r#"[package] name = "demo" version = "1.2.3" -rust-version = "1.89" +rust-version = "1.90" edition = "2024" [features] From ed7877df43a2db09566f80c73eec517b589a42a1 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 28 Sep 2025 07:13:15 +0700 Subject: [PATCH 26/28] up deps --- .github/workflows/reusable-ci.yml | 1 - Cargo.lock | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index 6bef884..4a3f0a1 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -12,7 +12,6 @@ jobs: runs-on: ubuntu-latest env: CARGO_LOCKED: "true" - CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index cbb43d9..0e83483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2834,9 +2834,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80ece43fc6fbed4eb5392ab50c07334d3e577cbf40997ee896fe7af40bba4245" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -2867,18 +2867,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a576275b607a2c86ea29e410193df32bc680303c82f31e275bbfcafe8b33be5" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e694923b8824cf0e9b382adf0f60d4e05f348f357b38833a3fa5ed7c2ede04" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", From 352a217d490e1525b6d4c60e0f7c2a41dfebb864 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sun, 28 Sep 2025 07:38:34 +0700 Subject: [PATCH 27/28] Normalize AppCode equality for custom codes --- CHANGELOG.md | 11 +++++++++ Cargo.toml | 2 +- src/code/app_code.rs | 39 +++++++++++++++++++------------ src/lib.rs | 4 ++-- src/response/tests.rs | 16 ++++++------- tests/ui/app_error/pass/enum.rs | 2 +- tests/ui/app_error/pass/struct.rs | 2 +- 7 files changed, 48 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9c9c3..2fa3b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.9] - 2025-10-25 + +### Fixed +- Treat compile-time and runtime custom `AppCode` values as equal by comparing + their canonical string representation, restoring successful JSON roundtrips + for `AppCode::new("…")` literals. + +### Changed +- Equality for `AppCode` is now string-based; prefer `==` checks instead of + pattern matching on `AppCode::Variant` constants. + ## [0.24.8] - 2025-10-24 ### Changed diff --git a/Cargo.toml b/Cargo.toml index eb9d659..0cc6195 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.24.8" +version = "0.24.9" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/src/code/app_code.rs b/src/code/app_code.rs index 5971a3f..0d7722c 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,7 +1,11 @@ -use alloc::{borrow::ToOwned, boxed::Box, string::String}; +use alloc::{ + borrow::{Cow, ToOwned}, + string::String +}; use core::{ error::Error as CoreError, fmt::{self, Display}, + hash::{Hash, Hasher}, str::FromStr }; @@ -44,15 +48,9 @@ impl CoreError for ParseAppCodeError {} /// - Validate custom codes using [`AppCode::try_new`] before exposing them /// publicly. #[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone)] pub struct AppCode { - repr: CodeRepr -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -enum CodeRepr { - Static(&'static str), - Owned(Box) + repr: Cow<'static, str> } #[allow(non_upper_case_globals)] @@ -108,13 +106,13 @@ impl AppCode { const fn from_static(code: &'static str) -> Self { Self { - repr: CodeRepr::Static(code) + repr: Cow::Borrowed(code) } } fn from_owned(code: String) -> Self { Self { - repr: CodeRepr::Owned(code.into_boxed_str()) + repr: Cow::Owned(code) } } @@ -169,10 +167,21 @@ impl AppCode { /// This matches the JSON serialization. #[must_use] pub fn as_str(&self) -> &str { - match &self.repr { - CodeRepr::Static(value) => value, - CodeRepr::Owned(value) => value - } + self.repr.as_ref() + } +} + +impl PartialEq for AppCode { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +impl Eq for AppCode {} + +impl Hash for AppCode { + fn hash(&self, state: &mut H) { + self.as_str().hash(state); } } diff --git a/src/lib.rs b/src/lib.rs index 83fbd13..86e8f0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -263,7 +263,7 @@ //! let app_err = AppError::new(AppErrorKind::NotFound, "user_not_found"); //! let resp: ErrorResponse = (&app_err).into(); //! assert_eq!(resp.status, 404); -//! assert!(matches!(resp.code, AppCode::NotFound)); +//! assert_eq!(resp.code, AppCode::NotFound); //! ``` //! //! # Typed control-flow macros @@ -393,7 +393,7 @@ pub use kind::AppErrorKind; /// name: "other" /// } /// .into(); -/// assert!(matches!(code, AppCode::BadRequest)); +/// assert_eq!(code, AppCode::BadRequest); /// ``` pub use masterror_derive::{Error, Masterror}; pub use response::{ diff --git a/src/response/tests.rs b/src/response/tests.rs index bd9cbdb..30ba794 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -9,7 +9,7 @@ use crate::{AppCode, AppError, AppErrorKind, ProblemJson}; fn new_sets_status_code_and_message() { let e = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); assert_eq!(e.status, 404); - assert!(matches!(e.code, AppCode::NotFound)); + assert_eq!(e.code, AppCode::NotFound); assert_eq!(e.message, "missing"); assert!(e.retry.is_none()); assert!(e.www_authenticate.is_none()); @@ -246,7 +246,7 @@ fn from_app_error_preserves_status_and_sets_code() { let app = AppError::new(AppErrorKind::NotFound, "user"); let e: ErrorResponse = (&app).into(); assert_eq!(e.status, 404); - assert!(matches!(e.code, AppCode::NotFound)); + assert_eq!(e.code, AppCode::NotFound); assert_eq!(e.message, "user"); assert!(e.retry.is_none()); } @@ -256,7 +256,7 @@ fn from_app_error_uses_default_message_when_none() { let app = AppError::bare(AppErrorKind::Internal); let e: ErrorResponse = (&app).into(); assert_eq!(e.status, 500); - assert!(matches!(e.code, AppCode::Internal)); + assert_eq!(e.code, AppCode::Internal); assert_eq!(e.message, AppErrorKind::Internal.label()); } @@ -269,7 +269,7 @@ fn from_owned_app_error_moves_message_and_metadata() { let resp: ErrorResponse = err.into(); assert_eq!(resp.status, 401); - assert!(matches!(resp.code, AppCode::Unauthorized)); + assert_eq!(resp.code, AppCode::Unauthorized); assert_eq!(resp.message, "owned message"); assert_eq!(resp.retry.unwrap().after_seconds, 5); assert_eq!(resp.www_authenticate.as_deref(), Some("Bearer")); @@ -280,7 +280,7 @@ fn from_owned_app_error_defaults_message_when_absent() { let resp: ErrorResponse = AppError::bare(AppErrorKind::Internal).into(); assert_eq!(resp.status, 500); - assert!(matches!(resp.code, AppCode::Internal)); + assert_eq!(resp.code, AppCode::Internal); assert_eq!(resp.message, AppErrorKind::Internal.label()); } @@ -290,7 +290,7 @@ fn from_app_error_bare_uses_kind_display_as_message() { let resp: ErrorResponse = app.into(); assert_eq!(resp.status, 504); - assert!(matches!(resp.code, AppCode::Timeout)); + assert_eq!(resp.code, AppCode::Timeout); assert_eq!(resp.message, AppErrorKind::Timeout.label()); } @@ -367,7 +367,7 @@ fn display_is_concise_and_does_not_leak_details() { fn new_legacy_defaults_to_internal_code() { let e = ErrorResponse::new_legacy(404, "boom"); assert_eq!(e.status, 404); - assert!(matches!(e.code, AppCode::Internal)); + assert_eq!(e.code, AppCode::Internal); assert_eq!(e.message, "boom"); } @@ -376,7 +376,7 @@ fn new_legacy_defaults_to_internal_code() { fn new_legacy_invalid_status_falls_back_to_internal_error() { let e = ErrorResponse::new_legacy(0, "boom"); assert_eq!(e.status, 500); - assert!(matches!(e.code, AppCode::Internal)); + assert_eq!(e.code, AppCode::Internal); assert_eq!(e.message, "boom"); } diff --git a/tests/ui/app_error/pass/enum.rs b/tests/ui/app_error/pass/enum.rs index 2b2a22e..0324d8f 100644 --- a/tests/ui/app_error/pass/enum.rs +++ b/tests/ui/app_error/pass/enum.rs @@ -26,5 +26,5 @@ fn main() { assert!(app_backend.message.is_none()); let code: AppCode = ApiError::Backend.into(); - assert!(matches!(code, AppCode::Service)); + assert_eq!(code, AppCode::Service); } diff --git a/tests/ui/app_error/pass/struct.rs b/tests/ui/app_error/pass/struct.rs index 05b64aa..b668a62 100644 --- a/tests/ui/app_error/pass/struct.rs +++ b/tests/ui/app_error/pass/struct.rs @@ -14,5 +14,5 @@ fn main() { assert_eq!(app.message.as_deref(), Some("missing flag: feature")); let code: AppCode = MissingFlag { name: "other" }.into(); - assert!(matches!(code, AppCode::BadRequest)); + assert_eq!(code, AppCode::BadRequest); } From 46e3a0ee356b4dbb997d51deb34af0a3498f5fda Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 28 Sep 2025 07:48:36 +0700 Subject: [PATCH 28/28] up deps --- Cargo.lock | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e83483..ea54a92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1806,7 +1806,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.8" +version = "0.24.9" dependencies = [ "actix-web", "anyhow", diff --git a/README.md b/README.md index 93f3380..16e7f8e 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.8", default-features = false } +masterror = { version = "0.24.9", default-features = false } # or with features: -# masterror = { version = "0.24.8", features = [ +# masterror = { version = "0.24.9", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis",