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/CHANGELOG.md b/CHANGELOG.md index 36fd3cc..2fa3b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,151 @@ 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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..ea54a92 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", ] @@ -230,32 +230,28 @@ dependencies = [ ] [[package]] -name = "arraydeque" -version = "0.5.1" +name = "anes" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] -name = "async-stream" -version = "0.3.6" +name = "anstyle" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] -name = "async-stream-impl" -version = "0.3.6" +name = "anyhow" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "async-trait" @@ -289,47 +285,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", @@ -340,27 +309,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", ] @@ -386,9 +335,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", @@ -396,7 +345,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -468,11 +417,17 @@ 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.38" +version = "1.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ "find-msvc-tools", "shlex", @@ -499,9 +454,61 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link 0.2.0", + "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" @@ -523,9 +530,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", @@ -616,6 +623,58 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "criterion" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +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" @@ -757,12 +816,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]] @@ -927,7 +986,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]] @@ -1141,9 +1200,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" @@ -1170,6 +1229,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" @@ -1580,6 +1649,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1588,9 +1666,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", @@ -1624,9 +1702,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" @@ -1714,33 +1792,38 @@ 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", ] [[package]] name = "masterror" -version = "0.21.2" +version = "0.24.9" dependencies = [ "actix-web", - "axum 0.8.4", + "anyhow", + "axum", "config", + "criterion", "http 1.3.1", + "itoa", "js-sys", "log", "log-mdc", - "masterror-derive", + "masterror-derive 0.9.2", "masterror-template", "metrics", "redis", "reqwest", + "ryu", "serde", "serde-wasm-bindgen", "serde_json", @@ -1764,7 +1847,9 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.9.0" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef013bb3e4e26e81cb15c88cc405833054faa4cc1005bc06778fddbd074d8973" dependencies = [ "masterror-template", "proc-macro2", @@ -1773,14 +1858,18 @@ dependencies = [ ] [[package]] -name = "masterror-template" -version = "0.3.6" +name = "masterror-derive" +version = "0.9.2" +dependencies = [ + "masterror-template", + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +name = "masterror-template" +version = "0.3.8" [[package]] name = "matchit" @@ -1800,9 +1889,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" @@ -1942,9 +2031,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", ] @@ -1955,6 +2044,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" @@ -2124,6 +2219,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" @@ -2185,15 +2308,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" @@ -2332,6 +2446,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" @@ -2343,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", @@ -2387,9 +2521,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", @@ -2399,9 +2533,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", @@ -2453,7 +2587,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower 0.5.2", + "tower", "tower-http", "tower-service", "url", @@ -2559,7 +2693,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -2621,13 +2755,22 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.1", ] [[package]] @@ -2662,9 +2805,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", @@ -2691,9 +2834,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -2724,18 +2867,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -3202,16 +3345,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", @@ -3220,7 +3363,6 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", - "thiserror", "toml", "wasm-bindgen", "wasm-bindgen-futures", @@ -3261,15 +3403,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]] @@ -3360,6 +3502,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" @@ -3408,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", @@ -3481,13 +3633,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", @@ -3499,31 +3650,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", @@ -3537,11 +3668,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]] @@ -3557,7 +3692,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", ] @@ -3822,6 +3957,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" @@ -3863,9 +4008,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", @@ -3876,9 +4021,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", @@ -3890,9 +4035,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", @@ -3903,9 +4048,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", @@ -3913,9 +4058,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", @@ -3926,9 +4071,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", ] @@ -3948,9 +4093,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", @@ -3982,27 +4127,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", @@ -4011,21 +4156,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" @@ -4038,7 +4177,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]] @@ -4047,7 +4186,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]] @@ -4083,16 +4222,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]] @@ -4128,11 +4267,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 00e286b..0cc6195 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.21.2" +version = "0.24.9" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -31,7 +31,7 @@ include = [ "masterror-derive/**", "masterror-template/**", ".cargo/audit.toml", - ".cargo/config.toml" + ".cargo/config.toml", ] [workspace] @@ -49,47 +49,58 @@ 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" } -masterror-template = { version = "0.3.6" } +masterror-derive = { version = "0.9.2" } +masterror-template = { version = "0.3.8" } [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" +itoa = "1" +ryu = "1" # optional integrations axum = { version = "0.8", optional = true, default-features = false, features = [ @@ -118,12 +129,12 @@ 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" -] } -tonic = { version = "0.12", optional = true } +uuid = { version = "1", default-features = false } +tonic = { version = "0.14", optional = true } [dev-dependencies] +anyhow = { version = "1", default-features = false, features = ["std"] } +criterion = "0.7" serde_json = "1" tokio = { version = "1", features = [ "macros", @@ -136,12 +147,17 @@ 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" [package.metadata.masterror.readme] feature_order = [ + "std", "axum", "actix", "openapi", @@ -184,6 +200,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..16e7f8e 100644 --- a/README.md +++ b/README.md @@ -74,19 +74,43 @@ 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.24.9", 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.24.9", 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" # ] } ~~~ --- +### 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 @@ -100,6 +124,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: @@ -418,4 +446,3 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); --- MSRV: **1.90** · License: **MIT OR Apache-2.0** · No `unsafe` - diff --git a/README.template.md b/README.template.md index 8d5482d..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 @@ -96,6 +119,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/benches/error_paths.rs b/benches/error_paths.rs new file mode 100644 index 0000000..9723f9b --- /dev/null +++ b/benches/error_paths.rs @@ -0,0 +1,104 @@ +use core::{ + net::{IpAddr, Ipv4Addr}, + time::Duration +}; +use std::{ + fmt::{Display, Formatter, Result as FmtResult}, + hint::black_box +}; + +use criterion::{BatchSize, Criterion, 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); diff --git a/docs/wiki/error-crate-comparison.md b/docs/wiki/error-crate-comparison.md index 2fb3c37..10394eb 100644 --- a/docs/wiki/error-crate-comparison.md +++ b/docs/wiki/error-crate-comparison.md @@ -122,9 +122,14 @@ 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. -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 d936189..95aec11 100644 --- a/docs/wiki/masterror-application-guide.md +++ b/docs/wiki/masterror-application-guide.md @@ -66,8 +66,16 @@ 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. +`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. + +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 @@ -154,10 +162,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..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 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 +69,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,16 +80,13 @@ 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`, `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))] @@ -109,7 +111,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/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index b757e0b..66b00d8 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" +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 7ceec57..e14b550 100644 --- a/masterror-template/Cargo.toml +++ b/masterror-template/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror-template" -version = "0.3.6" +version = "0.3.8" rust-version = "1.90" edition = "2024" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-template/README.md b/masterror-template/README.md index e1c15ff..989f8c8 100644 --- a/masterror-template/README.md +++ b/masterror-template/README.md @@ -10,7 +10,7 @@ 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.8" } ``` `masterror-template` targets Rust 1.90 and builds on stable and nightly toolchains alike. 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..7d20c15 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}, @@ -88,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(); self } @@ -131,51 +139,64 @@ impl Context { self } - pub(crate) fn into_error(mut self, source: E) -> Error + pub(crate) fn into_error(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( + 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; - if !self.fields.is_empty() { - self.apply_field_redactions(); - error.metadata.extend(self.fields); - } - for &(name, redaction) in &self.field_policies { - error = error.redact_field(name, redaction); + 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); + } else if !field_policies.is_empty() { + 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_source(source); + let error = error.with_context(source); error.emit_telemetry(); error } } 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/core.rs b/src/app_error/core.rs index 3c791b9..b329d72 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -1,3 +1,10 @@ +use alloc::{borrow::Cow, boxed::Box, string::String, 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,23 +14,42 @@ 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 = "serde_json")] +use serde::Serialize; +#[cfg(feature = "serde_json")] +use serde_json::{Value as JsonValue, to_value}; #[cfg(feature = "tracing")] 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; + +#[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 +77,13 @@ pub struct ErrorInner { pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. pub www_authenticate: Option, - pub source: 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, #[cfg(feature = "backtrace")] @@ -184,11 +216,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)) } } @@ -228,6 +260,7 @@ impl Error { edit_policy: MessageEditPolicy::Preserve, retry: None, www_authenticate: None, + details: None, source: None, #[cfg(feature = "backtrace")] backtrace: None, @@ -247,7 +280,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 +291,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() { @@ -278,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); } @@ -414,9 +449,42 @@ 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 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 +505,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,12 +513,98 @@ 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 } + /// 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 { @@ -460,13 +614,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() } @@ -475,7 +629,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/metadata.rs b/src/app_error/metadata.rs index 7735ee1..8590ab7 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 @@ -199,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; } @@ -257,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 @@ -354,8 +385,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 +402,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 +457,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 +471,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(); @@ -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); diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 592d911..6938bae 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}; @@ -12,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}; +use crate::{AppCode, AppErrorKind, Context, ErrorResponse, ResultExt}; // --- Helpers ------------------------------------------------------------- @@ -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"); @@ -139,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") @@ -148,6 +208,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"); @@ -173,6 +246,80 @@ 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() { + 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_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) @@ -188,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); @@ -203,6 +365,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") 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 9afc1f3..0d7722c 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,19 +1,28 @@ -use std::{ - error::Error as StdError, +use alloc::{ + borrow::{Cow, ToOwned}, + string::String +}; +use core::{ + error::Error as CoreError, fmt::{self, Display}, + hash::{Hash, Hasher}, str::FromStr }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[cfg(feature = "openapi")] -use utoipa::ToSchema; +use utoipa::{ + PartialSchema, ToSchema, + openapi::schema::{ObjectBuilder, Type} +}; 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; @@ -23,180 +32,156 @@ impl Display for ParseAppCodeError { } } -impl StdError for ParseAppCodeError {} +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. -#[cfg_attr(feature = "openapi", derive(ToSchema))] +/// - Validate custom codes using [`AppCode::try_new`] before exposing them +/// publicly. #[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, +#[derive(Debug, Clone)] +pub struct AppCode { + repr: Cow<'static, str> +} - // ───────────── 5xx family (server/infra categories) ───────────── - /// Unexpected server-side failure not captured by more specific kinds. - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Internal, +#[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: Cow::Borrowed(code) + } + } - /// Database-related failure (query, connection, migration, etc.). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Database, + fn from_owned(code: String) -> Self { + Self { + repr: Cow::Owned(code) + } + } - /// Generic service-layer failure (business logic or orchestration). + /// Construct an [`AppCode`] from a compile-time string literal. /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Service, - - /// Configuration error (missing/invalid environment or runtime config). + /// # Examples + /// ``` + /// use masterror::AppCode; /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Config, - - /// Failure in the Turnkey subsystem/integration. + /// let code = AppCode::new("INVALID_JSON"); + /// assert_eq!(code.as_str(), "INVALID_JSON"); + /// ``` /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Turnkey, - - /// Operation did not complete within the allotted time. + /// # Panics /// - /// Typically mapped to HTTP **504 Gateway Timeout**. - Timeout, + /// 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) + } - /// Network-level error (DNS, connect, TLS, request build). + /// Construct an [`AppCode`] from a dynamically provided string. /// - /// Typically mapped to HTTP **503 Service Unavailable**. - Network, - - /// External dependency is unavailable or degraded (cache, broker, - /// third-party). + /// 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 **503 Service Unavailable**. - DependencyUnavailable, - - /// Failed to serialize data (encode). + /// # Errors /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Serialization, - - /// Failed to deserialize data (decode). + /// 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**. - Deserialization, - - /// Upstream API returned an error or protocol-level failure. + /// # Examples + /// ``` + /// use masterror::AppCode; /// - /// Typically mapped to HTTP **500 Internal Server Error**. - ExternalApi, + /// 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)) + } - /// Queue processing failure (publish/consume/ack). + /// Get the canonical string form of this code (SCREAMING_SNAKE_CASE). /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Queue, + /// This matches the JSON serialization. + #[must_use] + pub fn as_str(&self) -> &str { + self.repr.as_ref() + } +} - /// Cache subsystem failure (read/write/encoding). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Cache +impl PartialEq for AppCode { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } } -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", +impl Eq for AppCode {} - // 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" - } +impl Hash for AppCode { + fn hash(&self, state: &mut H) { + self.as_str().hash(state); } } @@ -211,7 +196,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 +212,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 +257,136 @@ 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) + } +} + +#[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); + } + + 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 +406,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 +417,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 +446,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.rs b/src/convert.rs index 1dc4966..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` @@ -74,6 +77,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 +153,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/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..f36e5c9 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -21,6 +21,8 @@ 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} @@ -35,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 @@ -62,17 +64,15 @@ 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(); 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,10 +104,13 @@ 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) { + let mut formatter = MetadataValueFormatter::new(); for (name, value, redaction) in metadata.iter_with_redaction() { if !matches!(redaction, FieldRedaction::None) { continue; @@ -115,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); } } @@ -134,19 +137,63 @@ 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) => 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)) + 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(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::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::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 } @@ -215,4 +262,46 @@ mod tests { Some("2") ); } + + #[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)); + 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") + ); + } } diff --git a/src/kind.rs b/src/kind.rs index e025012..e66cc4b 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,63 +135,92 @@ 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 { + 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", + 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" + } + } + /// Framework-agnostic mapping to an HTTP status code (`u16`). /// /// This mapping is intentionally conservative and stable. It should **not** diff --git a/src/lib.rs b/src/lib.rs index dff5e40..86e8f0f 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 @@ -213,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`]. @@ -243,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 @@ -315,16 +335,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; @@ -382,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/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/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/core.rs b/src/response/core.rs index 6b429f4..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, @@ -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..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")] @@ -63,3 +66,5 @@ impl ErrorResponse { Ok(self.with_details_json(details)) } } +#[cfg(feature = "serde_json")] +use alloc::string::ToString; diff --git a/src/response/internal.rs b/src/response/internal.rs index 1cd90bd..6f03b13 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}; @@ -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/legacy.rs b/src/response/legacy.rs index 98c3383..ce9e3e7 100644 --- a/src/response/legacy.rs +++ b/src/response/legacy.rs @@ -1,3 +1,7 @@ +use alloc::string::String; + +use http::StatusCode; + use super::core::ErrorResponse; use crate::AppCode; @@ -12,14 +16,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 + } + } + } } } diff --git a/src/response/mapping.rs b/src/response/mapping.rs index c0d2302..631e842 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -1,7 +1,8 @@ -use std::fmt::{Display, Formatter, Result as FmtResult}; +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 { @@ -13,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 = 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; @@ -21,14 +22,26 @@ 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) { + 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 } @@ -39,16 +52,28 @@ 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() }; + #[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, + code: err.code.clone(), message, - details: None, + details, retry: err.retry, www_authenticate: err.www_authenticate.clone() } 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..21e2003 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -1,6 +1,13 @@ -use std::{borrow::Cow, collections::BTreeMap, fmt::Write, net::IpAddr}; +use alloc::{ + borrow::Cow, + collections::BTreeMap, + string::{String, ToString} +}; +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; @@ -19,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(), @@ -71,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); /// ``` @@ -111,6 +118,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. @@ -147,17 +162,18 @@ impl ProblemJson { pub fn from_app_error(mut error: AppError) -> Self { error.emit_telemetry(); - let code = error.code; 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; + let details = sanitize_details_owned(error.details.take(), edit_policy); 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 title = Cow::Borrowed(kind.label()); let detail = sanitize_detail(message, kind, edit_policy); let metadata = sanitize_metadata_owned(metadata, edit_policy); @@ -166,6 +182,7 @@ impl ProblemJson { title, status, detail, + details, code, grpc: Some(mapping.grpc()), metadata, @@ -191,10 +208,11 @@ 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 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); Self { @@ -202,7 +220,8 @@ impl ProblemJson { title, status, detail, - code: error.code, + details, + code: error.code.clone(), grpc: Some(mapping.grpc()), metadata, retry_after: error.retry.map(|value| value.after_seconds), @@ -226,23 +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, + title: Cow::Borrowed(mapping.kind().label()), + status, detail, - 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 } } @@ -383,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> { @@ -391,7 +420,50 @@ 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")] +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( @@ -475,17 +547,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) => { @@ -496,17 +602,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) { @@ -525,12 +639,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() @@ -540,8 +673,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(); } @@ -549,12 +682,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 } @@ -879,15 +1008,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 @@ -906,10 +1035,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") @@ -1006,6 +1148,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") @@ -1022,6 +1216,87 @@ 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 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"; diff --git a/src/response/tests.rs b/src/response/tests.rs index d7f11b0..30ba794 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}; @@ -7,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()); @@ -72,6 +74,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() { @@ -126,6 +141,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] @@ -133,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()); } @@ -143,8 +256,8 @@ 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.message, AppErrorKind::Internal.to_string()); + assert_eq!(e.code, AppCode::Internal); + assert_eq!(e.message, AppErrorKind::Internal.label()); } #[test] @@ -156,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")); @@ -167,8 +280,8 @@ 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.message, AppErrorKind::Internal.to_string()); + assert_eq!(resp.code, AppCode::Internal); + assert_eq!(resp.message, AppErrorKind::Internal.label()); } #[test] @@ -177,8 +290,32 @@ 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.message, AppErrorKind::Timeout.to_string()); + assert_eq!(resp.code, AppCode::Timeout); + 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] @@ -186,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] @@ -199,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)); } @@ -228,9 +365,18 @@ 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_eq!(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.code, AppCode::Internal); assert_eq!(e.message, "boom"); } @@ -252,9 +398,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""# ); } @@ -287,8 +437,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")] 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/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); +} 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); }