diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d1f7d..39651ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,321 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.21.1] - 2025-10-09 + +### Fixed +- Packed rarely used `AppError` context (source and backtrace slots) inside the + boxed inner payload so the `AppResult` alias no longer triggers Clippy's + `result_large_err` lint under `-D warnings`. + +## [0.21.0] - 2025-10-08 + +### Added +- Introduced typed `ensure!` and `fail!` macros as allocation-free alternatives + to `anyhow::ensure!`/`anyhow::bail!`, complete with documentation and tests. + +### Changed +- Highlighted the new control-flow macros across the English and Russian + READMEs and module documentation so adopters discover them alongside the + derive tooling. +## [0.20.8] - 2025-10-08 + +### Fixed +- Classified Redis `BusyLoadingError` responses as `DependencyUnavailable` and + preserved their retry advice in metadata so downstreams can distinguish cache + warmup from client mistakes when the `redis` feature is enabled. +- Serialized the serde_json syntax error position using the location reported + by `serde_json::Error` to stay aligned with the upstream parser changes. +- Guarded the tracing telemetry test with a process-wide mutex to prevent + spurious race failures when the full feature suite runs the test harness in + parallel. + +## [0.20.7] - 2025-10-07 + +### Fixed +- Replaced the remaining fallible `Status::try_from` conversions in the Tonic + adapter tests with the infallible `Status::from` API so Clippy's + `unnecessary_fallible_conversions` lint passes under `-D warnings`. + +## [0.20.6] - 2025-10-06 + +### Fixed +- Restored compilation on Rust 1.90+ by aliasing the infallible gRPC + conversion error to `core::convert::Infallible` and re-exporting it without + exposing the private `convert::tonic` module. + +## [0.20.5] - 2025-10-05 + +### Changed +- Rewrote the English and Russian READMEs to reflect the matured workspace, feature flags, telemetry flows and transport integrations introduced across the 0.20 releases. +### Fixed +- Promoted the gRPC converter to an infallible `From` implementation + while retaining the `TryFrom` API via the new documented + `StatusConversionError`, satisfying Clippy's infallible conversion lint. +- Collapsed nested metadata guards in the Tonic adapter and reused borrowed + booleans to silence Clippy without regressing runtime behaviour. +- Simplified the `AppResult` alias test to avoid large `Err` variant warnings + from Clippy's `result_large_err` lint. + +## [0.20.4] - 2025-10-04 + +### Added +- Implemented `FromStr` support for `AppCode` together with a lightweight + `ParseAppCodeError` so RFC7807 responses and documentation examples can parse + machine codes without bespoke helpers. + +### Fixed +- Removed the redundant `#![cfg(feature = "axum")]` attribute and tightened + Axum, SQLx and Tonic integration tests to satisfy `-D warnings` builds. +- Allowed attaching JSON details via `ErrorResponse::with_details` without + tripping Clippy's `result_large_err` lint by documenting the intentional + `AppError` return shape. + +## [0.20.3] - 2025-10-03 + +### Fixed +- Restored the Axum transport adapter in builds by wiring the `convert::axum` + module into the crate graph and relaxing the tests to validate responses via + `serde_json::Value` instead of requiring `ProblemJson` deserialization. +- Hardened converter telemetry for Redis, Reqwest, SQLx, Tonic and multipart + integrations by owning metadata strings where necessary and covering + non-exhaustive enums so the crate compiles cleanly on Rust 1.90. +- Reworked `ProblemJson` metadata internals to use `Cow<'static, str>` keys and + values, preserving zero-copy behaviour for borrowed data while allowing owned + fallbacks required by the updated converters. + +## [0.20.2] - 2025-10-02 + +### Fixed +- Restored compatibility with Rust 1.89 by updating gRPC, Redis, SQLx and + serde_json integrations to avoid deprecated APIs, unsafe environment + mutations and Debug requirements that no longer hold. +- Added deterministic backtrace preference overrides for unit tests so + telemetry behavior remains covered without mutating global environment + variables. +- Ensured config error mapping gracefully handles new non-exhaustive variants + by falling back to a generic context that captures the formatted error. + +## [0.20.1] - 2025-10-01 + +### Changed +- Enriched converter metadata across `multipart`, `redis`, `reqwest`, + `serde_json` and `sqlx` integrations to surface HTTP status details, + retry-after hints and structured failure positions while keeping existing + error categories intact. +- Updated the Teloxide mapping to classify `ApiError::InvalidToken` as + `Unauthorized` and hash potentially sensitive network error details before + emitting telemetry. + +### Tests +- Extended integration tests to assert the new metadata fields, retry hints, + and redaction policies covering the updated converters. + +## [0.20.0] - 2025-09-30 + +### Added +- Added a `Context::redact_field_mut` builder method to tweak metadata + redaction policies in place before attaching additional fields. +- Extended response tests to cover JSON serialization of redacted payloads and + hashed metadata along with checks for the opt-in internal formatters. + +### Changed +- Verified `ErrorResponse` and `ProblemJson` serialization respects message and + metadata redaction policies, ensuring secrets stay out of wire payloads while + keeping diagnostic logging intact. + +## [0.19.0] - 2025-09-29 + +### Changed +- Reworked `AppError` storage to keep sources behind shared `Arc` handles and + lazily capture optional `Backtrace` snapshots without allocating when + `RUST_BACKTRACE` disables them. +- Updated the `masterror::Error` derive and `ResultExt` conversions to forward + sources/backtraces automatically under the new storage layout. + +### Tests +- Added regression coverage for chained error sources and conditional + backtrace capture driven by the `RUST_BACKTRACE` environment variable. + +## [0.18.0] - 2025-09-28 + +### Added +- Added the `AppCode::UserAlreadyExists` classification and mapped it to RFC7807 + responses with the appropriate retry hint. + +### Changed +- Switched all integration converters in `src/convert/*` to build structured + `Context` metadata before producing `Error` values, including HTTP status, + operation, endpoint, duration and retry/edit flags. +- Extended integration tests to validate the enriched metadata, retry behavior + and error code/category mappings across the updated converters. + +## [0.17.0] - 2025-09-27 + +### Added +- Per-field redaction metadata via a new [`FieldRedaction`] enum, default + heuristics for common secret keys (passwords, tokens, card numbers) and the + `Context::redact_field` / `AppError::redact_field` helpers. +- `#[masterror(redact(fields(...)))]` support in the derive macro to configure + metadata policies alongside message redaction. +- Opt-in internal formatters for [`ErrorResponse`] and [`ProblemJson`] that are + safe to use in diagnostic logs without additional serialization boilerplate. + +### Changed +- Problem JSON and legacy `ErrorResponse` serialization now hash, mask or drop + metadata according to per-field policies while honoring the global redaction + flag. +- Redaction-aware conversions ensure redactable messages fall back to the error + kind across HTTP and gRPC mappings. + +## [0.16.0] - 2025-09-26 + +### Changed +- Switched the internal `AppError` source storage to `Arc` and added a + shared `with_source_arc` helper so conversions can reuse existing `Arc` + handles without extra allocations. +- Replaced the backtrace slot with an `Option` managed through an + environment-aware lazy capture that respects `RUST_BACKTRACE` and avoids + snapshot allocation when disabled. +- Updated the `masterror::Error` derive and `ResultExt` conversions to forward + sources using the new shared storage while preserving error chains. + +### Tests +- Added regression coverage for the `std::error::Error` chain, `Arc` source + preservation in the derives, and conditional backtrace capture driven by the + `RUST_BACKTRACE` environment variable. + +## [0.15.0] - 2025-09-25 + +### Added +- Introduced a `response::problem_json` module with an RFC7807 `ProblemJson` + payload that serializes metadata, gRPC mappings and retry/authentication + hints while respecting the message redaction policy. +- Added an optional `tonic` feature exposing `TryFrom for tonic::Status` + with sanitized metadata and canonical gRPC code mapping. +- Published a compile-time `CODE_MAPPINGS` table mapping each `AppCode` to + HTTP, gRPC and problem type information for reuse across transports. + +### Changed +- Updated Axum and Actix integrations to emit `application/problem+json` + bodies, attach `Retry-After`/`WWW-Authenticate` headers automatically and + avoid leaking redactable messages or metadata. +- Re-exported `ProblemJson` from the crate root alongside `ErrorResponse` for + direct construction in custom handlers. + +### Tests +- Added unit coverage for the problem+json metadata sanitizer, header + propagation in Axum, and gRPC code mapping under the new `tonic` feature. + + +## [0.14.1] - 2025-09-25 + +### Changed +- Boxed the internal `AppError` payload inside a new `ErrorInner` allocation, + keeping public field access via `Deref` while shrinking the error to a + pointer-sized handle that shares metadata, retry hints, and backtrace state. + +### Removed +- Dropped `clippy::result_large_err` allowances in response helpers and tests + now that `AppError` is pointer-sized and lint-clean without suppressions. + +### Fixed +- Removed the unused `BacktraceSlot::get` helper to restore builds with `-D warnings`. +- Simplified the metrics recorder test harness with dedicated types to satisfy + `clippy::type_complexity` without sacrificing coverage. + +## [0.14.0] - 2025-09-24 + +### Added +- Introduced optional `tracing`, `metrics` and `backtrace` features. When + enabled they emit structured `tracing` events, increment the + `error_total{code,category}` counter and capture lazy [`Backtrace`] snapshots + from a new `AppError::emit_telemetry` hook. + +### Changed +- Reworked the `AppError` core to emit telemetry exactly once, track dirty + mutations and expose a crate-private `new_raw` constructor for contexts that + enrich errors before flushing instrumentation. +- Updated Axum and Actix integrations to rely on the telemetry hook instead of + manually logging errors while preserving backward-compatible APIs. + +### Tests +- Added tracing dispatcher coverage to assert a single telemetry event with MDC + propagated `trace_id` values. +- Installed a deterministic metrics recorder in unit tests to confirm + `error_total` increments once per error. + +## [0.13.1] - 2025-09-23 + +### Fixed +- Documented allowances for `clippy::result_large_err` on APIs that intentionally + expose the rich `AppError` payload, restoring lint-clean builds. + +## [0.13.0] - 2025-09-23 + +### Added +- Introduced `#[derive(Masterror)]` and the `#[masterror(...)]` attribute to + convert domain errors directly into [`masterror::Error`] while capturing + metadata, message redaction policy and optional transport mappings. +- Added transport mapping descriptors in `mapping::{HttpMapping, GrpcMapping, + ProblemMapping}` generated by the new derive for HTTP/gRPC/problem-json + integrations. + +### Changed +- Re-exported the `Masterror` derive from the crate root alongside the existing + `Error` derive. + +### Documentation +- Expanded crate docs and both READMEs with `Masterror` examples, telemetry + guidance and redaction policy notes. + +### Tests +- Added integration tests and trybuild coverage exercising the + `#[masterror(...)]` attribute and generated mapping tables. + +## [0.12.1] - 2025-10-30 + +### Added +- Introduced the `Context` builder for enriching error conversions with + metadata, caller tracking, and redaction hints via `ResultExt::ctx`. +- Implemented the `ResultExt` trait to wrap fallible operations into + `masterror::Error` without extra allocations while merging context fields. + +### Documentation +- Added rustdoc examples showcasing `Context` chaining and the new + `ResultExt` helper. + +### Tests +- Added unit coverage for `ResultExt::ctx`, ensuring happy-path results pass + through and error branches preserve metadata and sources. + +## [0.12.0] - 2025-10-29 + +### Added +- Introduced typed `Metadata` storage with `Field`/`FieldValue` builders and helper functions in `field::*`. +- Captured error sources and backtraces inside the new `app_error::Error` container, exposing `MessageEditPolicy` to control redaction. + +### Changed +- Replaced the legacy `AppError` struct with the richer `Error` model carrying `AppCode`, metadata, retry/auth hints and transport policy. +- Updated response mapping and constructors to preserve machine-readable codes without extra allocations. + +### Documentation +- Refreshed crate docs, README (EN/RU) and examples to highlight metadata helpers and the new error contract. + +### Tests +- Added regression coverage ensuring codes, metadata and sources survive conversions without unnecessary cloning. + +## [0.11.2] - 2025-10-28 + +### Changed +- Surfaced the [`AppErrorKind`] display text as the fallback `ErrorResponse` + message so clients receive semantic descriptions without providing a custom + message. + +### Tests +- Added regression coverage ensuring bare `AppError` kinds map to their + corresponding default message. + ## [0.11.1] - 2025-10-27 ### Documentation diff --git a/Cargo.lock b/Cargo.lock index 907611e..8e6eb42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -223,6 +235,28 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -255,20 +289,47 @@ 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", + "axum-core 0.5.2", "bytes", "futures-util", "http 1.3.1", "http-body", "http-body-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "multer", @@ -279,7 +340,27 @@ dependencies = [ "serde_json", "serde_path_to_error", "sync_wrapper", - "tower", + "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-layer", "tower-service", ] @@ -1070,6 +1151,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap 2.11.4", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1220,9 +1320,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http 1.3.1", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1248,6 +1350,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.17" @@ -1585,6 +1700,12 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "log-mdc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" + [[package]] name = "lru-slab" version = "0.1.2" @@ -1606,20 +1727,24 @@ dependencies = [ [[package]] name = "masterror" -version = "0.11.1" +version = "0.21.1" dependencies = [ "actix-web", - "axum", + "axum 0.8.4", "config", "http 1.3.1", "js-sys", + "log", + "log-mdc", "masterror-derive", "masterror-template", + "metrics", "redis", "reqwest", "serde", "serde-wasm-bindgen", "serde_json", + "sha2", "sqlx", "sqlx-core", "telegram-webapp-sdk", @@ -1627,16 +1752,19 @@ dependencies = [ "tempfile", "tokio", "toml", + "tonic", "tracing", + "tracing-subscriber", "trybuild", "utoipa", + "uuid", "validator", "wasm-bindgen", ] [[package]] name = "masterror-derive" -version = "0.6.6" +version = "0.9.0" dependencies = [ "masterror-template", "proc-macro2", @@ -1648,6 +1776,12 @@ dependencies = [ name = "masterror-template" version = "0.3.6" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -1670,6 +1804,16 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "metrics" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dea7ac8057892855ec285c440160265225438c3c45072613c25a4b26e98ef5" +dependencies = [ + "ahash", + "portable-atomic", +] + [[package]] name = "mime" version = "0.3.17" @@ -1724,6 +1868,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1971,6 +2124,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + [[package]] name = "potential_utf" version = "0.1.3" @@ -2026,6 +2185,15 @@ 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" @@ -2285,7 +2453,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -2673,6 +2841,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3124,6 +3301,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -3230,6 +3416,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -3282,6 +3479,56 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64 0.22.1", + "bytes", + "h2", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "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-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -3310,7 +3557,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -3357,6 +3604,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -3531,6 +3804,12 @@ dependencies = [ "syn", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index beb989f..27f3748 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.11.1" +version = "0.21.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -50,6 +50,9 @@ 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"] @@ -68,20 +71,25 @@ 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"] [workspace.dependencies] -masterror-derive = { version = "0.6.6" } +masterror-derive = { version = "0.9.0" } masterror-template = { version = "0.3.6" } [dependencies] -masterror-derive = { version = "0.6" } +masterror-derive = { version = "0.9" } masterror-template = { workspace = true } -tracing = "0.1" +tracing = { version = "0.1", optional = true } +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 } http = "1" +sha2 = "0.10" # optional integrations axum = { version = "0.8", optional = true, default-features = false, features = [ @@ -110,6 +118,10 @@ 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 } [dev-dependencies] serde_json = "1" @@ -122,6 +134,7 @@ tokio = { version = "1", features = [ trybuild = "1" toml = "0.9" tempfile = "3" +tracing-subscriber = { version = "0.3", features = ["registry"] } [build-dependencies] serde = { version = "1", features = ["derive"] } @@ -133,6 +146,9 @@ feature_order = [ "actix", "openapi", "serde_json", + "tracing", + "metrics", + "backtrace", "sqlx", "sqlx-migrate", "reqwest", @@ -143,6 +159,7 @@ feature_order = [ "multipart", "teloxide", "telegram-webapp-sdk", + "tonic", "frontend", "turnkey", ] @@ -173,6 +190,15 @@ description = "Generate utoipa OpenAPI schema for ErrorResponse" [package.metadata.masterror.readme.features.serde_json] description = "Attach structured JSON details to AppError" +[package.metadata.masterror.readme.features.tracing] +description = "Emit structured tracing events when errors are constructed" + +[package.metadata.masterror.readme.features.metrics] +description = "Increment `error_total{code,category}` counter for each AppError" + +[package.metadata.masterror.readme.features.backtrace] +description = "Capture lazy `Backtrace` snapshots when telemetry is flushed" + [package.metadata.masterror.readme.features.sqlx] description = "Classify sqlx_core::Error variants into AppError kinds" @@ -203,6 +229,9 @@ description = "Convert teloxide_core::RequestError into domain errors" [package.metadata.masterror.readme.features."telegram-webapp-sdk"] description = "Surface Telegram WebApp validation failures" +[package.metadata.masterror.readme.features.tonic] +description = "Convert AppError into tonic::Status with redaction" + [package.metadata.masterror.readme.features.frontend] description = "Log to the browser console and convert to JsValue on WASM" diff --git a/README.md b/README.md index 32b36a4..17c08b6 100644 --- a/README.md +++ b/README.md @@ -14,91 +14,91 @@ > πŸ‡·πŸ‡Ί Π§ΠΈΡ‚Π°ΠΉΡ‚Π΅ README Π½Π° [русском языкС](README.ru.md). -Small, pragmatic error model for API-heavy Rust services with native derives -and typed telemetry. -Core is framework-agnostic; integrations are opt-in via feature flags. -Stable categories, conservative HTTP mapping, no `unsafe`. - -- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse` -- Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain - mappings and structured telemetry -- Optional Axum/Actix integration and browser/WASM console logging -- Optional OpenAPI schema (via `utoipa`) -- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` -- Turnkey domain taxonomy and helpers (`turnkey` feature) - -πŸ‘‰ Explore the new [error-handling wiki](docs/wiki/index.md) for step-by-step -guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. - ---- +`masterror` grew from a handful of helpers into a workspace of composable crates for +building consistent, observable error surfaces across Rust services. The core +crate stays framework-agnostic, while feature flags light up transport adapters, +integrations and telemetry without pulling in heavyweight defaults. No +`unsafe`, MSRV is pinned, and the derive macros keep your domain types in charge +of redaction and metadata. + +### Highlights + +- **Unified taxonomy.** `AppError`, `AppErrorKind` and `AppCode` model domain and + transport concerns with conservative HTTP/gRPC mappings, turnkey retry/auth + hints and RFC7807 output via `ProblemJson`. +- **Native derives.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]` and `#[provide]` wire custom types into `AppError` while + forwarding sources, backtraces, telemetry providers and redaction policy. +- **Typed telemetry.** `Metadata` stores structured key/value context with + per-field redaction controls and builders in `field::*`, so logs stay + structured without manual `String` maps. +- **Transport adapters.** Optional features expose Actix/Axum responders, + `tonic::Status` conversions, WASM/browser logging and OpenAPI schema + generation without contaminating the lean default build. +- **Battle-tested integrations.** Enable focused mappings for `sqlx`, + `reqwest`, `redis`, `validator`, `config`, `tokio`, `teloxide`, `multipart`, + Telegram WebApp SDK and more β€” each translating library errors into the + taxonomy with telemetry attached. +- **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog, + helper builders and tracing instrumentation for teams that want a consistent + baseline out of the box. +- **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions + with your domain errors without allocating or formatting on the happy path. + +### Workspace crates + +| Crate | What it provides | When to depend on it | +| --- | --- | --- | +| [`masterror`](https://crates.io/crates/masterror) | Core error types, metadata builders, transports, integrations and the prelude. | Application crates, services and libraries that want a stable error surface. | +| [`masterror-derive`](masterror-derive/README.md) | Proc-macros backing `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]` and `#[provide]`. | Brought in automatically via `masterror`; depend directly only for macro hacking. | +| [`masterror-template`](masterror-template/README.md) | Shared template parser used by the derive macros for formatter analysis. | Internal dependency; reuse when you need the template parser elsewhere. | + +### Feature flags at a glance + +Pick only what you need; everything is off by default. + +- **Web transports:** `axum`, `actix`, `multipart`, `openapi`, `serde_json`. +- **Telemetry & observability:** `tracing`, `metrics`, `backtrace`. +- **Async & IO integrations:** `tokio`, `reqwest`, `sqlx`, `sqlx-migrate`, + `redis`, `validator`, `config`. +- **Messaging & bots:** `teloxide`, `telegram-webapp-sdk`. +- **Front-end tooling:** `frontend` for WASM/browser console logging. +- **gRPC:** `tonic` to emit `tonic::Status` responses. +- **Batteries included:** `turnkey` to adopt the pre-built taxonomy and helpers. + +The build script keeps the full feature snippet below in sync with +`Cargo.toml`. ### TL;DR ~~~toml [dependencies] -masterror = { version = "0.11.1", default-features = false } +masterror = { version = "0.21.1", default-features = false } # or with features: -# masterror = { version = "0.11.1", features = [ +# masterror = { version = "0.21.1", features = [ # "axum", "actix", "openapi", "serde_json", -# "sqlx", "sqlx-migrate", "reqwest", "redis", -# "validator", "config", "tokio", "multipart", -# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" +# "tracing", "metrics", "backtrace", "sqlx", +# "sqlx-migrate", "reqwest", "redis", "validator", +# "config", "tokio", "multipart", "teloxide", +# "telegram-webapp-sdk", "tonic", "frontend", "turnkey" # ] } ~~~ -*Since v0.5.0: derive custom errors via `#[derive(Error)]` (`use masterror::Error;`) and inspect browser logging failures with `BrowserConsoleError::context()`.* -*Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* -*Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* - --- -
- Why this crate? - -- **Stable taxonomy.** Small set of `AppErrorKind` categories mapping conservatively to HTTP. -- **Framework-agnostic.** No assumptions, no `unsafe`, MSRV pinned. -- **Opt-in integrations.** Zero default features; you enable what you need. -- **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`. -- **One log at boundary.** Log once with `tracing`. -- **Less boilerplate.** Built-in conversions, compact prelude, and the - native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` - support. -- **Consistent workspace.** Same error surface across crates. - -
- -
- Installation - -~~~toml -[dependencies] -# lean core -masterror = { version = "0.11.1", default-features = false } - -# with Axum/Actix + JSON + integrations -# masterror = { version = "0.11.1", features = [ -# "axum", "actix", "openapi", "serde_json", -# "sqlx", "sqlx-migrate", "reqwest", "redis", -# "validator", "config", "tokio", "multipart", -# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" -# ] } -~~~ - -**MSRV:** 1.90 -**No unsafe:** forbidden by crate. - -
-
Quick start Create an error: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ With prelude: @@ -117,7 +117,36 @@ fn do_work(flag: bool) -> AppResult<()> {
- Derive custom errors + Fail fast without sacrificing typing + +`ensure!` and `fail!` provide typed alternatives to the formatting-heavy +`anyhow::ensure!`/`anyhow::bail!` helpers. They evaluate the error expression +only when the guard trips, so success paths stay allocation-free. + +~~~rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn guard(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::bad_request("flag must be set")); + Ok(()) +} + +fn bail() -> AppResult<()> { + masterror::fail!(AppError::unauthorized("token expired")); +} + +assert!(guard(true).is_ok()); +assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); +assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); +~~~ + +
+ +
+ Derive domain errors and map them to transports + +`masterror` ships native derives so your domain types stay expressive while the +crate handles conversions, telemetry and redaction for you. ~~~rust use std::io; @@ -151,7 +180,7 @@ let wrapped = WrappedDomainError::from(err); assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ -- `use masterror::Error;` brings the crate's derive macro into scope. +- `use masterror::Error;` brings the derive macro into scope. - `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are valid. - `#[error(transparent)]` enforces single-field wrappers that forward @@ -174,103 +203,85 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. -#### Display shorthand projections - -`#[error("...")]` supports the same shorthand syntax as `thiserror` for -referencing fields with `.field` or `.0`. The derive now understands chained -segments, so projections like `.limits.lo`, `.0.data` or -`.suggestion.as_ref().map_or_else(...)` keep compiling unchanged. Raw -identifiers and tuple indices are preserved, ensuring keywords such as -`r#type` and tuple fields continue to work even when you call methods on the -projected value. - -~~~rust -use masterror::Error; - -#[derive(Debug)] -struct Limits { - lo: i32, - hi: i32, -} - -#[derive(Debug, Error)] -#[error( - "range {lo}-{hi} suggestion {suggestion}", - lo = .limits.lo, - hi = .limits.hi, - suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) -)] -struct RangeError { - limits: Limits, - suggestion: Option, -} - -#[derive(Debug)] -struct Payload { - data: &'static str, -} - -#[derive(Debug, Error)] -enum UiError { - #[error("tuple data {data}", data = .0.data)] - Tuple(Payload), - #[error( - "named suggestion {value}", - value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) - )] - Named { suggestion: Option }, -} -~~~ +
-#### AppError conversions +
+ Attach telemetry, redaction policy and conversions -Annotating structs or enum variants with `#[app_error(...)]` captures the -metadata required to convert the domain error into `AppError` (and optionally -`AppCode`). Every variant in an enum must provide the mapping when any variant -requests it. +`#[derive(Masterror)]` wires a domain error into [`masterror::Error`], adds +metadata, redaction policy and optional transport mappings. The accompanying +`#[masterror(...)]` attribute mirrors the `#[app_error]` syntax while staying +explicit about telemetry and redaction. ~~~rust -use masterror::{AppCode, AppError, AppErrorKind, Error}; +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; -#[derive(Debug, Error)] -#[error("missing flag: {name}")] -#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message, fields("user_id" = hash)), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] struct MissingFlag { - name: &'static str, + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option } -let app: AppError = MissingFlag { name: "feature" }.into(); -assert!(matches!(app.kind, AppErrorKind::BadRequest)); -assert_eq!(app.message.as_deref(), Some("missing flag: feature")); +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); -let code: AppCode = MissingFlag { name: "feature" }.into(); -assert!(matches!(code, AppCode::BadRequest)); +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); ~~~ -For enums, each variant specifies the mapping while the derive generates a -single `From` implementation that matches every variant: +- `code` / `category` pick the public [`AppCode`] and internal + [`AppErrorKind`]. +- `message` forwards the formatted [`Display`] output as the safe public + message. Omit it to keep the message private. +- `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport + boundary, `fields("name" = hash, "card" = last4)` overrides metadata + policies (`hash`, `last4`, `redact`, `none`). +- `telemetry(...)` accepts expressions that evaluate to + `Option`. Each populated field is inserted into the + resulting [`Metadata`]; use `telemetry()` when no fields are attached. +- `map.grpc` / `map.problem` capture optional gRPC status codes (as `i32`) and + RFC 7807 `type` URIs. The derive emits tables such as + `MyError::HTTP_MAPPING`, `MyError::GRPC_MAPPING` and + `MyError::PROBLEM_MAPPING` (or slice variants for enums) for downstream + integrations. + +All familiar field-level attributes (`#[from]`, `#[source]`, `#[backtrace]`) +are still honoured. Sources and backtraces are automatically attached to the +generated [`masterror::Error`]. -~~~rust -#[derive(Debug, Error)] -enum ApiError { - #[error("missing resource {id}")] - #[app_error( - kind = AppErrorKind::NotFound, - code = AppCode::NotFound, - message - )] - Missing { id: u64 }, - #[error("backend unavailable")] - #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] - Backend, -} - -let missing = ApiError::Missing { id: 7 }; -let as_app: AppError = missing.into(); -assert_eq!(as_app.message.as_deref(), Some("missing resource 7")); -~~~ +
-#### Structured telemetry providers and AppError mappings +
+ Structured telemetry providers and AppError mappings `#[provide(...)]` exposes typed context through `std::error::Request`, while `#[app_error(...)]` records how your domain error translates into `AppError` @@ -371,351 +382,38 @@ assert!(matches!(app.kind, AppErrorKind::Service)); Compared to `thiserror`, you retain the familiar deriving surface while gaining structured telemetry (`#[provide]`) and first-class conversions into -`AppError`/`AppCode` without writing manual `From` implementations. - -#### Formatter traits - -Placeholders default to `Display` (`{value}`) but can opt into richer -formatters via the same specifiers supported by `thiserror` v2. -`TemplateFormatter::is_alternate()` tracks the `#` flag, while -`TemplateFormatterKind` exposes the underlying `core::fmt` trait so derived -code can branch on the requested renderer without manual pattern matching. -Unsupported formatters surface a compile error that mirrors `thiserror`'s -diagnostics. - -| Specifier | `core::fmt` trait | Example output | Notes | -|------------------|----------------------------|------------------------|-------| -| _default_ | `core::fmt::Display` | `value` | User-facing strings; `#` has no effect. | -| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / multi-line | Mirrors `Debug`; `#` pretty-prints structs. | -| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | Hexadecimal; `#` prepends `0x`. | -| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Uppercase hex; `#` prepends `0x`. | -| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Raw pointers; `#` is accepted for compatibility. | -| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Binary; `#` prepends `0b`. | -| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Octal; `#` prepends `0o`. | -| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Scientific notation; `#` forces the decimal point. | -| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Uppercase scientific; `#` forces the decimal point. | - -- `TemplateFormatterKind::supports_alternate()` reports whether the `#` flag is - meaningful for the requested trait (pointer accepts it even though the output - matches the non-alternate form). -- `TemplateFormatterKind::specifier()` returns the canonical format specifier - character when one exists, enabling custom derives to re-render placeholders - in their original style. -- `TemplateFormatter::from_kind(kind, alternate)` reconstructs a formatter from - the lightweight `TemplateFormatterKind`, making it easy to toggle the - alternate flag in generated code. - -~~~rust -use core::ptr; - -use masterror::Error; - -#[derive(Debug, Error)] -#[error( - "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ - oct={mask:o}, lower={ratio:e}, upper={ratio:E}" -)] -struct FormattedError { - id: u32, - payload: String, - ptr: *const u8, - mask: u8, - ratio: f32, -} - -let err = FormattedError { - id: 0x2a, - payload: "hello".into(), - ptr: ptr::null(), - mask: 0b1010_0001, - ratio: 0.15625, -}; - -let rendered = err.to_string(); -assert!(rendered.contains("debug=\"hello\"")); -assert!(rendered.contains("hex=0x2a")); -assert!(rendered.contains("ptr=0x0")); -assert!(rendered.contains("bin=0b10100001")); -assert!(rendered.contains("oct=241")); -assert!(rendered.contains("lower=1.5625e-1")); -assert!(rendered.contains("upper=1.5625E-1")); -~~~ - -~~~rust -use masterror::error::template::{ - ErrorTemplate, TemplateFormatter, TemplateFormatterKind -}; - -let template = ErrorTemplate::parse("{code:#x} β†’ {payload:?}").expect("parse"); -let mut placeholders = template.placeholders(); - -let code = placeholders.next().expect("code placeholder"); -let code_formatter = code.formatter(); -assert!(matches!( - code_formatter, - TemplateFormatter::LowerHex { alternate: true } -)); -let code_kind = code_formatter.kind(); -assert_eq!(code_kind, TemplateFormatterKind::LowerHex); -assert!(code_formatter.is_alternate()); -assert_eq!(code_kind.specifier(), Some('x')); -assert!(code_kind.supports_alternate()); -let lowered = TemplateFormatter::from_kind(code_kind, false); -assert!(matches!( - lowered, - TemplateFormatter::LowerHex { alternate: false } -)); - -let payload = placeholders.next().expect("payload placeholder"); -let payload_formatter = payload.formatter(); -assert_eq!( - payload_formatter, - &TemplateFormatter::Debug { alternate: false } -); -let payload_kind = payload_formatter.kind(); -assert_eq!(payload_kind, TemplateFormatterKind::Debug); -assert_eq!(payload_kind.specifier(), Some('?')); -assert!(payload_kind.supports_alternate()); -let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); -assert!(matches!( - pretty_debug, - TemplateFormatter::Debug { alternate: true } -)); -assert!(pretty_debug.is_alternate()); -~~~ - -Display-only format specs (alignment, precision, fill β€” including `#` as a fill -character) are preserved so you can forward them to `write!` without rebuilding -the fragment: - -~~~rust -use masterror::error::template::ErrorTemplate; - -let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); -let display = aligned.placeholders().next().expect("display placeholder"); -assert_eq!(display.formatter().display_spec(), Some(">8")); -assert_eq!( - display - .formatter() - .format_fragment() - .as_deref(), - Some(">8") -); - -let hashed = ErrorTemplate::parse("{value:#>4}").expect("parse"); -let hash_placeholder = hashed - .placeholders() - .next() - .expect("hash-fill display placeholder"); -assert_eq!(hash_placeholder.formatter().display_spec(), Some("#>4")); -assert_eq!( - hash_placeholder - .formatter() - .format_fragment() - .as_deref(), - Some("#>4") -); -~~~ - -> **Compatibility with `thiserror` v2:** the derive understands the extended -> formatter set introduced in `thiserror` 2.x and reports identical diagnostics -> for unsupported specifiers, so migrating existing derives is drop-in. - -```rust -use masterror::error::template::{ErrorTemplate, TemplateIdentifier}; - -let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); -let display = template.display_with(|placeholder, f| match placeholder.identifier() { - TemplateIdentifier::Named("code") => write!(f, "{}", 404), - TemplateIdentifier::Named("message") => f.write_str("Not Found"), - _ => Ok(()), -}); - -assert_eq!(display.to_string(), "404: Not Found"); -``` +`AppError`/`AppCode` without manual glue.
- Error response payload + Problem JSON payloads and retry/authentication hints ~~~rust -use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse}; +use masterror::{AppError, AppErrorKind, ProblemJson}; use std::time::Duration; -let app_err = AppError::new(AppErrorKind::Unauthorized, "Token expired"); -let resp: ErrorResponse = (&app_err).into() - .with_retry_after_duration(Duration::from_secs(30)) - .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#); - -assert_eq!(resp.status, 401); -~~~ - -
- -
- Web framework integrations - -
- Axum - -~~~rust -// features = ["axum", "serde_json"] -... - assert!(payload.is_object()); - - #[cfg(target_arch = "wasm32")] - { - if let Err(console_err) = err.log_to_browser_console() { - eprintln!( - "failed to log to browser console: {:?}", - console_err.context() - ); - } - } - - Ok(()) -} -~~~ - -- On non-WASM targets `log_to_browser_console` returns - `BrowserConsoleError::UnsupportedTarget`. -- `BrowserConsoleError::context()` exposes optional browser diagnostics for - logging/telemetry when console logging fails. - -
- -
- -
- Feature flags - -- `axum` β€” IntoResponse integration with structured JSON bodies -- `actix` β€” Actix Web ResponseError and Responder implementations -- `openapi` β€” Generate utoipa OpenAPI schema for ErrorResponse -- `serde_json` β€” Attach structured JSON details to AppError -- `sqlx` β€” Classify sqlx_core::Error variants into AppError kinds -- `sqlx-migrate` β€” Map sqlx::migrate::MigrateError into AppError (Database) -- `reqwest` β€” Classify reqwest::Error as timeout/network/external API -- `redis` β€” Map redis::RedisError into cache-aware AppError -- `validator` β€” Convert validator::ValidationErrors into validation failures -- `config` β€” Propagate config::ConfigError as configuration issues -- `tokio` β€” Classify tokio::time::error::Elapsed as timeout -- `multipart` β€” Handle axum multipart extraction errors -- `teloxide` β€” Convert teloxide_core::RequestError into domain errors -- `telegram-webapp-sdk` β€” Surface Telegram WebApp validation failures -- `frontend` β€” Log to the browser console and convert to JsValue on WASM -- `turnkey` β€” Ship Turnkey-specific error taxonomy and conversions - -
- -
- Conversions - -- `std::io::Error` β†’ Internal -- `String` β†’ BadRequest -- `sqlx::Error` β†’ NotFound/Database -- `redis::RedisError` β†’ Cache -- `reqwest::Error` β†’ Timeout/Network/ExternalApi -- `axum::extract::multipart::MultipartError` β†’ BadRequest -- `validator::ValidationErrors` β†’ Validation -- `config::ConfigError` β†’ Config -- `tokio::time::error::Elapsed` β†’ Timeout -- `teloxide_core::RequestError` β†’ RateLimited/Network/ExternalApi/Deserialization/Internal -- `telegram_webapp_sdk::utils::validate_init_data::ValidationError` β†’ TelegramAuth - -
- -
- Typical setups - -Minimal core: - -~~~toml -masterror = { version = "0.11.1", default-features = false } -~~~ - -API (Axum + JSON + deps): - -~~~toml -masterror = { version = "0.11.1", features = [ - "axum", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -API (Actix + JSON + deps): - -~~~toml -masterror = { version = "0.11.1", features = [ - "actix", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -
- -
- Turnkey +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_duration(Duration::from_secs(30)) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) +); -~~~rust -// features = ["turnkey"] -use masterror::turnkey::{classify_turnkey_error, TurnkeyError, TurnkeyErrorKind}; -use masterror::{AppError, AppErrorKind}; - -// Classify a raw SDK/provider error -let kind = classify_turnkey_error("429 Too Many Requests"); -assert!(matches!(kind, TurnkeyErrorKind::RateLimited)); - -// Wrap into AppError -let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "throttled upstream"); -let app: AppError = e.into(); -assert_eq!(app.kind, AppErrorKind::RateLimited); +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~
-
- Migration 0.2 β†’ 0.3 +### Further resources -- Use `ErrorResponse::new(status, AppCode::..., "msg")` instead of legacy -- New helpers: `.with_retry_after_secs`, `.with_retry_after_duration`, `.with_www_authenticate` -- `ErrorResponse::new_legacy` is temporary shim +- Explore the [error-handling wiki](docs/wiki/index.md) for step-by-step guides, + comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. +- Browse the [crate documentation on docs.rs](https://docs.rs/masterror) for API + details, feature-specific guides and transport tables. +- Check [`CHANGELOG.md`](CHANGELOG.md) for release highlights and migration notes. -
- -
- Versioning & MSRV - -Semantic versioning. Breaking API/wire contract β†’ major bump. -MSRV = 1.90 (may raise in minor, never in patch). - -
- -
- Release checklist - -1. `cargo +nightly fmt --` -1. `cargo clippy -- -D warnings` -1. `cargo test --all` -1. `cargo build` (regenerates README.md from the template) -1. `cargo doc --no-deps` -1. `cargo package --locked` - -
- -
- Non-goals - -- Not a general-purpose error aggregator like `anyhow` -- Not a replacement for your domain errors - -
- -
- License - -Apache-2.0 OR MIT, at your option. - -
+--- +MSRV: **1.90** Β· License: **MIT OR Apache-2.0** Β· No `unsafe` diff --git a/README.ru.md b/README.ru.md index b846592..a301b06 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,6 +1,6 @@ # masterror Β· ΠšΠ°Ρ€ΠΊΠ°Ρ-нСзависимыС Ρ‚ΠΈΠΏΡ‹ ошибок ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΉ -> Π­Ρ‚ΠΎΡ‚ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ β€” русская вСрсия основной Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ. ΠΠ½Π³Π»ΠΈΠΉΡΠΊΡƒΡŽ Π²Π΅Ρ€ΡΠΈΡŽ см. Π² [README.md](README.md). +> Π­Ρ‚Π° страница β€” русская вСрсия основной Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ. Английский ΠΎΡ€ΠΈΠ³ΠΈΠ½Π°Π» см. Π² [README.md](README.md). [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) @@ -11,53 +11,87 @@ [![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) [![Cargo Deny](https://img.shields.io/github/actions/workflow/status/RAprogramm/masterror/ci.yml?branch=main&label=Cargo%20Deny)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) -НСбольшая прагматичная модСль ошибок для Rust-сСрвисов с Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½Π½Ρ‹ΠΌ API ΠΈ -встроСнными Π΄Π΅Ρ€ΠΈΠ²Π°ΠΌΠΈ. -Основной ΠΊΡ€Π΅ΠΉΡ‚ Π½Π΅ зависит ΠΎΡ‚ Π²Π΅Π±-Ρ„Ρ€Π΅ΠΉΠΌΠ²ΠΎΡ€ΠΊΠΎΠ², Π° Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΡ Π²ΠΊΠ»ΡŽΡ‡Π°ΡŽΡ‚ΡΡ Ρ‡Π΅Ρ€Π΅Π· -Ρ„ΠΈΡ‡ΠΈ. Ваксономия ошибок ΡΡ‚Π°Π±ΠΈΠ»ΡŒΠ½Π°, соотвСтствиС HTTP-ΠΊΠΎΠ΄Π°ΠΌ консСрвативно, -`unsafe` Π·Π°ΠΏΡ€Π΅Ρ‰Ρ‘Π½. - -## ΠžΡΠ½ΠΎΠ²Π½Ρ‹Π΅ возмоТности - -- Π‘Π°Π·ΠΎΠ²Ρ‹Π΅ Ρ‚ΠΈΠΏΡ‹: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`. -- Π”Π΅Ρ€ΠΈΠ²Ρ‹ `#[derive(Error)]`, `#[app_error]`, `#[provide]` для Ρ‚ΠΈΠΏΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π½ΠΎΠ³ΠΎ - тСлСмСтричСского контСкста ΠΈ прямых конвСрсий Π΄ΠΎΠΌΠ΅Π½Π½Ρ‹Ρ… ошибок. -- АдаптСры для Axum ΠΈ Actix плюс Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ Π² Π±Ρ€Π°ΡƒΠ·Π΅Ρ€/`JsValue` для WASM (ΠΏΠΎ - Ρ„ΠΈΡ‡Π°ΠΌ). -- ГСнСрация схСм OpenAPI Ρ‡Π΅Ρ€Π΅Π· `utoipa`. -- ΠšΠΎΠ½Π²Π΅Ρ€ΡΠΈΠΈ ΠΈΠ· распространённых Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊ (`sqlx`, `reqwest`, `redis`, - `validator`, `config`, `tokio` ΠΈ Π΄Ρ€.). -- Π“ΠΎΡ‚ΠΎΠ²Ρ‹ΠΉ ΠΏΡ€Π΅Π»ΡŽΠ΄ΠΈΡ-ΠΌΠΎΠ΄ΡƒΠ»ΡŒ ΠΈ Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΠ΅ `turnkey` с собствСнной таксономиСй - ошибок. - -## Установка - -Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ Π·Π°Π²ΠΈΡΠΈΠΌΠΎΡΡ‚ΡŒ Π² `Cargo.toml`: +`masterror` вырос ΠΈΠ· Π½Π°Π±ΠΎΡ€Π° Π²ΡΠΏΠΎΠΌΠΎΠ³Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΉ Π² ΠΏΠΎΠ»Π½ΠΎΡ†Π΅Π½Π½Ρ‹ΠΉ workspace с +ΠΌΠΎΠ΄ΡƒΠ»ΡŒΠ½Ρ‹ΠΌΠΈ ΠΊΡ€Π΅ΠΉΡ‚Π°ΠΌΠΈ для построСния Π½Π°Π±Π»ΡŽΠ΄Π°Π΅ΠΌΡ‹Ρ… ΠΈ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… ошибок Π² +Rust-сСрвисах. Π‘Π°Π·ΠΎΠ²Ρ‹ΠΉ ΠΊΡ€Π΅ΠΉΡ‚ остаётся нСзависимым ΠΎΡ‚ Π²Π΅Π±-Ρ„Ρ€Π΅ΠΉΠΌΠ²ΠΎΡ€ΠΊΠΎΠ², Π° Ρ„ΠΈΡ‡ΠΈ +Π²ΠΊΠ»ΡŽΡ‡Π°ΡŽΡ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½ΡƒΠΆΠ½Ρ‹Π΅ Π°Π΄Π°ΠΏΡ‚Π΅Ρ€Ρ‹, ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ ΠΈ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΡŽ. `unsafe` Π·Π°ΠΏΡ€Π΅Ρ‰Ρ‘Π½, +MSRV зафиксирован, Π° Ρ€ΠΎΠ΄Π½Ρ‹Π΅ Π΄Π΅Ρ€ΠΈΠ²Ρ‹ ΠΏΠΎΠ·Π²ΠΎΠ»ΡΡŽΡ‚ Π΄ΠΎΠΌΠ΅Π½Π½Ρ‹ΠΌ Ρ‚ΠΈΠΏΠ°ΠΌ ΡƒΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ +Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ΠΌ сообщСний ΠΈ ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹ΠΌΠΈ. + +## ΠšΠ»ΡŽΡ‡Π΅Π²Ρ‹Π΅ возмоТности + +- **Единая таксономия.** `AppError`, `AppErrorKind` ΠΈ `AppCode` ΠΎΠΏΠΈΡΡ‹Π²Π°ΡŽΡ‚ + Π΄ΠΎΠΌΠ΅Π½Π½Ρ‹Π΅ ΠΈ транспортныС аспСкты, ΠΈΠΌΠ΅ΡŽΡ‚ консСрвативноС соотвСтствиС HTTP/gRPC, + Π³ΠΎΡ‚ΠΎΠ²Ρ‹Π΅ подсказки retry/auth ΠΈ RFC7807-ΠΎΡ‚Π²Π΅Ρ‚Ρ‹ Ρ‡Π΅Ρ€Π΅Π· `ProblemJson`. +- **Π ΠΎΠ΄Π½Ρ‹Π΅ Π΄Π΅Ρ€ΠΈΠ²Ρ‹.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]` ΠΈ `#[provide]` ΡΠΎΠ΅Π΄ΠΈΠ½ΡΡŽΡ‚ ваши Ρ‚ΠΈΠΏΡ‹ с `AppError`, пробрасывая + источники, бэктрСйсы, Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΡŽ ΠΈ ΠΏΠΎΠ»ΠΈΡ‚ΠΈΠΊΡƒ рСдактирования. +- **Випизированная тСлСмСтрия.** `Metadata` Ρ…Ρ€Π°Π½ΠΈΡ‚ структурированныС ΠΊΠ»ΡŽΡ‡ΠΈ ΠΈ + значСния с ΠΈΠ½Π΄ΠΈΠ²ΠΈΠ΄ΡƒΠ°Π»ΡŒΠ½Ρ‹ΠΌΠΈ ΠΏΡ€Π°Π²ΠΈΠ»Π°ΠΌΠΈ маскирования, Π° Π±ΠΈΠ»Π΄Π΅Ρ€Ρ‹ `field::*` + ΠΈΠ·Π±Π°Π²Π»ΡΡŽΡ‚ ΠΎΡ‚ Ρ€ΡƒΡ‡Π½Ρ‹Ρ… `String`-ΠΊΠ°Ρ€Ρ‚. +- **ВранспортныС Π°Π΄Π°ΠΏΡ‚Π΅Ρ€Ρ‹.** ΠžΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½Ρ‹Π΅ Ρ„ΠΈΡ‡ΠΈ Π²ΠΊΠ»ΡŽΡ‡Π°ΡŽΡ‚ рСспондСры для Actix/Axum, + ΠΊΠΎΠ½Π²Π΅Ρ€Ρ‚Π°Ρ†ΠΈΡŽ Π² `tonic::Status`, Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ Π² Π±Ρ€Π°ΡƒΠ·Π΅Ρ€/WASM ΠΈ Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΡŽ схСм + OpenAPI Π±Π΅Π· утяТСлСния Π΄Π΅Ρ„ΠΎΠ»Ρ‚Π½ΠΎΠΉ сборки. +- **Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ, ΠΏΡ€ΠΎΠ²Π΅Ρ€Π΅Π½Π½Ρ‹Π΅ Π² бою.** АктивируйтС ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ΠΈ для `sqlx`, `reqwest`, + `redis`, `validator`, `config`, `tokio`, `teloxide`, `multipart`, Telegram + WebApp SDK ΠΈ Π΄Ρ€ΡƒΠ³ΠΈΡ… Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊ β€” каТдая ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄ΠΈΡ‚ ошибки Π² Ρ‚Π°ΠΊΡΠΎΠ½ΠΎΠΌΠΈΡŽ с + ΠΏΡ€ΠΈΠΊΡ€Π΅ΠΏΠ»Ρ‘Π½Π½ΠΎΠΉ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΠ΅ΠΉ. +- **Π“ΠΎΡ‚ΠΎΠ²Ρ‹Π΅ настройки.** ΠœΠΎΠ΄ΡƒΠ»ΡŒ `turnkey` поставляСт Π³ΠΎΡ‚ΠΎΠ²Ρ‹ΠΉ ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ ошибок, + Π±ΠΈΠ»Π΄Π΅Ρ€Ρ‹ ΠΈ ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡŽ с `tracing` для ΠΊΠΎΠΌΠ°Π½Π΄, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΌ Π½ΡƒΠΆΠ½Π° стартовая + конфигурация Β«ΠΈΠ· ΠΊΠΎΡ€ΠΎΠ±ΠΊΠΈΒ». +- **ВипобСзопасныС макросы управлСния ΠΏΠΎΡ‚ΠΎΠΊΠΎΠΌ.** `ensure!` ΠΈ `fail!` ΠΏΡ€Π΅Ρ€Ρ‹Π²Π°ΡŽΡ‚ + Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ с Π΄ΠΎΠΌΠ΅Π½Π½ΠΎΠΉ ошибкой Π±Π΅Π· Π°Π»Π»ΠΎΠΊΠ°Ρ†ΠΈΠΉ ΠΈ форматирования Π½Π° ΡƒΠ΄Π°Ρ‡Π½ΠΎΠΉ Π²Π΅Ρ‚ΠΊΠ΅. + +## Бостав workspace + +| ΠšΡ€Π΅ΠΉΡ‚ | Π§Ρ‚ΠΎ содСрТит | Когда ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ | +| --- | --- | --- | +| [`masterror`](https://crates.io/crates/masterror) | ΠžΡΠ½ΠΎΠ²Π½Ρ‹Π΅ Ρ‚ΠΈΠΏΡ‹ ошибок, Π±ΠΈΠ»Π΄Π΅Ρ€Ρ‹ ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Ρ…, транспорты, ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ ΠΈ ΠΏΡ€Π΅Π»ΡŽΠ΄ΠΈΡ. | Π‘ΠΎΠ΅Π²Ρ‹Π΅ сСрвисы ΠΈ Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΠΈ, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΌ Π½ΡƒΠΆΠ½Π° ΡΡ‚Π°Π±ΠΈΠ»ΡŒΠ½Π°Ρ ΠΏΠΎΠ²Π΅Ρ€Ρ…Π½ΠΎΡΡ‚ΡŒ ошибок. | +| [`masterror-derive`](masterror-derive/README.md) | ΠŸΡ€ΠΎΡ†Π΅Π΄ΡƒΡ€Π½Ρ‹Π΅ макросы `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[provide]`. | Π£ΠΆΠ΅ ΠΈΠ΄Ρ‘Ρ‚ Ρ‚Ρ€Π°Π½Π·ΠΈΡ‚ΠΈΠ²Π½ΠΎ; ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°ΠΉΡ‚Π΅ Π½Π°ΠΏΡ€ΡΠΌΡƒΡŽ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ для экспСримСнтов с макросами. | +| [`masterror-template`](masterror-template/README.md) | ΠžΠ±Ρ‰ΠΈΠΉ парсСр шаблонов для Π°Π½Π°Π»ΠΈΠ·Π° Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Ρ‚Π΅Ρ€ΠΎΠ² Π² Π΄Π΅Ρ€ΠΈΠ²Π°Ρ…. | Π’Π½ΡƒΡ‚Ρ€Π΅Π½Π½ΠΈΠΉ ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚; ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅, Ссли Π½ΡƒΠΆΠ΅Π½ этот парсСр Π² Π΄Ρ€ΡƒΠ³ΠΎΠΌ ΠΊΠΎΠ΄Π΅. | + +## Π€Π»Π°Π³ΠΈ Ρ„ΠΈΡ‡ + +ВсС Ρ„ΠΈΡ‡ΠΈ ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π΅Π½Ρ‹ ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ β€” Π²Ρ‹Π±ΠΈΡ€Π°ΠΉΡ‚Π΅ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½ΡƒΠΆΠ½ΠΎΠ΅. + +- **Π’Π΅Π± ΠΈ API:** `axum`, `actix`, `multipart`, `openapi`, `serde_json`. +- **ΠΠ°Π±Π»ΡŽΠ΄Π°Π΅ΠΌΠΎΡΡ‚ΡŒ:** `tracing`, `metrics`, `backtrace`. +- **Async ΠΈ IO:** `tokio`, `reqwest`, `sqlx`, `sqlx-migrate`, `redis`, `validator`, + `config`. +- **Π‘ΠΎΡ‚Ρ‹ ΠΈ мСссСндТСры:** `teloxide`, `telegram-webapp-sdk`. +- **Π€Ρ€ΠΎΠ½Ρ‚Π΅Π½Π΄:** `frontend` для логирования Π² Π±Ρ€Π°ΡƒΠ·Π΅Ρ€Π΅/WASM. +- **gRPC:** `tonic` для Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ `tonic::Status`. +- **Готовая таксономия:** `turnkey`. + +## TL;DR ~~~toml [dependencies] -# минимальноС ядро -masterror = { version = "0.11.1", default-features = false } -# ΠΈΠ»ΠΈ с Π½ΡƒΠΆΠ½Ρ‹ΠΌΠΈ интСграциями -# masterror = { version = "0.11.1", features = [ +masterror = { version = "0.21.1", default-features = false } +# ΠΈΠ»ΠΈ с Π½ΡƒΠΆΠ½Ρ‹ΠΌΠΈ Ρ„ΠΈΡ‡Π°ΠΌΠΈ: +# masterror = { version = "0.21.1", features = [ # "axum", "actix", "openapi", "serde_json", -# "sqlx", "sqlx-migrate", "reqwest", "redis", -# "validator", "config", "tokio", "multipart", -# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" +# "tracing", "metrics", "backtrace", "sqlx", +# "sqlx-migrate", "reqwest", "redis", "validator", +# "config", "tokio", "multipart", "teloxide", +# "telegram-webapp-sdk", "tonic", "frontend", "turnkey" # ] } ~~~ -**MSRV:** 1.90 +--- -## Быстрый старт +### Быстрый старт Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ошибки Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Π€Π»Π°Π³ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ установлСн"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ ИспользованиС ΠΏΡ€Π΅Π»ΡŽΠ΄ΠΈΠΈ: @@ -73,27 +107,144 @@ fn do_work(flag: bool) -> AppResult<()> { } ~~~ -## Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ +### ΠœΠ°ΠΊΡ€ΠΎΡΡ‹ для Ρ€Π°Π½Π½Π΅Π³ΠΎ Π²ΠΎΠ·Π²Ρ€Π°Ρ‚Π° Π±Π΅Π· ΠΏΠΎΡ‚Π΅Ρ€ΠΈ Ρ‚ΠΈΠΏΠΈΠ·Π°Ρ†ΠΈΠΈ + +`ensure!` ΠΈ `fail!` β€” Ρ‚ΠΈΠΏΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ Π°Π½Π°Π»ΠΎΠ³ΠΈ `anyhow::ensure!`/`anyhow::bail!`. +Они Π²Ρ‹Ρ‡ΠΈΡΠ»ΡΡŽΡ‚ Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ошибки Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΡ€ΠΈ срабатывании Π³Π²Π°Ρ€Π΄Π°, поэтому +ΡƒΡΠΏΠ΅ΡˆΠ½Ρ‹ΠΉ ΠΏΡƒΡ‚ΡŒ остаётся Π±Π΅Π· Π°Π»Π»ΠΎΠΊΠ°Ρ†ΠΈΠΉ. + +~~~rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn guard(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::bad_request("Π€Π»Π°Π³ обязатСлСн")); + Ok(()) +} + +fn bail() -> AppResult<()> { + masterror::fail!(AppError::unauthorized("Π’ΠΎΠΊΠ΅Π½ истёк")); +} + +assert!(guard(true).is_ok()); +assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); +assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); +~~~ + +### Π”Π΅Ρ€ΠΈΠ²Ρ‹ для Π΄ΠΎΠΌΠ΅Π½Π½Ρ‹Ρ… ошибок ΠΈ транспорта + +`masterror` прСдоставляСт Ρ€ΠΎΠ΄Π½Ρ‹Π΅ Π΄Π΅Ρ€ΠΈΠ²Ρ‹, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Ρ‚ΠΈΠΏΡ‹ ΠΎΡΡ‚Π°Π²Π°Π»ΠΈΡΡŒ Π²Ρ‹Ρ€Π°Π·ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹ΠΌΠΈ, Π° +crate ΠΎΡ‚Π²Π΅Ρ‡Π°Π» Π·Π° конвСрсии, Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΡŽ ΠΈ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ сообщСний. + +~~~rust +use std::io; + +use masterror::Error; + +#[derive(Debug, Error)] +#[error("I/O failed: {source}")] +pub struct DomainError { + #[from] + #[source] + source: io::Error, +} -- `sqlx` β€” классификация `sqlx::Error` ΠΏΠΎ Π²ΠΈΠ΄Π°ΠΌ ошибок. -- `sqlx-migrate` β€” ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° `sqlx::migrate::MigrateError` ΠΊΠ°ΠΊ Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ…. -- `reqwest` β€” ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄ сСтСвых/HTTP-сбоСв Π² Π΄ΠΎΠΌΠ΅Π½Π½Ρ‹Π΅ ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ. -- `redis` β€” коррСктная ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ошибок кСша. -- `validator` β€” ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ `ValidationErrors` Π² Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠΎΠ½Π½Ρ‹Π΅ ошибки API. -- `config` β€” Ρ‚ΠΈΠΏΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ ошибки ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ. -- `tokio` β€” ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ Ρ‚Π°ΠΉΠΌΠ°ΡƒΡ‚ΠΎΠ² (`tokio::time::error::Elapsed`). -- `multipart` β€” ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ошибок извлСчСния multipart Π² Axum. -- `teloxide` β€” ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ `teloxide_core::RequestError` Π² Π΄ΠΎΠΌΠ΅Π½Π½Ρ‹Π΅ ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ. -- `telegram-webapp-sdk` β€” ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ошибок Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠΈ Π΄Π°Π½Π½Ρ‹Ρ… Telegram WebApp. -- `frontend` β€” Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ Π² Π±Ρ€Π°ΡƒΠ·Π΅Ρ€Π΅ ΠΈ ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ Π² `JsValue` для WASM. -- `turnkey` β€” Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΠ΅ таксономии для Turnkey SDK. +#[derive(Debug, Error)] +#[error(transparent)] +pub struct WrappedDomainError( + #[from] + #[source] + DomainError +); -## Атрибуты `#[provide]` ΠΈ `#[app_error]` +fn load() -> Result<(), DomainError> { + Err(io::Error::other("disk offline").into()) +} -Атрибут `#[provide(...)]` позволяСт ΠΏΠ΅Ρ€Π΅Π΄Π°Π²Π°Ρ‚ΡŒ ΡΡ‚Ρ€ΡƒΠΊΡ‚ΡƒΡ€ΠΈΡ€ΠΎΠ²Π°Π½Π½ΡƒΡŽ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΡŽ Ρ‡Π΅Ρ€Π΅Π· -`std::error::Request`, Π° `#[app_error(...)]` описываСт прямой ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ Π΄ΠΎΠΌΠ΅Π½Π½ΠΎΠΉ -ошибки Π² `AppError` ΠΈ `AppCode`. Π”Π΅Ρ€ΠΈΠ² сохраняСт синтаксис `thiserror`, Π½ΠΎ -дополняСт Π΅Π³ΠΎ ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€Π°ΠΌΠΈ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΠΈ ΠΈ Π³ΠΎΡ‚ΠΎΠ²Ρ‹ΠΌΠΈ конвСрсиями Π² Ρ‚ΠΈΠΏΡ‹ `masterror`. +let err = load().unwrap_err(); +assert_eq!(err.to_string(), "I/O failed: disk offline"); + +let wrapped = WrappedDomainError::from(err); +assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); +~~~ + +- `use masterror::Error;` ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ макрос Π΄Π΅Ρ€ΠΈΠ²Π°. +- `#[from]` автоматичСски Ρ€Π΅Π°Π»ΠΈΠ·ΡƒΠ΅Ρ‚ `From<...>` ΠΈ провСряСт Ρ„ΠΎΡ€ΠΌΡƒ Π²Ρ€Π°ΠΏΠΏΠ΅Ρ€Π°. +- `#[error(transparent)]` Π³Π°Ρ€Π°Π½Ρ‚ΠΈΡ€ΡƒΠ΅Ρ‚ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΡƒΡŽ ΠΏΡ€ΠΎΠΊΠ»Π°Π΄ΠΊΡƒ `Display`/`source`. +- `#[app_error(kind = ..., code = ..., message)]` сопоставляСт ΠΎΡˆΠΈΠ±ΠΊΡƒ с + `AppError`/`AppCode`; `code = ...` добавляСт `From for AppCode`, Π° + `message` ΠΏΡƒΠ±Π»ΠΈΠΊΡƒΠ΅Ρ‚ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½ΡƒΡŽ строку вмСсто ΠΎΠ±Π΅Π·Π»ΠΈΡ‡Π΅Π½Π½ΠΎΠ³ΠΎ тСкста. +- `masterror::error::template::ErrorTemplate` Ρ€Π°Π·Π±ΠΈΡ€Π°Π΅Ρ‚ строки Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π°, позволяя + ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ собствСнныС Π΄Π΅Ρ€ΠΈΠ²Ρ‹ Π±Π΅Π· зависимости ΠΎΡ‚ `thiserror`. + +### ВСлСмСтрия, Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΈ ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ΠΈ транспортов + +`#[derive(Masterror)]` ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΡƒΠ΅Ρ‚ Π΄ΠΎΠΌΠ΅Π½Π½ΡƒΡŽ ΠΎΡˆΠΈΠ±ΠΊΡƒ Π² [`masterror::Error`], +прикрСпляя ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Π΅, ΠΏΠΎΠ»ΠΈΡ‚ΠΈΠΊΡƒ рСдактирования ΠΈ ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ΠΈ для HTTP/gRPC/RFC7807. + +~~~rust +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message, fields("user_id" = hash)), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); + +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +~~~ + +- `code` / `category` Π·Π°Π΄Π°ΡŽΡ‚ ΠΏΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹ΠΉ [`AppCode`] ΠΈ Π²Π½ΡƒΡ‚Ρ€Π΅Π½Π½ΠΈΠΉ + [`AppErrorKind`]. +- `message` ΠΏΡƒΠ±Π»ΠΈΠΊΡƒΠ΅Ρ‚ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½ΡƒΡŽ строку ΠΊΠ°ΠΊ бСзопасноС сообщСниС. +- `redact(message)` Π²ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅, Π° `fields("name" = hash)` Π·Π°Π΄Π°Ρ‘Ρ‚ + ΠΏΡ€Π°Π²ΠΈΠ»Π° маскирования для ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Ρ…. +- `telemetry(...)` ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°Π΅Ρ‚ выраТСния, Π΄Π°ΡŽΡ‰ΠΈΠ΅ `Option`; Π·Π°ΠΏΠΎΠ»Π½Π΅Π½Π½Ρ‹Π΅ поля + ΠΏΠΎΠΏΠ°Π΄Π°ΡŽΡ‚ Π² [`Metadata`]. +- `map.grpc` / `map.problem` Π΄ΠΎΠ±Π°Π²Π»ΡΡŽΡ‚ gRPC-ΠΊΠΎΠ΄ ΠΈ RFC7807 `type` URI. Π”Π΅Ρ€ΠΈΠ² + Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅Ρ‚ Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹ `HTTP_MAPPING`, `GRPC_MAPPING`, `PROBLEM_MAPPING`. + +ВсС Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚Ρ‹ уровня ΠΏΠΎΠ»Π΅ΠΉ (`#[from]`, `#[source]`, `#[backtrace]`) ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠ°ΡŽΡ‚ +Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ. Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊΠΈ ΠΈ бэктрСйсы автоматичСски ΠΏΡ€ΠΈΠΊΡ€Π΅ΠΏΠ»ΡΡŽΡ‚ΡΡ ΠΊ +[`masterror::Error`]. + +### ΠŸΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€Ρ‹ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΠΈ ΠΈ `AppError` + +`#[provide(...)]` раскрываСт ΡΡ‚Ρ€ΡƒΠΊΡ‚ΡƒΡ€ΠΈΡ€ΠΎΠ²Π°Π½Π½ΡƒΡŽ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΡŽ Ρ‡Π΅Ρ€Π΅Π· +`std::error::Request`, Π° `#[app_error(...)]` описываСт ΠΊΠΎΠ½Π²Π΅Ρ€ΡΠΈΡŽ Π² `AppError` ΠΈ +`AppCode`. ~~~rust use std::error::request_ref; @@ -129,8 +280,8 @@ let via_app = request_ref::(&app).expect("telemetry"); assert_eq!(via_app.name, "db.query"); ~~~ -ΠžΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½Ρ‹Π΅ поля автоматичСски ΠΏΡ€ΠΎΠΏΡƒΡΠΊΠ°ΡŽΡ‚ΡΡ, Ссли значСния Π½Π΅Ρ‚. ΠŸΡ€ΠΈ запросС -значСния `Option` ΠΌΠΎΠΆΠ½ΠΎ Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ ΠΊΠ°ΠΊ ΠΏΠΎ ссылкС, Ρ‚Π°ΠΊ ΠΈ ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‚ΡŒ Π²Π»Π°Π΄Π΅Π½ΠΈΠ΅: +ΠžΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½Π°Ρ тСлСмСтрия Π½Π΅ рСгистрируСт ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€, Ссли Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ `None`, Π° +Π²Π»Π°Π΄Π΅Π½ΠΈΠ΅ ΠΌΠΎΠΆΠ½ΠΎ ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‚ΡŒ Ρ‡Π΅Ρ€Π΅Π· `value = ...`. ~~~rust use masterror::{AppCode, AppErrorKind, Error}; @@ -155,8 +306,7 @@ assert!(request_ref::(&noisy).is_some()); assert!(request_ref::(&silent).is_none()); ~~~ -Для пСрСчислСний каТдая Π²Π΅Ρ‚ΠΊΠ° ΠΌΠΎΠΆΠ΅Ρ‚ Π·Π°Π΄Π°Π²Π°Ρ‚ΡŒ ΡΠΎΠ±ΡΡ‚Π²Π΅Π½Π½ΡƒΡŽ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΡŽ ΠΈ -ΠΊΠΎΠ½Π²Π΅Ρ€ΡΠΈΡŽ. Π”Π΅Ρ€ΠΈΠ² сгСнСрируСт Π΅Π΄ΠΈΠ½Ρ‹ΠΉ `From` для `AppError`/`AppCode`: +ΠŸΠ΅Ρ€Π΅Ρ‡ΠΈΡΠ»Π΅Π½ΠΈΡ ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°ΡŽΡ‚ собствСнныС ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ΠΈ ΠΈ ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€Ρ‹ Π½Π° Π²Π°Ρ€ΠΈΠ°Π½Ρ‚: ~~~rust #[derive(Debug, Error)] @@ -184,184 +334,34 @@ let app: AppError = owned.into(); assert!(matches!(app.kind, AppErrorKind::Service)); ~~~ -Π’ ΠΎΡ‚Π»ΠΈΡ‡ΠΈΠ΅ ΠΎΡ‚ `thiserror`, Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½ΡƒΡŽ ΡΡ‚Ρ€ΡƒΠΊΡ‚ΡƒΡ€ΠΈΡ€ΠΎΠ²Π°Π½Π½ΡƒΡŽ -ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ ΠΈ прямой ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ Π² `AppError`/`AppCode` Π±Π΅Π· Ρ€ΡƒΡ‡Π½Ρ‹Ρ… Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΉ -`From`. - -## Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ шаблонов `#[error]` - -Π¨Π°Π±Π»ΠΎΠ½ `#[error("...")]` ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ `Display`, Π½ΠΎ любая -подстановка ΠΌΠΎΠΆΠ΅Ρ‚ Π·Π°ΠΏΡ€ΠΎΡΠΈΡ‚ΡŒ Π΄Ρ€ΡƒΠ³ΠΎΠΉ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Ρ‚Π΅Ρ€. -`TemplateFormatter::is_alternate()` фиксируСт Ρ„Π»Π°Π³ `#`, Π° `TemplateFormatterKind` -сообщаСт, ΠΊΠ°ΠΊΠΎΠΉ Ρ‚Ρ€Π΅ΠΉΡ‚ `core::fmt` Π½ΡƒΠΆΠ΅Π½, поэтому ΠΏΠΎΡ€ΠΎΠΆΠ΄Ρ‘Π½Π½Ρ‹ΠΉ ΠΊΠΎΠ΄ ΠΌΠΎΠΆΠ΅Ρ‚ -ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒΡΡ ΠΌΠ΅ΠΆΠ΄Ρƒ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Π°ΠΌΠΈ Π±Π΅Π· Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ `match`. НСподдСрТанныС спСцификаторы -приводят ΠΊ диагностикС Π½Π° этапС компиляции, ΡΠΎΠ²ΠΏΠ°Π΄Π°ΡŽΡ‰Π΅ΠΉ с `thiserror`. - -| Π‘ΠΏΠ΅Ρ†ΠΈΡ„ΠΈΠΊΠ°Ρ‚ΠΎΡ€ | Π’Ρ€Π΅ΠΉΡ‚ | ΠŸΡ€ΠΈΠΌΠ΅Ρ€ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚Π° | ΠŸΡ€ΠΈΠΌΠ΅Ρ‡Π°Π½ΠΈΡ | -|------------------|-------------------------|--------------------------|------------| -| _ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ_ | `core::fmt::Display` | `value` | ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠ΅ сообщСния; `#` игнорируСтся. | -| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / многострочный | ПовСдСниС `Debug`; `#` Π²ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ pretty-print. | -| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | ШСстнадцатСричный Π²Ρ‹Π²ΠΎΠ΄; `#` добавляСт `0x`. | -| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Π’Π΅Ρ€Ρ…Π½ΠΈΠΉ рСгистр; `#` добавляСт `0x`. | -| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Π‘Ρ‹Ρ€Ρ‹Π΅ ΡƒΠΊΠ°Π·Π°Ρ‚Π΅Π»ΠΈ; `#` поддСрТиваСтся для совмСстимости. | -| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Π”Π²ΠΎΠΈΡ‡Π½Ρ‹ΠΉ Π²Ρ‹Π²ΠΎΠ΄; `#` добавляСт `0b`. | -| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Π’ΠΎΡΡŒΠΌΠ΅Ρ€ΠΈΡ‡Π½Ρ‹ΠΉ Π²Ρ‹Π²ΠΎΠ΄; `#` добавляСт `0o`. | -| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Научная запись; `#` заставляСт Π²Ρ‹Π²ΠΎΠ΄ΠΈΡ‚ΡŒ Π΄Π΅ΡΡΡ‚ΠΈΡ‡Π½ΡƒΡŽ Ρ‚ΠΎΡ‡ΠΊΡƒ. | -| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Π’Π΅Ρ€Ρ…Π½ΠΈΠΉ рСгистр Π½Π°ΡƒΡ‡Π½ΠΎΠΉ записи; `#` заставляСт Π²Ρ‹Π²ΠΎΠ΄ΠΈΡ‚ΡŒ Ρ‚ΠΎΡ‡ΠΊΡƒ. | - -- `TemplateFormatterKind::supports_alternate()` сообщаСт, ΠΈΠΌΠ΅Π΅Ρ‚ Π»ΠΈ смысл `#` для - Π²Ρ‹Π±Ρ€Π°Π½Π½ΠΎΠ³ΠΎ Ρ‚Ρ€Π΅ΠΉΡ‚Π° (для ΡƒΠΊΠ°Π·Π°Ρ‚Π΅Π»Π΅ΠΉ Π²Ρ‹Π²ΠΎΠ΄ совпадаСт с ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹ΠΌ). -- `TemplateFormatterKind::specifier()` Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ каноничСский символ - спСцификатора, Ρ‡Ρ‚ΠΎ ΡƒΠΏΡ€ΠΎΡ‰Π°Π΅Ρ‚ ΠΏΠΎΠ²Ρ‚ΠΎΡ€Π½Ρ‹ΠΉ Ρ€Π΅Π½Π΄Π΅Ρ€ΠΈΠ½Π³ плСйсхолдСров. -- `TemplateFormatter::from_kind(kind, alternate)` собираСт Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Ρ‚Π΅Ρ€ ΠΈΠ· - `TemplateFormatterKind`, позволяя ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΠ½ΠΎ ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ Ρ„Π»Π°Π³ `#`. -- Display-плСйсхолдСры ΡΠΎΡ…Ρ€Π°Π½ΡΡŽΡ‚ исходныС ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹ форматирования: - ΠΌΠ΅Ρ‚ΠΎΠ΄Ρ‹ `TemplateFormatter::display_spec()` ΠΈ - `TemplateFormatter::format_fragment()` Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°ΡŽΡ‚ `:>8`, `:.3` ΠΈ Π΄Ρ€ΡƒΠ³ΠΈΠ΅ - Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Ρ‹ Π±Π΅Π· нСобходимости ΡΠΎΠ±ΠΈΡ€Π°Ρ‚ΡŒ строку Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ. +Π’Π°ΠΊ Π²Ρ‹ сохраняСтС Π·Π½Π°ΠΊΠΎΠΌΡ‹ΠΉ интСрфСйс `thiserror`, Π½ΠΎ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΡŽ ΠΈ +Π³ΠΎΡ‚ΠΎΠ²Ρ‹Π΅ конвСрсии Π² `AppError`/`AppCode` Π±Π΅Π· Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ ΠΊΠΎΠ΄Π°. -~~~rust -use core::ptr; - -use masterror::Error; - -#[derive(Debug, Error)] -#[error( - "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ - oct={mask:o}, lower={ratio:e}, upper={ratio:E}" -)] -struct FormatterDemo { - id: u32, - payload: String, - ptr: *const u8, - mask: u8, - ratio: f32, -} - -let err = FormatterDemo { - id: 0x2a, - payload: "hello".into(), - ptr: ptr::null(), - mask: 0b1010_0001, - ratio: 0.15625, -}; - -let rendered = err.to_string(); -assert!(rendered.contains("debug=\"hello\"")); -assert!(rendered.contains("hex=0x2a")); -assert!(rendered.contains("ptr=0x0")); -assert!(rendered.contains("bin=0b10100001")); -assert!(rendered.contains("oct=241")); -assert!(rendered.contains("lower=1.5625e-1")); -assert!(rendered.contains("upper=1.5625E-1")); -~~~ - -`masterror::error::template::ErrorTemplate` позволяСт Ρ€Π°Π·ΠΎΠ±Ρ€Π°Ρ‚ΡŒ шаблон ΠΈ -ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΠ½ΠΎ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ Π·Π°ΠΏΡ€ΠΎΡˆΠ΅Π½Π½Ρ‹Π΅ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Ρ‚Π΅Ρ€Ρ‹; пСрСчислСниС -`TemplateFormatterKind` Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ Ρ‚Ρ€Π΅ΠΉΡ‚Π° для ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ плСйсхолдСра: +### Problem JSON ΠΈ подсказки retry/auth ~~~rust -use masterror::error::template::{ - ErrorTemplate, TemplateFormatter, TemplateFormatterKind -}; +use masterror::{AppError, AppErrorKind, ProblemJson}; +use std::time::Duration; -let template = ErrorTemplate::parse("{code:#x} β†’ {payload:?}").expect("parse"); -let mut placeholders = template.placeholders(); - -let code = placeholders.next().expect("code placeholder"); -let code_formatter = code.formatter(); -assert!(matches!( - code_formatter, - TemplateFormatter::LowerHex { alternate: true } -)); -let code_kind = code_formatter.kind(); -assert_eq!(code_kind, TemplateFormatterKind::LowerHex); -assert!(code_formatter.is_alternate()); -assert_eq!(code_kind.specifier(), Some('x')); -assert!(code_kind.supports_alternate()); -let lowered = TemplateFormatter::from_kind(code_kind, false); -assert!(matches!( - lowered, - TemplateFormatter::LowerHex { alternate: false } -)); - -let payload = placeholders.next().expect("payload placeholder"); -let payload_formatter = payload.formatter(); -assert_eq!( - payload_formatter, - &TemplateFormatter::Debug { alternate: false } +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_duration(Duration::from_secs(30)) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) ); -let payload_kind = payload_formatter.kind(); -assert_eq!(payload_kind, TemplateFormatterKind::Debug); -assert_eq!(payload_kind.specifier(), Some('?')); -assert!(payload_kind.supports_alternate()); -let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); -assert!(matches!( - pretty_debug, - TemplateFormatter::Debug { alternate: true } -)); -assert!(pretty_debug.is_alternate()); -~~~ - -ΠžΠΏΡ†ΠΈΠΈ выравнивания, точности ΠΈ заполнСния для `Display` ΡΠΎΡ…Ρ€Π°Π½ΡΡŽΡ‚ΡΡ ΠΈ доступны -для прямой ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‡ΠΈ Π² `write!`: -~~~rust -use masterror::error::template::ErrorTemplate; - -let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); -let display = aligned.placeholders().next().expect("display placeholder"); -assert_eq!(display.formatter().display_spec(), Some(">8")); -assert_eq!( - display - .formatter() - .format_fragment() - .as_deref(), - Some(">8") -); +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~ -ДинамичСскиС ΡˆΠΈΡ€ΠΈΠ½Π° ΠΈ Ρ‚ΠΎΡ‡Π½ΠΎΡΡ‚ΡŒ (`{value:>width$}`, `{value:.precision$}`) -Ρ‚ΠΎΠΆΠ΅ доходят Π΄ΠΎ Π²Ρ‹Π·ΠΎΠ²Π° `write!`, Ссли ΠΎΠ±ΡŠΡΠ²ΠΈΡ‚ΡŒ ΡΠΎΠΎΡ‚Π²Π΅Ρ‚ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ Π°Ρ€Π³ΡƒΠΌΠ΅Π½Ρ‚Ρ‹ Π² -Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚Π΅ `#[error(...)]`: - -~~~rust -use masterror::Error; - -#[derive(Debug, Error)] -#[error("{value:>width$}", value = .value, width = .width)] -struct DynamicWidth { - value: &'static str, - width: usize, -} - -#[derive(Debug, Error)] -#[error("{value:.precision$}", value = .value, precision = .precision)] -struct DynamicPrecision { - value: f64, - precision: usize, -} - -let width = DynamicWidth { - value: "x", - width: 5, -}; -let precision = DynamicPrecision { - value: 123.456_f64, - precision: 4, -}; - -assert_eq!(width.to_string(), format!("{value:>width$}", value = "x", width = 5)); -assert_eq!( - precision.to_string(), - format!("{value:.precision$}", value = 123.456_f64, precision = 4) -); -~~~ +### Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΌΠ°Ρ‚Π΅Ρ€ΠΈΠ°Π»Ρ‹ -> **Π‘ΠΎΠ²ΠΌΠ΅ΡΡ‚ΠΈΠΌΠΎΡΡ‚ΡŒ с `thiserror` v2.** ДоступныС спСцификаторы, сообщСния ΠΎΠ± -> ΠΎΡˆΠΈΠ±ΠΊΠ°Ρ… ΠΈ ΠΏΠΎΠ²Π΅Π΄Π΅Π½ΠΈΠ΅ ΡΠΎΠ²ΠΏΠ°Π΄Π°ΡŽΡ‚ с `thiserror` 2.x, поэтому миграция с -> `thiserror::Error` Π½Π° `masterror::Error` Π½Π΅ Ρ‚Ρ€Π΅Π±ΡƒΠ΅Ρ‚ ΠΏΠ΅Ρ€Π΅ΠΏΠΈΡΡ‹Π²Π°Ρ‚ΡŒ ΡˆΠ°Π±Π»ΠΎΠ½Ρ‹. +- [Π’ΠΈΠΊΠΈ ΠΏΠΎ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ΅ ошибок](docs/wiki/index.md) с ΠΏΠΎΡˆΠ°Π³ΠΎΠ²Ρ‹ΠΌΠΈ руководствами, + сравнСниСм `thiserror`/`anyhow` ΠΈ Ρ€Π΅Ρ†Π΅ΠΏΡ‚Π°ΠΌΠΈ устранСния ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌ. +- [ДокумСнтация Π½Π° docs.rs](https://docs.rs/masterror) с ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½Ρ‹ΠΌΠΈ Ρ‚Π°Π±Π»ΠΈΡ†Π°ΠΌΠΈ ΠΏΠΎ + Ρ„ΠΈΡ‡Π°ΠΌ ΠΈ транспортам. +- [`CHANGELOG.md`](CHANGELOG.md) для истории Ρ€Π΅Π»ΠΈΠ·ΠΎΠ² ΠΈ ΠΌΠΈΠ³Ρ€Π°Ρ†ΠΈΠΉ. -## ЛицСнзия +--- -ΠŸΡ€ΠΎΠ΅ΠΊΡ‚ распространяСтся ΠΏΠΎ Π»ΠΈΡ†Π΅Π½Π·ΠΈΠΈ Apache-2.0 ΠΈΠ»ΠΈ MIT Π½Π° ваш Π²Ρ‹Π±ΠΎΡ€. +MSRV: **1.90** Β· ЛицСнзия: **MIT OR Apache-2.0** Β· Π‘Π΅Π· `unsafe` diff --git a/README.template.md b/README.template.md index 456a20b..74dc2d7 100644 --- a/README.template.md +++ b/README.template.md @@ -14,23 +14,60 @@ > πŸ‡·πŸ‡Ί Π§ΠΈΡ‚Π°ΠΉΡ‚Π΅ README Π½Π° [русском языкС](README.ru.md). -Small, pragmatic error model for API-heavy Rust services with native derives -and typed telemetry. -Core is framework-agnostic; integrations are opt-in via feature flags. -Stable categories, conservative HTTP mapping, no `unsafe`. - -- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse` -- Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain - mappings and structured telemetry -- Optional Axum/Actix integration and browser/WASM console logging -- Optional OpenAPI schema (via `utoipa`) -- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` -- Turnkey domain taxonomy and helpers (`turnkey` feature) - -πŸ‘‰ Explore the new [error-handling wiki](docs/wiki/index.md) for step-by-step -guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. - ---- +`masterror` grew from a handful of helpers into a workspace of composable crates for +building consistent, observable error surfaces across Rust services. The core +crate stays framework-agnostic, while feature flags light up transport adapters, +integrations and telemetry without pulling in heavyweight defaults. No +`unsafe`, MSRV is pinned, and the derive macros keep your domain types in charge +of redaction and metadata. + +### Highlights + +- **Unified taxonomy.** `AppError`, `AppErrorKind` and `AppCode` model domain and + transport concerns with conservative HTTP/gRPC mappings, turnkey retry/auth + hints and RFC7807 output via `ProblemJson`. +- **Native derives.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]` and `#[provide]` wire custom types into `AppError` while + forwarding sources, backtraces, telemetry providers and redaction policy. +- **Typed telemetry.** `Metadata` stores structured key/value context with + per-field redaction controls and builders in `field::*`, so logs stay + structured without manual `String` maps. +- **Transport adapters.** Optional features expose Actix/Axum responders, + `tonic::Status` conversions, WASM/browser logging and OpenAPI schema + generation without contaminating the lean default build. +- **Battle-tested integrations.** Enable focused mappings for `sqlx`, + `reqwest`, `redis`, `validator`, `config`, `tokio`, `teloxide`, `multipart`, + Telegram WebApp SDK and more β€” each translating library errors into the + taxonomy with telemetry attached. +- **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog, + helper builders and tracing instrumentation for teams that want a consistent + baseline out of the box. +- **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions + with your domain errors without allocating or formatting on the happy path. + +### Workspace crates + +| Crate | What it provides | When to depend on it | +| --- | --- | --- | +| [`masterror`](https://crates.io/crates/masterror) | Core error types, metadata builders, transports, integrations and the prelude. | Application crates, services and libraries that want a stable error surface. | +| [`masterror-derive`](masterror-derive/README.md) | Proc-macros backing `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]` and `#[provide]`. | Brought in automatically via `masterror`; depend directly only for macro hacking. | +| [`masterror-template`](masterror-template/README.md) | Shared template parser used by the derive macros for formatter analysis. | Internal dependency; reuse when you need the template parser elsewhere. | + +### Feature flags at a glance + +Pick only what you need; everything is off by default. + +- **Web transports:** `axum`, `actix`, `multipart`, `openapi`, `serde_json`. +- **Telemetry & observability:** `tracing`, `metrics`, `backtrace`. +- **Async & IO integrations:** `tokio`, `reqwest`, `sqlx`, `sqlx-migrate`, + `redis`, `validator`, `config`. +- **Messaging & bots:** `teloxide`, `telegram-webapp-sdk`. +- **Front-end tooling:** `frontend` for WASM/browser console logging. +- **gRPC:** `tonic` to emit `tonic::Status` responses. +- **Batteries included:** `turnkey` to adopt the pre-built taxonomy and helpers. + +The build script keeps the full feature snippet below in sync with +`Cargo.toml`. ### TL;DR @@ -43,56 +80,21 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } # ] } ~~~ -*Since v0.5.0: derive custom errors via `#[derive(Error)]` (`use masterror::Error;`) and inspect browser logging failures with `BrowserConsoleError::context()`.* -*Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* -*Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* - --- -
- Why this crate? - -- **Stable taxonomy.** Small set of `AppErrorKind` categories mapping conservatively to HTTP. -- **Framework-agnostic.** No assumptions, no `unsafe`, MSRV pinned. -- **Opt-in integrations.** Zero default features; you enable what you need. -- **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`. -- **One log at boundary.** Log once with `tracing`. -- **Less boilerplate.** Built-in conversions, compact prelude, and the - native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` - support. -- **Consistent workspace.** Same error surface across crates. - -
- -
- Installation - -~~~toml -[dependencies] -# lean core -masterror = { version = "{{CRATE_VERSION}}", default-features = false } - -# with Axum/Actix + JSON + integrations -# masterror = { version = "{{CRATE_VERSION}}", features = [ -{{FEATURE_SNIPPET}} -# ] } -~~~ - -**MSRV:** {{MSRV}} -**No unsafe:** forbidden by crate. - -
-
Quick start Create an error: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ With prelude: @@ -111,7 +113,36 @@ fn do_work(flag: bool) -> AppResult<()> {
- Derive custom errors + Fail fast without sacrificing typing + +`ensure!` and `fail!` provide typed alternatives to the formatting-heavy +`anyhow::ensure!`/`anyhow::bail!` helpers. They evaluate the error expression +only when the guard trips, so success paths stay allocation-free. + +~~~rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn guard(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::bad_request("flag must be set")); + Ok(()) +} + +fn bail() -> AppResult<()> { + masterror::fail!(AppError::unauthorized("token expired")); +} + +assert!(guard(true).is_ok()); +assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); +assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); +~~~ + +
+ +
+ Derive domain errors and map them to transports + +`masterror` ships native derives so your domain types stay expressive while the +crate handles conversions, telemetry and redaction for you. ~~~rust use std::io; @@ -145,7 +176,7 @@ let wrapped = WrappedDomainError::from(err); assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ -- `use masterror::Error;` brings the crate's derive macro into scope. +- `use masterror::Error;` brings the derive macro into scope. - `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are valid. - `#[error(transparent)]` enforces single-field wrappers that forward @@ -168,103 +199,85 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. -#### Display shorthand projections - -`#[error("...")]` supports the same shorthand syntax as `thiserror` for -referencing fields with `.field` or `.0`. The derive now understands chained -segments, so projections like `.limits.lo`, `.0.data` or -`.suggestion.as_ref().map_or_else(...)` keep compiling unchanged. Raw -identifiers and tuple indices are preserved, ensuring keywords such as -`r#type` and tuple fields continue to work even when you call methods on the -projected value. - -~~~rust -use masterror::Error; - -#[derive(Debug)] -struct Limits { - lo: i32, - hi: i32, -} - -#[derive(Debug, Error)] -#[error( - "range {lo}-{hi} suggestion {suggestion}", - lo = .limits.lo, - hi = .limits.hi, - suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) -)] -struct RangeError { - limits: Limits, - suggestion: Option, -} - -#[derive(Debug)] -struct Payload { - data: &'static str, -} - -#[derive(Debug, Error)] -enum UiError { - #[error("tuple data {data}", data = .0.data)] - Tuple(Payload), - #[error( - "named suggestion {value}", - value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) - )] - Named { suggestion: Option }, -} -~~~ +
-#### AppError conversions +
+ Attach telemetry, redaction policy and conversions -Annotating structs or enum variants with `#[app_error(...)]` captures the -metadata required to convert the domain error into `AppError` (and optionally -`AppCode`). Every variant in an enum must provide the mapping when any variant -requests it. +`#[derive(Masterror)]` wires a domain error into [`masterror::Error`], adds +metadata, redaction policy and optional transport mappings. The accompanying +`#[masterror(...)]` attribute mirrors the `#[app_error]` syntax while staying +explicit about telemetry and redaction. ~~~rust -use masterror::{AppCode, AppError, AppErrorKind, Error}; +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; -#[derive(Debug, Error)] -#[error("missing flag: {name}")] -#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message, fields("user_id" = hash)), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] struct MissingFlag { - name: &'static str, + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option } -let app: AppError = MissingFlag { name: "feature" }.into(); -assert!(matches!(app.kind, AppErrorKind::BadRequest)); -assert_eq!(app.message.as_deref(), Some("missing flag: feature")); +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); -let code: AppCode = MissingFlag { name: "feature" }.into(); -assert!(matches!(code, AppCode::BadRequest)); +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); ~~~ -For enums, each variant specifies the mapping while the derive generates a -single `From` implementation that matches every variant: - -~~~rust -#[derive(Debug, Error)] -enum ApiError { - #[error("missing resource {id}")] - #[app_error( - kind = AppErrorKind::NotFound, - code = AppCode::NotFound, - message - )] - Missing { id: u64 }, - #[error("backend unavailable")] - #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] - Backend, -} +- `code` / `category` pick the public [`AppCode`] and internal + [`AppErrorKind`]. +- `message` forwards the formatted [`Display`] output as the safe public + message. Omit it to keep the message private. +- `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport + boundary, `fields("name" = hash, "card" = last4)` overrides metadata + policies (`hash`, `last4`, `redact`, `none`). +- `telemetry(...)` accepts expressions that evaluate to + `Option`. Each populated field is inserted into the + resulting [`Metadata`]; use `telemetry()` when no fields are attached. +- `map.grpc` / `map.problem` capture optional gRPC status codes (as `i32`) and + RFC 7807 `type` URIs. The derive emits tables such as + `MyError::HTTP_MAPPING`, `MyError::GRPC_MAPPING` and + `MyError::PROBLEM_MAPPING` (or slice variants for enums) for downstream + integrations. + +All familiar field-level attributes (`#[from]`, `#[source]`, `#[backtrace]`) +are still honoured. Sources and backtraces are automatically attached to the +generated [`masterror::Error`]. -let missing = ApiError::Missing { id: 7 }; -let as_app: AppError = missing.into(); -assert_eq!(as_app.message.as_deref(), Some("missing resource 7")); -~~~ +
-#### Structured telemetry providers and AppError mappings +
+ Structured telemetry providers and AppError mappings `#[provide(...)]` exposes typed context through `std::error::Request`, while `#[app_error(...)]` records how your domain error translates into `AppError` @@ -365,325 +378,38 @@ assert!(matches!(app.kind, AppErrorKind::Service)); Compared to `thiserror`, you retain the familiar deriving surface while gaining structured telemetry (`#[provide]`) and first-class conversions into -`AppError`/`AppCode` without writing manual `From` implementations. - -#### Formatter traits - -Placeholders default to `Display` (`{value}`) but can opt into richer -formatters via the same specifiers supported by `thiserror` v2. -`TemplateFormatter::is_alternate()` tracks the `#` flag, while -`TemplateFormatterKind` exposes the underlying `core::fmt` trait so derived -code can branch on the requested renderer without manual pattern matching. -Unsupported formatters surface a compile error that mirrors `thiserror`'s -diagnostics. - -| Specifier | `core::fmt` trait | Example output | Notes | -|------------------|----------------------------|------------------------|-------| -| _default_ | `core::fmt::Display` | `value` | User-facing strings; `#` has no effect. | -| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / multi-line | Mirrors `Debug`; `#` pretty-prints structs. | -| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | Hexadecimal; `#` prepends `0x`. | -| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Uppercase hex; `#` prepends `0x`. | -| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Raw pointers; `#` is accepted for compatibility. | -| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Binary; `#` prepends `0b`. | -| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Octal; `#` prepends `0o`. | -| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Scientific notation; `#` forces the decimal point. | -| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Uppercase scientific; `#` forces the decimal point. | - -- `TemplateFormatterKind::supports_alternate()` reports whether the `#` flag is - meaningful for the requested trait (pointer accepts it even though the output - matches the non-alternate form). -- `TemplateFormatterKind::specifier()` returns the canonical format specifier - character when one exists, enabling custom derives to re-render placeholders - in their original style. -- `TemplateFormatter::from_kind(kind, alternate)` reconstructs a formatter from - the lightweight `TemplateFormatterKind`, making it easy to toggle the - alternate flag in generated code. - -~~~rust -use core::ptr; - -use masterror::Error; - -#[derive(Debug, Error)] -#[error( - "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ - oct={mask:o}, lower={ratio:e}, upper={ratio:E}" -)] -struct FormattedError { - id: u32, - payload: String, - ptr: *const u8, - mask: u8, - ratio: f32, -} - -let err = FormattedError { - id: 0x2a, - payload: "hello".into(), - ptr: ptr::null(), - mask: 0b1010_0001, - ratio: 0.15625, -}; - -let rendered = err.to_string(); -assert!(rendered.contains("debug=\"hello\"")); -assert!(rendered.contains("hex=0x2a")); -assert!(rendered.contains("ptr=0x0")); -assert!(rendered.contains("bin=0b10100001")); -assert!(rendered.contains("oct=241")); -assert!(rendered.contains("lower=1.5625e-1")); -assert!(rendered.contains("upper=1.5625E-1")); -~~~ - -~~~rust -use masterror::error::template::{ - ErrorTemplate, TemplateFormatter, TemplateFormatterKind -}; - -let template = ErrorTemplate::parse("{code:#x} β†’ {payload:?}").expect("parse"); -let mut placeholders = template.placeholders(); - -let code = placeholders.next().expect("code placeholder"); -let code_formatter = code.formatter(); -assert!(matches!( - code_formatter, - TemplateFormatter::LowerHex { alternate: true } -)); -let code_kind = code_formatter.kind(); -assert_eq!(code_kind, TemplateFormatterKind::LowerHex); -assert!(code_formatter.is_alternate()); -assert_eq!(code_kind.specifier(), Some('x')); -assert!(code_kind.supports_alternate()); -let lowered = TemplateFormatter::from_kind(code_kind, false); -assert!(matches!( - lowered, - TemplateFormatter::LowerHex { alternate: false } -)); - -let payload = placeholders.next().expect("payload placeholder"); -let payload_formatter = payload.formatter(); -assert_eq!( - payload_formatter, - &TemplateFormatter::Debug { alternate: false } -); -let payload_kind = payload_formatter.kind(); -assert_eq!(payload_kind, TemplateFormatterKind::Debug); -assert_eq!(payload_kind.specifier(), Some('?')); -assert!(payload_kind.supports_alternate()); -let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); -assert!(matches!( - pretty_debug, - TemplateFormatter::Debug { alternate: true } -)); -assert!(pretty_debug.is_alternate()); -~~~ - -Display-only format specs (alignment, precision, fill β€” including `#` as a fill -character) are preserved so you can forward them to `write!` without rebuilding -the fragment: - -~~~rust -use masterror::error::template::ErrorTemplate; - -let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); -let display = aligned.placeholders().next().expect("display placeholder"); -assert_eq!(display.formatter().display_spec(), Some(">8")); -assert_eq!( - display - .formatter() - .format_fragment() - .as_deref(), - Some(">8") -); - -let hashed = ErrorTemplate::parse("{value:#>4}").expect("parse"); -let hash_placeholder = hashed - .placeholders() - .next() - .expect("hash-fill display placeholder"); -assert_eq!(hash_placeholder.formatter().display_spec(), Some("#>4")); -assert_eq!( - hash_placeholder - .formatter() - .format_fragment() - .as_deref(), - Some("#>4") -); -~~~ - -> **Compatibility with `thiserror` v2:** the derive understands the extended -> formatter set introduced in `thiserror` 2.x and reports identical diagnostics -> for unsupported specifiers, so migrating existing derives is drop-in. - -```rust -use masterror::error::template::{ErrorTemplate, TemplateIdentifier}; - -let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); -let display = template.display_with(|placeholder, f| match placeholder.identifier() { - TemplateIdentifier::Named("code") => write!(f, "{}", 404), - TemplateIdentifier::Named("message") => f.write_str("Not Found"), - _ => Ok(()), -}); - -assert_eq!(display.to_string(), "404: Not Found"); -``` +`AppError`/`AppCode` without manual glue.
- Error response payload + Problem JSON payloads and retry/authentication hints ~~~rust -use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse}; +use masterror::{AppError, AppErrorKind, ProblemJson}; use std::time::Duration; -let app_err = AppError::new(AppErrorKind::Unauthorized, "Token expired"); -let resp: ErrorResponse = (&app_err).into() - .with_retry_after_duration(Duration::from_secs(30)) - .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#); - -assert_eq!(resp.status, 401); -~~~ - -
- -
- Web framework integrations - -
- Axum - -~~~rust -// features = ["axum", "serde_json"] -... - assert!(payload.is_object()); - - #[cfg(target_arch = "wasm32")] - { - if let Err(console_err) = err.log_to_browser_console() { - eprintln!( - "failed to log to browser console: {:?}", - console_err.context() - ); - } - } - - Ok(()) -} -~~~ - -- On non-WASM targets `log_to_browser_console` returns - `BrowserConsoleError::UnsupportedTarget`. -- `BrowserConsoleError::context()` exposes optional browser diagnostics for - logging/telemetry when console logging fails. - -
- -
- -
- Feature flags - -{{FEATURE_BULLETS}} - -
- -
- Conversions - -{{CONVERSION_BULLETS}} - -
- -
- Typical setups - -Minimal core: - -~~~toml -masterror = { version = "{{CRATE_VERSION}}", default-features = false } -~~~ - -API (Axum + JSON + deps): - -~~~toml -masterror = { version = "{{CRATE_VERSION}}", features = [ - "axum", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -API (Actix + JSON + deps): - -~~~toml -masterror = { version = "{{CRATE_VERSION}}", features = [ - "actix", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -
- -
- Turnkey +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_duration(Duration::from_secs(30)) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) +); -~~~rust -// features = ["turnkey"] -use masterror::turnkey::{classify_turnkey_error, TurnkeyError, TurnkeyErrorKind}; -use masterror::{AppError, AppErrorKind}; - -// Classify a raw SDK/provider error -let kind = classify_turnkey_error("429 Too Many Requests"); -assert!(matches!(kind, TurnkeyErrorKind::RateLimited)); - -// Wrap into AppError -let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "throttled upstream"); -let app: AppError = e.into(); -assert_eq!(app.kind, AppErrorKind::RateLimited); +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~
-
- Migration 0.2 β†’ 0.3 - -- Use `ErrorResponse::new(status, AppCode::..., "msg")` instead of legacy -- New helpers: `.with_retry_after_secs`, `.with_retry_after_duration`, `.with_www_authenticate` -- `ErrorResponse::new_legacy` is temporary shim - -
- -
- Versioning & MSRV - -Semantic versioning. Breaking API/wire contract β†’ major bump. -MSRV = {{MSRV}} (may raise in minor, never in patch). - -
- -
- Release checklist +### Further resources -1. `cargo +nightly fmt --` -1. `cargo clippy -- -D warnings` -1. `cargo test --all` -1. `cargo build` (regenerates README.md from the template) -1. `cargo doc --no-deps` -1. `cargo package --locked` +- Explore the [error-handling wiki](docs/wiki/index.md) for step-by-step guides, + comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. +- Browse the [crate documentation on docs.rs](https://docs.rs/masterror) for API + details, feature-specific guides and transport tables. +- Check [`CHANGELOG.md`](CHANGELOG.md) for release highlights and migration notes. -
- -
- Non-goals - -- Not a general-purpose error aggregator like `anyhow` -- Not a replacement for your domain errors - -
- -
- License - -Apache-2.0 OR MIT, at your option. +--- -
+MSRV: **{{MSRV}}** Β· License: **MIT OR Apache-2.0** Β· No `unsafe` diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index c9ba1a6..b757e0b 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.6.6" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index 73408b8..382c0e4 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -6,7 +6,7 @@ use syn::{ Expr, ExprPath, Field as SynField, Fields as SynFields, GenericArgument, Ident, LitBool, LitInt, LitStr, Token, TypePath, ext::IdentExt, - parse::{Parse, ParseStream}, + parse::{Parse, ParseBuffer, ParseStream}, punctuated::Punctuated, spanned::Spanned, token::Paren @@ -33,7 +33,8 @@ pub struct StructData { pub display: DisplaySpec, #[allow(dead_code)] pub format_args: FormatArgsSpec, - pub app_error: Option + pub app_error: Option, + pub masterror: Option } #[derive(Debug)] @@ -44,6 +45,7 @@ pub struct VariantData { #[allow(dead_code)] pub format_args: FormatArgsSpec, pub app_error: Option, + pub masterror: Option, pub span: Span } @@ -55,6 +57,39 @@ pub struct AppErrorSpec { pub attribute_span: Span } +#[derive(Clone, Debug)] +pub struct MasterrorSpec { + pub code: Expr, + pub category: ExprPath, + pub expose_message: bool, + pub redact: RedactSpec, + pub telemetry: Vec, + pub map_grpc: Option, + pub map_problem: Option, + #[allow(dead_code)] + pub attribute_span: Span +} + +#[derive(Clone, Debug, Default)] +pub struct RedactSpec { + pub message: bool, + pub fields: Vec +} + +#[derive(Clone, Debug)] +pub struct FieldRedactionSpec { + pub name: LitStr, + pub policy: FieldRedactionKind +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldRedactionKind { + None, + Redact, + Hash, + Last4 +} + #[derive(Debug)] pub enum Fields { Unit, @@ -524,6 +559,7 @@ fn parse_struct( ) -> Result { let display = extract_display_spec(attrs, ident.span(), errors)?; let app_error = extract_app_error_spec(attrs, errors)?; + let masterror = extract_masterror_spec(attrs, errors)?; let fields = Fields::from_syn(&data.fields, errors); validate_from_usage(&fields, &display, errors); @@ -534,7 +570,8 @@ fn parse_struct( fields, display, format_args: FormatArgsSpec::default(), - app_error + app_error, + masterror }))) } @@ -573,6 +610,7 @@ fn parse_variant(variant: syn::Variant, errors: &mut Vec) -> Result) -> Result +) -> Result, ()> { + let mut spec = None; + let mut had_error = false; + + for attr in attrs { + if !path_is(attr, "masterror") { + continue; + } + + if spec.is_some() { + errors.push(Error::new_spanned( + attr, + "duplicate #[masterror(...)] attribute" + )); + had_error = true; + continue; + } + + match parse_masterror_attribute(attr) { + Ok(parsed) => spec = Some(parsed), + Err(err) => { + errors.push(err); + had_error = true; + } + } + } + + if had_error { Err(()) } else { Ok(spec) } +} + fn extract_app_error_spec( attrs: &[Attribute], errors: &mut Vec @@ -734,6 +806,287 @@ fn parse_app_error_attribute(attr: &Attribute) -> Result { }) } +fn parse_masterror_attribute(attr: &Attribute) -> Result { + attr.parse_args_with(|input: ParseStream| { + let mut code = None; + let mut category = None; + let mut expose_message = false; + let mut redact = RedactSpec::default(); + let mut seen_redact = false; + let mut telemetry = None; + let mut map_grpc = None; + let mut map_problem = None; + + while !input.is_empty() { + let ident: Ident = input.call(Ident::parse_any)?; + match ident.to_string().as_str() { + "code" => { + if code.is_some() { + return Err(Error::new(ident.span(), "duplicate code specification")); + } + input.parse::()?; + let value: Expr = input.parse()?; + code = Some(value); + } + "category" => { + if category.is_some() { + return Err(Error::new(ident.span(), "duplicate category specification")); + } + input.parse::()?; + let value: ExprPath = input.parse()?; + category = Some(value); + } + "message" => { + if expose_message { + return Err(Error::new(ident.span(), "duplicate message flag")); + } + expose_message = parse_flag_value(input)?; + } + "redact" => { + if seen_redact { + return Err(Error::new(ident.span(), "duplicate redact(...) block")); + } + redact = parse_redact_block(input, ident.span())?; + seen_redact = true; + } + "telemetry" => { + if telemetry.is_some() { + return Err(Error::new(ident.span(), "duplicate telemetry(...) block")); + } + telemetry = Some(parse_telemetry_block(input, ident.span())?); + } + "map" => { + input.parse::()?; + let sub: Ident = input.call(Ident::parse_any)?; + match sub.to_string().as_str() { + "grpc" => { + if map_grpc.is_some() { + return Err(Error::new( + sub.span(), + "duplicate map.grpc specification" + )); + } + input.parse::()?; + let value: Expr = input.parse()?; + map_grpc = Some(value); + } + "problem" => { + if map_problem.is_some() { + return Err(Error::new( + sub.span(), + "duplicate map.problem specification" + )); + } + input.parse::()?; + let value: Expr = input.parse()?; + map_problem = Some(value); + } + other => { + return Err(Error::new( + sub.span(), + format!("unknown #[masterror] mapping `map.{other}`") + )); + } + } + } + other => { + return Err(Error::new( + ident.span(), + format!("unknown #[masterror] option `{other}`") + )); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } else if !input.is_empty() { + return Err(Error::new( + input.span(), + "expected `,` or end of input in #[masterror(...)]" + )); + } + } + + let code = match code { + Some(value) => value, + None => { + return Err(Error::new( + attr.span(), + "missing `code = ...` in #[masterror(...)]" + )); + } + }; + + let category = match category { + Some(value) => value, + None => { + return Err(Error::new( + attr.span(), + "missing `category = ...` in #[masterror(...)]" + )); + } + }; + + Ok(MasterrorSpec { + code, + category, + expose_message, + redact, + telemetry: telemetry.unwrap_or_default(), + map_grpc, + map_problem, + attribute_span: attr.span() + }) + }) +} + +fn parse_flag_value(input: ParseStream) -> Result { + if input.peek(Token![=]) { + input.parse::()?; + let value: LitBool = input.parse()?; + Ok(value.value) + } else { + Ok(true) + } +} + +fn parse_redact_block(input: ParseStream, span: Span) -> Result { + let content; + syn::parenthesized!(content in input); + + if content.is_empty() { + return Err(Error::new(span, "redact(...) requires at least one option")); + } + + let mut spec = RedactSpec::default(); + + while !content.is_empty() { + let ident: Ident = content.call(Ident::parse_any)?; + match ident.to_string().as_str() { + "message" => { + if spec.message { + return Err(Error::new(ident.span(), "duplicate redact(message) option")); + } + if content.peek(Token![=]) { + content.parse::()?; + let value: LitBool = content.parse()?; + spec.message = value.value; + } else { + spec.message = true; + } + } + "fields" => { + if !spec.fields.is_empty() { + return Err(Error::new( + ident.span(), + "duplicate redact(fields(...)) option" + )); + } + spec.fields = parse_redact_fields(&content, ident.span())?; + } + other => { + return Err(Error::new( + ident.span(), + format!("unknown redact option `{other}`") + )); + } + } + + if content.peek(Token![,]) { + content.parse::()?; + } else if !content.is_empty() { + return Err(Error::new( + content.span(), + "expected `,` or end of input in redact(...)" + )); + } + } + + Ok(spec) +} + +fn parse_redact_fields( + content: &ParseBuffer<'_>, + span: Span +) -> Result, Error> { + let inner; + syn::parenthesized!(inner in *content); + + if inner.is_empty() { + return Err(Error::new( + span, + "redact(fields(...)) requires at least one field" + )); + } + + let mut fields = Vec::new(); + while !inner.is_empty() { + let name: LitStr = inner.parse()?; + let policy = if inner.peek(Token![=]) { + inner.parse::()?; + let ident: Ident = inner.call(Ident::parse_any)?; + match ident.to_string().to_ascii_lowercase().as_str() { + "none" => FieldRedactionKind::None, + "redact" => FieldRedactionKind::Redact, + "hash" => FieldRedactionKind::Hash, + "last4" | "last_four" => FieldRedactionKind::Last4, + other => { + return Err(Error::new( + ident.span(), + format!("unknown redact policy `{other}` in fields(...)") + )); + } + } + } else { + FieldRedactionKind::Redact + }; + fields.push(FieldRedactionSpec { + name, + policy + }); + + if inner.peek(Token![,]) { + inner.parse::()?; + } else if !inner.is_empty() { + return Err(Error::new( + inner.span(), + "expected `,` or end of input in redact(fields(...))" + )); + } + } + + Ok(fields) +} + +fn parse_telemetry_block(input: ParseStream, span: Span) -> Result, Error> { + let content; + syn::parenthesized!(content in input); + + let mut entries = Vec::new(); + + while !content.is_empty() { + let expr: Expr = content.parse()?; + entries.push(expr); + + if content.peek(Token![,]) { + content.parse::()?; + if content.is_empty() { + return Err(Error::new( + span, + "expected expression after comma in telemetry(...)" + )); + } + } else if !content.is_empty() { + return Err(Error::new( + content.span(), + "expected `,` or end of input in telemetry(...)" + )); + } + } + + Ok(entries) +} + fn parse_error_attribute(attr: &Attribute) -> Result { mod kw { syn::custom_keyword!(transparent); @@ -1240,6 +1593,19 @@ pub(crate) fn option_inner_type(ty: &syn::Type) -> Option<&syn::Type> { }) } +pub(crate) fn is_arc_type(ty: &syn::Type) -> bool { + let syn::Type::Path(path) = ty else { + return false; + }; + if path.qself.is_some() { + return false; + } + path.path + .segments + .last() + .is_some_and(|segment| segment.ident == "Arc") +} + pub(crate) fn is_backtrace_type(ty: &syn::Type) -> bool { let syn::Type::Path(path) = ty else { return false; diff --git a/masterror-derive/src/lib.rs b/masterror-derive/src/lib.rs index cffacd7..ded7de6 100644 --- a/masterror-derive/src/lib.rs +++ b/masterror-derive/src/lib.rs @@ -8,6 +8,7 @@ mod display; mod error_trait; mod from_impl; mod input; +mod masterror_impl; mod span; mod template_support; @@ -24,6 +25,18 @@ pub fn derive_error(tokens: TokenStream) -> TokenStream { } } +#[proc_macro_derive( + Masterror, + attributes(error, source, from, backtrace, masterror, provide) +)] +pub fn derive_masterror(tokens: TokenStream) -> TokenStream { + let input = parse_macro_input!(tokens as DeriveInput); + match expand_masterror(input) { + Ok(stream) => stream.into(), + Err(err) => err.to_compile_error().into() + } +} + fn expand(input: DeriveInput) -> Result { let parsed = input::parse_input(input)?; let display_impl = display::expand(&parsed)?; @@ -38,3 +51,18 @@ fn expand(input: DeriveInput) -> Result { #(#app_error_impls)* }) } + +fn expand_masterror(input: DeriveInput) -> Result { + let parsed = input::parse_input(input)?; + let display_impl = display::expand(&parsed)?; + let error_impl = error_trait::expand(&parsed)?; + let from_impls = from_impl::expand(&parsed)?; + let masterror_impl = masterror_impl::expand(&parsed)?; + + Ok(quote! { + #display_impl + #error_impl + #(#from_impls)* + #masterror_impl + }) +} diff --git a/masterror-derive/src/masterror_impl.rs b/masterror-derive/src/masterror_impl.rs new file mode 100644 index 0000000..1a91dc0 --- /dev/null +++ b/masterror-derive/src/masterror_impl.rs @@ -0,0 +1,545 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::{Error, Expr, ExprPath, Index}; + +use crate::input::{ + ErrorData, ErrorInput, Field, FieldRedactionKind, Fields, MasterrorSpec, RedactSpec, + StructData, VariantData, is_arc_type, is_option_type, option_inner_type +}; + +pub fn expand(input: &ErrorInput) -> Result { + match &input.data { + ErrorData::Struct(data) => expand_struct(input, data), + ErrorData::Enum(variants) => expand_enum(input, variants) + } +} + +fn expand_struct(input: &ErrorInput, data: &StructData) -> Result { + let spec = data.masterror.as_ref().ok_or_else(|| { + Error::new( + input.ident.span(), + "#[derive(Masterror)] requires #[masterror(...)] on structs" + ) + })?; + + let conversion = struct_conversion_impl(input, data, spec); + let mappings = struct_mapping_impl(input, spec); + + Ok(quote! { + #conversion + #mappings + }) +} + +fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result { + ensure_all_variants_have_masterror(variants)?; + + let conversion = enum_conversion_impl(input, variants); + let mappings = enum_mapping_impl(input, variants); + + Ok(quote! { + #conversion + #mappings + }) +} + +fn ensure_all_variants_have_masterror(variants: &[VariantData]) -> Result<(), Error> { + for variant in variants { + if variant.masterror.is_none() { + return Err(Error::new( + variant.span, + "all variants must use #[masterror(...)] to derive masterror::Error conversion" + )); + } + } + Ok(()) +} + +struct BoundField<'a> { + field: &'a Field, + binding: Ident +} + +fn struct_conversion_impl( + input: &ErrorInput, + data: &StructData, + spec: &MasterrorSpec +) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let code = &spec.code; + let category = &spec.category; + + let message_init = message_initialization(spec.expose_message, quote!(&value)); + let (destructure, bound_fields) = bind_struct_fields(ident, &data.fields); + let field_usage = field_usage_tokens(&bound_fields); + let telemetry_init = telemetry_initialization(&spec.telemetry); + let metadata_attach = metadata_attach_tokens(); + let redact_tokens = redact_tokens(&spec.redact); + let source_tokens = source_attachment_tokens(&bound_fields); + let backtrace_tokens = backtrace_attachment_tokens(&data.fields, &bound_fields); + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::Error #where_clause { + fn from(value: #ident #ty_generics) -> Self { + #message_init + #destructure + #field_usage + #telemetry_init + let mut __masterror_error = match __masterror_message { + Some(message) => masterror::Error::with((#category), message), + None => masterror::Error::bare((#category)) + }; + __masterror_error = __masterror_error.with_code((#code)); + #metadata_attach + #redact_tokens + #source_tokens + #backtrace_tokens + __masterror_error + } + } + } +} + +fn enum_conversion_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let mut arms = Vec::new(); + + let mut message_arms = Vec::new(); + + for variant in variants { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + let (pattern, bound_fields) = bind_variant_fields(ident, variant); + let field_usage = field_usage_tokens(&bound_fields); + let telemetry_init = telemetry_initialization(&spec.telemetry); + let metadata_attach = metadata_attach_tokens(); + let redact_tokens = redact_tokens(&spec.redact); + let source_tokens = source_attachment_tokens(&bound_fields); + let backtrace_tokens = backtrace_attachment_tokens(&variant.fields, &bound_fields); + message_arms.push(enum_message_arm(ident, variant, spec.expose_message)); + + arms.push(quote! { + #pattern => { + #field_usage + #telemetry_init + let mut __masterror_error = match __masterror_message { + Some(message) => masterror::Error::with((#category), message), + None => masterror::Error::bare((#category)) + }; + __masterror_error = __masterror_error.with_code((#code)); + #metadata_attach + #redact_tokens + #source_tokens + #backtrace_tokens + __masterror_error + } + }); + } + + let message_match = quote! { + let __masterror_message: Option = match &value { + #(#message_arms)* + }; + }; + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::Error #where_clause { + fn from(value: #ident #ty_generics) -> Self { + #message_match + match value { + #(#arms),* + } + } + } + } +} + +fn enum_message_arm( + enum_ident: &Ident, + variant: &VariantData, + expose_message: bool +) -> TokenStream { + if expose_message { + let binding = format_ident!("__masterror_variant_ref"); + let pattern = enum_message_pattern(enum_ident, variant, Some(&binding)); + quote! { + #pattern => Some(std::string::ToString::to_string(#binding)), + } + } else { + let pattern = enum_message_pattern(enum_ident, variant, None); + quote! { + #pattern => None, + } + } +} + +fn enum_message_pattern( + enum_ident: &Ident, + variant: &VariantData, + binding: Option<&Ident> +) -> TokenStream { + let variant_ident = &variant.ident; + match (&variant.fields, binding) { + (Fields::Unit, Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident), + (Fields::Unit, None) => quote!(#enum_ident::#variant_ident), + (Fields::Named(_), Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident { .. }), + (Fields::Named(_), None) => quote!(#enum_ident::#variant_ident { .. }), + (Fields::Unnamed(_), Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident(..)), + (Fields::Unnamed(_), None) => quote!(#enum_ident::#variant_ident(..)) + } +} + +fn field_usage_tokens(bound_fields: &[BoundField<'_>]) -> TokenStream { + if bound_fields.is_empty() { + return TokenStream::new(); + } + + let names = bound_fields.iter().map(|field| &field.binding); + quote! { + let _ = (#(&#names),*); + } +} + +fn struct_mapping_impl(input: &ErrorInput, spec: &MasterrorSpec) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let code = &spec.code; + let category = &spec.category; + let grpc_mapping = + mapping_option_tokens(spec.map_grpc.as_ref(), code, category, MappingKind::Grpc); + let problem_mapping = mapping_option_tokens( + spec.map_problem.as_ref(), + code, + category, + MappingKind::Problem + ); + + quote! { + impl #impl_generics #ident #ty_generics #where_clause { + /// HTTP mapping for this error type. + pub const HTTP_MAPPING: masterror::mapping::HttpMapping = + masterror::mapping::HttpMapping::new((#code), (#category)); + + /// gRPC mapping for this error type. + pub const GRPC_MAPPING: Option = #grpc_mapping; + + /// Problem JSON mapping for this error type. + pub const PROBLEM_MAPPING: Option = #problem_mapping; + } + } +} + +fn enum_mapping_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let http_entries: Vec<_> = variants + .iter() + .map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + quote!(masterror::mapping::HttpMapping::new((#code), (#category))) + }) + .collect(); + + let grpc_entries: Vec<_> = variants + .iter() + .filter_map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + spec.map_grpc.as_ref().map( + |expr| quote!(masterror::mapping::GrpcMapping::new((#code), (#category), (#expr))) + ) + }) + .collect(); + + let problem_entries: Vec<_> = variants + .iter() + .filter_map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + spec.map_problem.as_ref().map(|expr| { + quote!(masterror::mapping::ProblemMapping::new((#code), (#category), (#expr))) + }) + }) + .collect(); + + let http_len = Index::from(http_entries.len()); + + let grpc_slice = if grpc_entries.is_empty() { + quote!(&[] as &[masterror::mapping::GrpcMapping]) + } else { + quote!(&[#(#grpc_entries),*]) + }; + + let problem_slice = if problem_entries.is_empty() { + quote!(&[] as &[masterror::mapping::ProblemMapping]) + } else { + quote!(&[#(#problem_entries),*]) + }; + + quote! { + impl #impl_generics #ident #ty_generics #where_clause { + /// HTTP mappings for enum variants. + pub const HTTP_MAPPINGS: [masterror::mapping::HttpMapping; #http_len] = [#(#http_entries),*]; + + /// gRPC mappings for enum variants. + pub const GRPC_MAPPINGS: &'static [masterror::mapping::GrpcMapping] = #grpc_slice; + + /// Problem JSON mappings for enum variants. + pub const PROBLEM_MAPPINGS: &'static [masterror::mapping::ProblemMapping] = #problem_slice; + } + } +} + +fn message_initialization(enabled: bool, value: TokenStream) -> TokenStream { + if enabled { + quote! { + let __masterror_message = Some(std::string::ToString::to_string(#value)); + } + } else { + quote! { + let __masterror_message: Option = None; + } + } +} + +fn bind_struct_fields<'a>( + ident: &Ident, + fields: &'a Fields +) -> (TokenStream, Vec>) { + match fields { + Fields::Unit => (quote!(let _ = value;), Vec::new()), + Fields::Named(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + let pattern_tokens = quote!(let #ident { #(#pattern),* } = value;); + (pattern_tokens, bound) + } + Fields::Unnamed(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + let pattern_tokens = quote!(let #ident(#(#pattern),*) = value;); + (pattern_tokens, bound) + } + } +} + +fn bind_variant_fields<'a>( + enum_ident: &Ident, + variant: &'a VariantData +) -> (TokenStream, Vec>) { + let variant_ident = &variant.ident; + + match &variant.fields { + Fields::Unit => (quote!(#enum_ident::#variant_ident), Vec::new()), + Fields::Named(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + (quote!(#enum_ident::#variant_ident { #(#pattern),* }), bound) + } + Fields::Unnamed(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + (quote!(#enum_ident::#variant_ident(#(#pattern),*)), bound) + } + } +} + +fn telemetry_initialization(entries: &[Expr]) -> TokenStream { + if entries.is_empty() { + quote!(let __masterror_metadata: Option = None;) + } else { + let inserts = entries.iter().map(|expr| { + quote! { + if let Some(field) = (#expr) { + __masterror_metadata_inner.insert(field); + } + } + }); + quote! { + let mut __masterror_metadata_inner = masterror::Metadata::new(); + #(#inserts)* + let __masterror_metadata = if __masterror_metadata_inner.is_empty() { + None + } else { + Some(__masterror_metadata_inner) + }; + } + } +} + +fn metadata_attach_tokens() -> TokenStream { + quote! { + if let Some(metadata) = __masterror_metadata { + __masterror_error = __masterror_error.with_metadata(metadata); + } + } +} + +fn redact_tokens(spec: &RedactSpec) -> TokenStream { + let message = if spec.message { + quote!( + __masterror_error = __masterror_error.redactable(); + ) + } else { + TokenStream::new() + }; + + let field_updates = spec.fields.iter().map(|field| { + let name = &field.name; + let policy = field_redaction_tokens(field.policy); + quote!( + __masterror_error = __masterror_error.redact_field(#name, #policy); + ) + }); + + quote! { + #message + #( #field_updates )* + } +} + +fn field_redaction_tokens(kind: FieldRedactionKind) -> TokenStream { + match kind { + FieldRedactionKind::None => quote!(masterror::FieldRedaction::None), + FieldRedactionKind::Redact => quote!(masterror::FieldRedaction::Redact), + FieldRedactionKind::Hash => quote!(masterror::FieldRedaction::Hash), + FieldRedactionKind::Last4 => quote!(masterror::FieldRedaction::Last4) + } +} + +fn source_attachment_tokens(bound_fields: &[BoundField<'_>]) -> TokenStream { + for bound in bound_fields { + if bound.field.attrs.has_source() { + let binding = &bound.binding; + let ty = &bound.field.ty; + if is_option_type(ty) { + let arc_inner = option_inner_type(ty).is_some_and(is_arc_type); + if arc_inner { + return quote! { + if let Some(source) = #binding { + __masterror_error = __masterror_error.with_source_arc(source); + } + }; + } + return quote! { + if let Some(source) = #binding { + __masterror_error = __masterror_error.with_source(source); + } + }; + } else { + if is_arc_type(ty) { + return quote! { + __masterror_error = __masterror_error.with_source_arc(#binding); + }; + } + return quote! { + __masterror_error = __masterror_error.with_source(#binding); + }; + } + } + } + TokenStream::new() +} + +fn backtrace_attachment_tokens(fields: &Fields, bound_fields: &[BoundField<'_>]) -> TokenStream { + let Some(backtrace_field) = fields.backtrace_field() else { + return TokenStream::new(); + }; + let index = backtrace_field.index(); + let Some(binding) = bound_fields + .iter() + .find(|bound| bound.field.index == index) + .map(|bound| &bound.binding) + else { + return TokenStream::new(); + }; + + if is_option_type(&backtrace_field.field().ty) { + quote! { + if let Some(trace) = #binding { + __masterror_error = __masterror_error.with_backtrace(trace); + } + } + } else { + quote! { + __masterror_error = __masterror_error.with_backtrace(#binding); + } + } +} + +#[derive(Clone, Copy)] +enum MappingKind { + Grpc, + Problem +} + +fn mapping_option_tokens( + expr: Option<&Expr>, + code: &Expr, + category: &ExprPath, + kind: MappingKind +) -> TokenStream { + match expr { + Some(value) => match kind { + MappingKind::Grpc => { + quote!(Some(masterror::mapping::GrpcMapping::new((#code), (#category), (#value)))) + } + MappingKind::Problem => { + quote!(Some(masterror::mapping::ProblemMapping::new((#code), (#category), (#value)))) + } + }, + None => quote!(None) + } +} + +fn binding_ident(field: &Field) -> Ident { + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("__field{}", field.index, span = field.span)) +} diff --git a/src/app_error.rs b/src/app_error.rs index d76600f..d952cc0 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -11,6 +11,8 @@ //! [`AppErrorKind`]. //! - **Optional message:** human-readable, safe-to-expose text. Do not put //! secrets here. +//! - **Structured metadata:** attach typed key/value pairs for diagnostics via +//! [`Metadata`]. //! - **No panics:** all helpers avoid `unwrap/expect`. //! - **Transport-agnostic:** mapping to HTTP lives in `kind.rs` and //! `convert/*`. @@ -51,16 +53,26 @@ //! } //! ``` //! -//! ## Logging +//! ## Telemetry //! -//! [`AppError::log`] emits a single structured `tracing::error!` event with -//! `kind`, `code` and optional `message` fields. Prefer calling it at the -//! transport boundary (e.g. in `IntoResponse`) to avoid duplicate logs. +//! [`AppError::log`] flushes telemetry once: it emits a structured `tracing` +//! event (when the `tracing` feature is enabled), increments the +//! `error_total{code,category}` counter (with the `metrics` feature) and +//! captures a lazy [`Backtrace`] snapshot (with the `backtrace` feature). +//! Constructors and framework integrations call it automatically, so manual +//! usage is rarely required. mod constructors; +mod context; mod core; +mod metadata; -pub use core::{AppError, AppResult}; +pub use core::{AppError, AppResult, Error, MessageEditPolicy}; +#[cfg(all(test, feature = "backtrace"))] +pub(crate) use core::{reset_backtrace_preference, set_backtrace_preference_override}; + +pub use context::Context; +pub use metadata::{Field, FieldRedaction, FieldValue, Metadata, field}; #[cfg(test)] mod tests; diff --git a/src/app_error/constructors.rs b/src/app_error/constructors.rs index c95d421..30abece 100644 --- a/src/app_error/constructors.rs +++ b/src/app_error/constructors.rs @@ -62,12 +62,9 @@ impl AppError { /// assert!(err.message.is_none()); /// ``` pub fn database(msg: Option>) -> Self { - Self { - kind: AppErrorKind::Database, - message: msg, - retry: None, - www_authenticate: None - } + let err = Self::new_raw(AppErrorKind::Database, msg); + err.emit_telemetry(); + err } /// Build a `Database` error with a message. diff --git a/src/app_error/context.rs b/src/app_error/context.rs new file mode 100644 index 0000000..a3ad334 --- /dev/null +++ b/src/app_error/context.rs @@ -0,0 +1,198 @@ +use std::{error::Error as StdError, panic::Location}; + +use super::{ + core::{AppError, Error, MessageEditPolicy}, + metadata::{Field, FieldRedaction, FieldValue} +}; +use crate::{AppCode, AppErrorKind}; + +/// Builder describing how to convert an external error into [`AppError`]. +/// +/// The context captures the target [`AppCode`], [`AppErrorKind`], optional +/// metadata fields and redaction policy. It is primarily consumed by +/// [`ResultExt`](crate::ResultExt) when promoting `Result` values into +/// [`AppError`]. +/// +/// # Examples +/// +/// ```rust +/// use std::io::{Error as IoError, ErrorKind}; +/// +/// use masterror::{AppErrorKind, Context, ResultExt, field}; +/// +/// fn failing_io() -> Result<(), IoError> { +/// Err(IoError::from(ErrorKind::Other)) +/// } +/// +/// let err = failing_io() +/// .ctx(|| { +/// Context::new(AppErrorKind::Service) +/// .with(field::str("operation", "sync")) +/// .redact(true) +/// .track_caller() +/// }) +/// .unwrap_err(); +/// +/// assert_eq!(err.kind, AppErrorKind::Service); +/// assert!(err.metadata().get("operation").is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Context { + code: AppCode, + category: AppErrorKind, + fields: Vec, + field_policies: Vec<(&'static str, FieldRedaction)>, + edit_policy: MessageEditPolicy, + caller_location: Option<&'static Location<'static>>, + code_overridden: bool +} + +impl Context { + /// Create a new [`Context`] targeting the provided [`AppErrorKind`]. + /// + /// The initial [`AppCode`] defaults to the canonical mapping for the + /// supplied kind. Use [`Context::code`] to override it. + #[must_use] + pub fn new(category: AppErrorKind) -> Self { + Self { + code: AppCode::from(category), + category, + fields: Vec::new(), + field_policies: Vec::new(), + edit_policy: MessageEditPolicy::Preserve, + caller_location: None, + code_overridden: false + } + } + + /// Override the public [`AppCode`]. + #[must_use] + pub fn code(mut self, code: AppCode) -> Self { + self.code = code; + self.code_overridden = true; + self + } + + /// Update the [`AppErrorKind`]. + /// + /// When the code has not been overridden explicitly, it is kept in sync + /// with the new kind. + #[must_use] + pub fn category(mut self, category: AppErrorKind) -> Self { + self.category = category; + if !self.code_overridden { + self.code = AppCode::from(category); + } + self + } + + /// Attach a metadata [`Field`]. + #[must_use] + pub fn with(mut self, field: Field) -> Self { + self.fields.push(field); + self.apply_field_redactions(); + self + } + + /// Override the redaction policy for a metadata field. + #[must_use] + pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { + self.set_field_policy(name, redaction); + self + } + + /// Override the redaction policy for a metadata field in place. + #[must_use] + pub fn redact_field_mut( + &mut self, + name: &'static str, + redaction: FieldRedaction + ) -> &mut Self { + self.set_field_policy(name, redaction); + self + } + + /// Toggle message redaction policy. + #[must_use] + pub fn redact(mut self, redact: bool) -> Self { + self.edit_policy = if redact { + MessageEditPolicy::Redact + } else { + MessageEditPolicy::Preserve + }; + self + } + + /// Capture caller location and store it as metadata. + #[must_use] + #[track_caller] + pub fn track_caller(mut self) -> Self { + self.caller_location = Some(Location::caller()); + self + } + + pub(crate) fn into_error(mut self, source: E) -> Error + where + E: StdError + Send + Sync + 'static + { + if let Some(location) = self.caller_location { + self.fields.push(Field::new( + "caller.file", + FieldValue::Str(location.file().into()) + )); + self.fields.push(Field::new( + "caller.line", + FieldValue::U64(u64::from(location.line())) + )); + self.fields.push(Field::new( + "caller.column", + FieldValue::U64(u64::from(location.column())) + )); + } + + let mut error = AppError::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); + } + if matches!(self.edit_policy, MessageEditPolicy::Redact) { + error.edit_policy = MessageEditPolicy::Redact; + } + let error = error.with_source(source); + error.emit_telemetry(); + error + } +} + +impl Context { + fn apply_field_redactions(&mut self) { + if self.field_policies.is_empty() { + return; + } + for field in &mut self.fields { + if let Some((_, policy)) = self + .field_policies + .iter() + .rev() + .find(|(name, _)| *name == field.name()) + { + field.set_redaction(*policy); + } + } + } + + fn set_field_policy(&mut self, name: &'static str, redaction: FieldRedaction) { + self.field_policies + .retain(|(existing, _)| *existing != name); + self.field_policies.push((name, redaction)); + for field in &mut self.fields { + if field.name() == name { + field.set_redaction(redaction); + } + } + } +} diff --git a/src/app_error/core.rs b/src/app_error/core.rs index b6863a1..3c791b9 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -1,36 +1,206 @@ -use std::borrow::Cow; +#[cfg(feature = "backtrace")] +use std::{ + backtrace::Backtrace, + env, + sync::{ + OnceLock, + 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} + } +}; -use tracing::error; +#[cfg(feature = "tracing")] +use tracing::{Level, event}; -use crate::{Error, RetryAdvice, code::AppCode, kind::AppErrorKind}; +use super::metadata::{Field, FieldRedaction, Metadata}; +use crate::{AppCode, AppErrorKind, RetryAdvice}; -/// Thin error wrapper: kind + optional message. -/// -/// `Display` prints only the `kind`. The optional `message` is intended for -/// logs and (when appropriate) public JSON payloads. Keep messages concise and -/// free of sensitive data. -#[derive(Debug, Error)] -#[error("{kind}")] -pub struct AppError { - /// Semantic category of the error. - pub kind: AppErrorKind, +/// Controls whether the public message may be redacted before exposure. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum MessageEditPolicy { + /// Message must be preserved as-is. + #[default] + Preserve, + /// Message may be redacted or replaced at the transport boundary. + Redact +} + +#[derive(Debug)] +#[doc(hidden)] +pub struct ErrorInner { + /// Stable machine-readable error code. + pub code: AppCode, + /// Semantic error category. + pub kind: AppErrorKind, /// Optional, public-friendly message. - pub message: Option>, + pub message: Option>, + /// Structured metadata for telemetry. + pub metadata: Metadata, + /// Policy describing whether the message can be redacted. + pub edit_policy: MessageEditPolicy, /// Optional retry advice rendered as `Retry-After`. - pub retry: Option, + pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. - pub www_authenticate: Option + pub www_authenticate: Option, + pub source: Option>, + #[cfg(feature = "backtrace")] + pub backtrace: Option, + #[cfg(feature = "backtrace")] + pub captured_backtrace: OnceLock>, + telemetry_dirty: AtomicBool +} + +#[cfg(feature = "backtrace")] +const BACKTRACE_STATE_UNSET: u8 = 0; +#[cfg(feature = "backtrace")] +const BACKTRACE_STATE_ENABLED: u8 = 1; +#[cfg(feature = "backtrace")] +const BACKTRACE_STATE_DISABLED: u8 = 2; + +#[cfg(feature = "backtrace")] +static BACKTRACE_STATE: AtomicU8 = AtomicU8::new(BACKTRACE_STATE_UNSET); + +#[cfg(feature = "backtrace")] +fn capture_backtrace_snapshot() -> Option { + if should_capture_backtrace() { + Some(Backtrace::capture()) + } else { + None + } +} + +#[cfg(feature = "backtrace")] +fn should_capture_backtrace() -> bool { + match BACKTRACE_STATE.load(AtomicOrdering::Acquire) { + BACKTRACE_STATE_ENABLED => true, + BACKTRACE_STATE_DISABLED => false, + _ => { + let enabled = detect_backtrace_preference(); + BACKTRACE_STATE.store( + if enabled { + BACKTRACE_STATE_ENABLED + } else { + BACKTRACE_STATE_DISABLED + }, + AtomicOrdering::Release + ); + enabled + } + } +} + +#[cfg(feature = "backtrace")] +fn detect_backtrace_preference() -> bool { + #[cfg(all(test, feature = "backtrace"))] + if let Some(value) = test_backtrace_override::get() { + return value; + } + + match env::var_os("RUST_BACKTRACE") { + None => false, + Some(value) => { + let value = value.to_string_lossy(); + let trimmed = value.trim(); + if trimmed.is_empty() { + return false; + } + let lowered = trimmed.to_ascii_lowercase(); + !(matches!(lowered.as_str(), "0" | "off" | "false")) + } + } +} + +#[cfg(all(test, feature = "backtrace"))] +pub(crate) fn reset_backtrace_preference() { + BACKTRACE_STATE.store(BACKTRACE_STATE_UNSET, AtomicOrdering::Release); + test_backtrace_override::set(None); +} + +#[cfg(all(test, feature = "backtrace"))] +pub(crate) fn set_backtrace_preference_override(value: Option) { + test_backtrace_override::set(value); +} + +#[cfg(all(test, feature = "backtrace"))] +mod test_backtrace_override { + use std::sync::atomic::{AtomicI8, Ordering}; + + const OVERRIDE_UNSET: i8 = -1; + const OVERRIDE_DISABLED: i8 = 0; + const OVERRIDE_ENABLED: i8 = 1; + + static OVERRIDE_STATE: AtomicI8 = AtomicI8::new(OVERRIDE_UNSET); + + pub(super) fn set(value: Option) { + let state = match value { + Some(true) => OVERRIDE_ENABLED, + Some(false) => OVERRIDE_DISABLED, + None => OVERRIDE_UNSET + }; + OVERRIDE_STATE.store(state, Ordering::Release); + } + + pub(super) fn get() -> Option { + match OVERRIDE_STATE.load(Ordering::Acquire) { + OVERRIDE_ENABLED => Some(true), + OVERRIDE_DISABLED => Some(false), + _ => None + } + } +} + +/// Rich application error preserving domain code, taxonomy and metadata. +#[derive(Debug)] +pub struct Error { + inner: Box +} + +impl Deref for Error { + type Target = ErrorInner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for Error { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&self.kind, f) + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn StdError + 'static)) + } } /// Conventional result alias for application code. /// -/// The alias defaults to [`AppError`] but accepts a custom error type when the +/// The alias defaults to [`Error`] but accepts a custom error type when the /// context requires a different domain error. /// /// # Examples /// /// ```rust -/// use std::io::Error; +/// use std::io::Error as IoError; /// /// use masterror::AppResult; /// @@ -38,20 +208,110 @@ pub struct AppError { /// Ok(7) /// } /// -/// fn io_logic() -> AppResult<(), Error> { +/// fn io_logic() -> AppResult<(), IoError> { /// Ok(()) /// } /// /// assert_eq!(app_logic().unwrap(), 7); /// assert!(io_logic().is_ok()); /// ``` -pub type AppResult = Result; +pub type AppResult = Result; + +impl Error { + pub(crate) fn new_raw(kind: AppErrorKind, message: Option>) -> Self { + Self { + inner: Box::new(ErrorInner { + code: AppCode::from(kind), + kind, + message, + metadata: Metadata::new(), + edit_policy: MessageEditPolicy::Preserve, + retry: None, + www_authenticate: None, + source: None, + #[cfg(feature = "backtrace")] + backtrace: None, + #[cfg(feature = "backtrace")] + captured_backtrace: OnceLock::new(), + telemetry_dirty: AtomicBool::new(true) + }) + } + } + + fn mark_dirty(&self) { + self.telemetry_dirty.store(true, Ordering::Release); + } + + fn take_dirty(&self) -> bool { + self.telemetry_dirty.swap(false, Ordering::AcqRel) + } + + #[cfg(feature = "backtrace")] + fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { + if let Some(backtrace) = self.backtrace.as_ref() { + return Some(backtrace); + } + + self.captured_backtrace + .get_or_init(capture_backtrace_snapshot) + .as_ref() + } -impl AppError { - /// Create a new [`AppError`] with a kind and message. + #[cfg(not(feature = "backtrace"))] + fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { + None + } + + #[cfg(feature = "backtrace")] + fn set_backtrace_slot(&mut self, backtrace: std::backtrace::Backtrace) { + self.backtrace = Some(backtrace); + self.captured_backtrace = OnceLock::new(); + } + + #[cfg(not(feature = "backtrace"))] + fn set_backtrace_slot(&mut self, _backtrace: std::backtrace::Backtrace) {} + + pub(crate) fn emit_telemetry(&self) { + if self.take_dirty() { + #[cfg(feature = "backtrace")] + let _ = self.capture_backtrace(); + + #[cfg(feature = "metrics")] + { + metrics::counter!( + "error_total", + "code" => self.code.as_str(), + "category" => kind_label(self.kind) + ) + .increment(1); + } + + #[cfg(feature = "tracing")] + { + let message = self.message.as_deref(); + let retry_seconds = self.retry.map(|value| value.after_seconds); + let trace_id = log_mdc::get("trace_id", |value| value.map(str::to_owned)); + event!( + target: "masterror::error", + Level::ERROR, + code = self.code.as_str(), + category = kind_label(self.kind), + message = message, + retry_seconds, + redactable = matches!(self.edit_policy, MessageEditPolicy::Redact), + metadata_len = self.metadata.len() as u64, + www_authenticate = self.www_authenticate.as_deref(), + trace_id = trace_id.as_deref(), + "app error constructed" + ); + } + } + } + + /// Create a new [`Error`] with a kind and message. /// - /// This is equivalent to [`AppError::with`], provided for API symmetry and - /// to keep doctests readable. + /// This is equivalent to [`Error::with`], provided for API symmetry and to + /// keep doctests readable. /// /// # Examples /// @@ -60,33 +320,38 @@ impl AppError { /// let err = AppError::new(AppErrorKind::BadRequest, "invalid payload"); /// assert!(err.message.is_some()); /// ``` + #[must_use] pub fn new(kind: AppErrorKind, msg: impl Into>) -> Self { Self::with(kind, msg) } /// Create an error with the given kind and message. /// - /// Prefer named helpers (e.g. [`AppError::not_found`]) where it clarifies + /// Prefer named helpers (e.g. [`Error::not_found`]) where it clarifies /// intent. + #[must_use] pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { - Self { - kind, - message: Some(msg.into()), - retry: None, - www_authenticate: None - } + let err = Self::new_raw(kind, Some(msg.into())); + err.emit_telemetry(); + err } /// Create a message-less error with the given kind. /// /// Useful when the kind alone conveys sufficient information to the client. + #[must_use] pub fn bare(kind: AppErrorKind) -> Self { - Self { - kind, - message: None, - retry: None, - www_authenticate: None - } + let err = Self::new_raw(kind, None); + err.emit_telemetry(); + err + } + + /// Override the machine-readable [`AppCode`]. + #[must_use] + pub fn with_code(mut self, code: AppCode) -> Self { + self.code = code; + self.mark_dirty(); + self } /// Attach retry advice to the error. @@ -97,6 +362,7 @@ impl AppError { self.retry = Some(RetryAdvice { after_seconds: secs }); + self.mark_dirty(); self } @@ -104,27 +370,152 @@ impl AppError { #[must_use] pub fn with_www_authenticate(mut self, value: impl Into) -> Self { self.www_authenticate = Some(value.into()); + self.mark_dirty(); self } - /// Log the error once at the boundary with stable fields. - /// - /// Emits a `tracing::error!` with `kind`, `code` and optional `message`. - /// No internals or sources are leaked. + /// Attach additional metadata to the error. + #[must_use] + pub fn with_field(mut self, field: Field) -> Self { + self.metadata.insert(field); + self.mark_dirty(); + self + } + + /// Extend metadata from an iterator of fields. + #[must_use] + pub fn with_fields(mut self, fields: impl IntoIterator) -> Self { + self.metadata.extend(fields); + self.mark_dirty(); + self + } + + /// Override the redaction policy for a stored metadata field. + #[must_use] + pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { + self.metadata.set_redaction(name, redaction); + self.mark_dirty(); + self + } + + /// Replace metadata entirely. + #[must_use] + pub fn with_metadata(mut self, metadata: Metadata) -> Self { + self.metadata = metadata; + self.mark_dirty(); + self + } + + /// Mark the message as redactable. + #[must_use] + pub fn redactable(mut self) -> Self { + self.edit_policy = MessageEditPolicy::Redact; + self.mark_dirty(); + self + } + + /// Attach a source error for diagnostics. + #[must_use] + pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { + self.source = Some(Arc::new(source)); + self.mark_dirty(); + self + } + + /// Attach a shared source error without cloning the underlying `Arc`. /// /// # Examples /// /// ```rust + /// use std::sync::Arc; + /// /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::internal("boom"); - /// // In production, call this at the boundary (e.g. HTTP mapping). - /// err.log(); + /// + /// let source = Arc::new(std::io::Error::new(std::io::ErrorKind::Other, "boom")); + /// let err = AppError::internal("boom").with_source_arc(source.clone()); + /// assert!(err.source_ref().is_some()); + /// assert_eq!(Arc::strong_count(&source), 2); /// ``` - pub fn log(&self) { - let code = AppCode::from(self.kind); + #[must_use] + pub fn with_source_arc(mut self, source: Arc) -> Self { + self.source = Some(source); + self.mark_dirty(); + self + } + + /// Attach a captured backtrace. + #[must_use] + pub fn with_backtrace(mut self, backtrace: std::backtrace::Backtrace) -> Self { + self.set_backtrace_slot(backtrace); + self.mark_dirty(); + self + } + + /// Borrow the attached metadata. + #[must_use] + pub fn metadata(&self) -> &Metadata { + &self.metadata + } + + /// Borrow the backtrace, capturing it lazily when the `backtrace` feature + /// is enabled. + #[must_use] + pub fn backtrace(&self) -> Option<&std::backtrace::Backtrace> { + self.capture_backtrace() + } + + /// Borrow the source if present. + #[must_use] + pub fn source_ref(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> { + self.source.as_deref() + } + + /// Human-readable message or the kind fallback. + #[must_use] + pub fn render_message(&self) -> Cow<'_, str> { match &self.message { - Some(m) => error!(kind = ?self.kind, code = %code, message = %m), - None => error!(kind = ?self.kind, code = %code) + Some(msg) => Cow::Borrowed(msg.as_ref()), + None => Cow::Owned(self.kind.to_string()) } } + + /// Emit telemetry (`tracing` event, metrics counter, backtrace capture). + /// + /// Downstream code can call this to guarantee telemetry after mutating the + /// error. It is automatically invoked by constructors and conversions. + pub fn log(&self) { + self.emit_telemetry(); + } +} + +/// Backwards-compatible export using the historical name. +pub use Error as AppError; + +#[cfg(any(feature = "metrics", feature = "tracing"))] +fn kind_label(kind: AppErrorKind) -> &'static str { + match kind { + AppErrorKind::NotFound => "NotFound", + AppErrorKind::Validation => "Validation", + AppErrorKind::Conflict => "Conflict", + AppErrorKind::Unauthorized => "Unauthorized", + AppErrorKind::Forbidden => "Forbidden", + AppErrorKind::NotImplemented => "NotImplemented", + AppErrorKind::Internal => "Internal", + AppErrorKind::BadRequest => "BadRequest", + AppErrorKind::TelegramAuth => "TelegramAuth", + AppErrorKind::InvalidJwt => "InvalidJwt", + AppErrorKind::Database => "Database", + AppErrorKind::Service => "Service", + AppErrorKind::Config => "Config", + AppErrorKind::Turnkey => "Turnkey", + AppErrorKind::Timeout => "Timeout", + AppErrorKind::Network => "Network", + AppErrorKind::RateLimited => "RateLimited", + AppErrorKind::DependencyUnavailable => "DependencyUnavailable", + AppErrorKind::Serialization => "Serialization", + AppErrorKind::Deserialization => "Deserialization", + AppErrorKind::ExternalApi => "ExternalApi", + AppErrorKind::Queue => "Queue", + AppErrorKind::Cache => "Cache" + } } diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs new file mode 100644 index 0000000..0aa8267 --- /dev/null +++ b/src/app_error/metadata.rs @@ -0,0 +1,392 @@ +use std::{ + borrow::Cow, + collections::BTreeMap, + fmt::{Display, Formatter, Result as FmtResult} +}; + +/// Redaction policy associated with a metadata [`Field`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum FieldRedaction { + /// Preserve the value as-is. + #[default] + None, + /// Remove the value from public payloads. + Redact, + /// Hash the value with a cryptographic digest before exposure. + Hash, + /// Preserve only the last four characters (mask the rest). + Last4 +} + +use uuid::Uuid; + +/// Value stored inside [`Metadata`]. +/// +/// The enum keeps the most common telemetry-friendly primitives without forcing +/// callers to allocate temporary strings. Strings use [`Cow`] so `'static` +/// literals avoid allocation while owned [`String`]s are supported when +/// necessary. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldValue { + /// Human-readable string. + Str(Cow<'static, str>), + /// Signed 64-bit integer. + I64(i64), + /// Unsigned 64-bit integer. + U64(u64), + /// Boolean flag. + Bool(bool), + /// UUID represented with the canonical binary type. + Uuid(Uuid) +} + +impl Display for FieldValue { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Self::Str(value) => Display::fmt(value, f), + Self::I64(value) => Display::fmt(value, f), + Self::U64(value) => Display::fmt(value, f), + Self::Bool(value) => Display::fmt(value, f), + Self::Uuid(value) => Display::fmt(value, f) + } + } +} + +/// Single metadata field – name plus value. +#[derive(Clone, Debug, PartialEq)] +pub struct Field { + name: &'static str, + value: FieldValue, + redaction: FieldRedaction +} + +impl Field { + /// Create a new [`Field`]. + #[must_use] + pub fn new(name: &'static str, value: FieldValue) -> Self { + let redaction = infer_default_redaction(name); + Self { + name, + value, + redaction + } + } + + /// Field name. + #[must_use] + pub const fn name(&self) -> &'static str { + self.name + } + + /// Field value. + #[must_use] + pub const fn value(&self) -> &FieldValue { + &self.value + } + + /// Field redaction policy. + #[must_use] + pub const fn redaction(&self) -> FieldRedaction { + self.redaction + } + + /// Override the redaction policy while consuming the field. + #[must_use] + pub fn with_redaction(mut self, redaction: FieldRedaction) -> Self { + self.redaction = redaction; + self + } + + /// Update the redaction policy in place. + pub fn set_redaction(&mut self, redaction: FieldRedaction) { + self.redaction = redaction; + } + + /// Consume the field and return owned components. + #[must_use] + pub fn into_parts(self) -> (&'static str, FieldValue, FieldRedaction) { + (self.name, self.value, self.redaction) + } + + /// Consume the field and return only the value. + #[must_use] + pub fn into_value(self) -> FieldValue { + self.value + } +} + +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") + { + return FieldRedaction::Redact; + } + + let mut card_like = false; + let mut number_like = false; + + for segment in lowered.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("key") + || segment.eq_ignore_ascii_case("access") && lowered.contains("token") + || segment.eq_ignore_ascii_case("refresh") && lowered.contains("token") + { + return FieldRedaction::Hash; + } + + if segment.eq_ignore_ascii_case("card") + || segment.eq_ignore_ascii_case("iban") + || segment.eq_ignore_ascii_case("pan") + || segment.eq_ignore_ascii_case("account") + || segment.eq_ignore_ascii_case("acct") + { + card_like = true; + } + + if segment.eq_ignore_ascii_case("number") + || segment.eq_ignore_ascii_case("no") + || segment.eq_ignore_ascii_case("id") + { + number_like = true; + } + } + + if card_like && number_like { + FieldRedaction::Last4 + } else { + FieldRedaction::None + } +} + +/// Structured metadata attached to [`crate::AppError`]. +/// +/// Internally backed by a deterministic [`BTreeMap`] keyed by `'static` field +/// names. Use the helpers in [`field`] to build [`Field`] values without manual +/// enum construction. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Metadata { + fields: BTreeMap<&'static str, Field> +} + +impl Metadata { + /// Create an empty metadata container. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Build metadata from an iterator of [`Field`] values. + #[must_use] + pub fn from_fields(fields: impl IntoIterator) -> Self { + let mut map = BTreeMap::new(); + for field in fields { + map.insert(field.name, field); + } + Self { + fields: map + } + } + + /// Number of fields stored in the metadata. + #[must_use] + pub fn len(&self) -> usize { + self.fields.len() + } + + /// Whether the metadata is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } + + /// Insert or replace a field and return the previous value. + pub fn insert(&mut self, field: Field) -> Option { + self.fields + .insert(field.name, field) + .map(|previous| previous.into_value()) + } + + /// Extend metadata with additional fields. + pub fn extend(&mut self, fields: impl IntoIterator) { + for field in fields { + self.insert(field); + } + } + + /// Borrow a field value by name. + #[must_use] + pub fn get(&self, name: &'static str) -> Option<&FieldValue> { + self.fields.get(name).map(|field| field.value()) + } + + /// Borrow the full field entry by name. + #[must_use] + pub fn get_field(&self, name: &'static str) -> Option<&Field> { + self.fields.get(name) + } + + /// Override the redaction policy for a specific field. + pub fn set_redaction(&mut self, name: &'static str, redaction: FieldRedaction) { + if let Some(field) = self.fields.get_mut(name) { + field.set_redaction(redaction); + } + } + + /// Retrieve the redaction policy for a field if present. + #[must_use] + pub fn redaction(&self, name: &'static str) -> Option { + self.fields.get(name).map(|field| field.redaction()) + } + + /// Iterator over metadata fields in sorted order. + pub fn iter(&self) -> impl Iterator { + self.fields.iter().map(|(k, v)| (*k, v.value())) + } + + /// Iterator over metadata entries including the redaction policy. + pub fn iter_with_redaction( + &self + ) -> impl Iterator { + self.fields + .iter() + .map(|(name, field)| (*name, field.value(), field.redaction())) + } +} + +impl IntoIterator for Metadata { + type Item = Field; + type IntoIter = std::iter::Map< + std::collections::btree_map::IntoIter<&'static str, Field>, + fn((&'static str, Field)) -> Field + >; + + fn into_iter(self) -> Self::IntoIter { + fn into_field(entry: (&'static str, Field)) -> Field { + entry.1 + } + self.fields + .into_iter() + .map(into_field as fn((&'static str, Field)) -> Field) + } +} + +/// Factories for [`Field`] values. +pub mod field { + use std::borrow::Cow; + + use uuid::Uuid; + + use super::{Field, FieldValue}; + + /// Build a string metadata field. + #[must_use] + pub fn str(name: &'static str, value: impl Into>) -> Field { + Field::new(name, FieldValue::Str(value.into())) + } + + /// Build an `i64` metadata field. + #[must_use] + pub fn i64(name: &'static str, value: i64) -> Field { + Field::new(name, FieldValue::I64(value)) + } + + /// Build a `u64` metadata field. + #[must_use] + pub fn u64(name: &'static str, value: u64) -> Field { + Field::new(name, FieldValue::U64(value)) + } + + /// Build a boolean metadata field. + #[must_use] + pub fn bool(name: &'static str, value: bool) -> Field { + Field::new(name, FieldValue::Bool(value)) + } + + /// Build a UUID metadata field. + #[must_use] + pub fn uuid(name: &'static str, value: Uuid) -> Field { + Field::new(name, FieldValue::Uuid(value)) + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use uuid::Uuid; + + use super::{FieldRedaction, FieldValue, Metadata, field}; + + #[test] + fn metadata_roundtrip() { + let mut meta = Metadata::new(); + meta.insert(field::str("request_id", Cow::Borrowed("abc"))); + meta.insert(field::i64("count", 42)); + + assert_eq!( + meta.get("request_id"), + Some(&FieldValue::Str(Cow::Borrowed("abc"))) + ); + assert_eq!(meta.get("count"), Some(&FieldValue::I64(42))); + assert_eq!(meta.redaction("request_id"), Some(FieldRedaction::None)); + } + + #[test] + fn metadata_from_fields_is_deterministic() { + let uuid = Uuid::nil(); + let meta = + Metadata::from_fields([field::uuid("trace_id", uuid), field::bool("cached", true)]); + let collected: Vec<_> = meta.iter().collect(); + assert_eq!(collected.len(), 2); + assert_eq!(collected[0].0, "cached"); + assert_eq!(collected[1].0, "trace_id"); + } + + #[test] + fn inserting_field_replaces_previous_value() { + let mut meta = Metadata::from_fields([field::i64("count", 1)]); + let replaced = meta.insert(field::i64("count", 2)); + assert_eq!(replaced, Some(FieldValue::I64(1))); + assert_eq!(meta.get("count"), Some(&FieldValue::I64(2))); + } + + #[test] + fn default_redaction_applies_to_common_keys() { + let password = field::str("password", Cow::Borrowed("secret")); + assert!(matches!(password.redaction(), FieldRedaction::Redact)); + + let token = field::str("api_token", Cow::Borrowed("abcdef")); + assert!(matches!(token.redaction(), FieldRedaction::Hash)); + + let card = field::str("card_number", Cow::Borrowed("4111111111111111")); + assert!(matches!(card.redaction(), FieldRedaction::Last4)); + } + + #[test] + fn field_into_parts_returns_components() { + let field = field::u64("elapsed_ms", 30); + let clone = field.clone(); + assert_eq!(clone.name(), field.name()); + assert_eq!(clone.value(), field.value()); + let (owned_name, owned_value, redaction) = clone.into_parts(); + assert_eq!(owned_name, field.name()); + assert_eq!(owned_value, field.value().clone()); + assert_eq!(redaction, field.redaction()); + } +} diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 7d4d6c3..592d911 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -1,7 +1,18 @@ -use std::borrow::Cow; +#[cfg(any(feature = "backtrace", feature = "tracing"))] +use std::sync::Mutex; +use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; -use super::{AppResult, core::AppError}; -use crate::AppErrorKind; +#[cfg(feature = "backtrace")] +use super::core::{reset_backtrace_preference, set_backtrace_preference_override}; + +#[cfg(feature = "backtrace")] +static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); + +#[cfg(feature = "tracing")] +static TELEMETRY_GUARD: Mutex<()> = Mutex::new(()); + +use super::{AppError, FieldRedaction, FieldValue, MessageEditPolicy, field}; +use crate::{AppCode, AppErrorKind}; // --- Helpers ------------------------------------------------------------- @@ -137,6 +148,164 @@ fn retry_and_www_authenticate_are_attached() { assert_eq!(err.www_authenticate.as_deref(), Some("Bearer")); } +#[test] +fn render_message_does_not_allocate_for_borrowed_str() { + let err = AppError::new(AppErrorKind::BadRequest, "borrowed"); + let rendered = err.render_message(); + assert!(matches!(rendered, Cow::Borrowed("borrowed"))); + assert!(std::ptr::eq(rendered.as_ref(), "borrowed")); +} + +#[test] +fn metadata_and_code_are_preserved() { + let err = AppError::service("downstream") + .with_field(field::str("request_id", "abc-123")) + .with_field(field::i64("attempt", 2)) + .with_code(AppCode::Service); + + assert_eq!(err.code, AppCode::Service); + let metadata = err.metadata(); + assert_eq!(metadata.len(), 2); + assert_eq!( + metadata.get("request_id"), + Some(&FieldValue::Str(Cow::Borrowed("abc-123"))) + ); + assert_eq!(metadata.get("attempt"), Some(&FieldValue::I64(2))); +} + +#[test] +fn context_redact_field_overrides_policy() { + let err = super::Context::new(AppErrorKind::Service) + .with(field::str("token", "super-secret")) + .redact_field("token", FieldRedaction::Redact) + .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 context_redact_field_mut_applies_policies() { + let mut context = super::Context::new(AppErrorKind::Service); + let _ = context.redact_field_mut("token", FieldRedaction::Hash); + context = context.with(field::str("token", "super-secret")); + + let err = context.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 app_error_redact_field_updates_metadata() { + let err = AppError::internal("boom") + .with_field(field::str("api_key", "key")) + .redact_field("api_key", FieldRedaction::Hash); + + assert_eq!( + err.metadata().redaction("api_key"), + Some(FieldRedaction::Hash) + ); + assert_eq!( + err.metadata().get("api_key"), + Some(&FieldValue::Str(Cow::Borrowed("key"))) + ); +} + +#[derive(Debug)] +struct DummyError; + +impl Display for DummyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("dummy") + } +} + +impl StdError for DummyError {} + +#[test] +fn source_is_preserved_without_extra_allocation() { + let source = Arc::new(DummyError); + let err = AppError::internal("boom").with_source_arc(source.clone()); + + assert_eq!(Arc::strong_count(&source), 2); + + let stored = err.source_ref().expect("source"); + let stored_dummy = stored + .downcast_ref::() + .expect("dummy should be preserved"); + assert!(std::ptr::eq(stored_dummy, &*source)); +} + +#[test] +fn error_chain_is_preserved() { + #[derive(Debug)] + struct NestedError { + inner: DummyError + } + + impl Display for NestedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.inner.fmt(f) + } + } + + impl StdError for NestedError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.inner) + } + } + + let err = AppError::internal("boom").with_source(NestedError { + inner: DummyError + }); + let top_source = StdError::source(&err).expect("top source"); + assert!(top_source.is::()); + let nested = top_source.source().expect("nested source"); + assert!(nested.is::()); +} + +#[cfg(feature = "backtrace")] +fn with_backtrace_preference(value: Option, test: F) { + let _guard = BACKTRACE_ENV_GUARD.lock().expect("env guard"); + reset_backtrace_preference(); + set_backtrace_preference_override(value); + test(); + set_backtrace_preference_override(None); + reset_backtrace_preference(); +} + +#[cfg(feature = "backtrace")] +#[test] +fn backtrace_respects_disabled_env() { + with_backtrace_preference(Some(false), || { + let err = AppError::internal("boom"); + assert!(err.backtrace().is_none()); + }); +} + +#[cfg(feature = "backtrace")] +#[test] +fn backtrace_enabled_when_env_requests() { + with_backtrace_preference(Some(true), || { + let err = AppError::internal("boom"); + assert!(err.backtrace().is_some()); + }); +} + +#[test] +fn redactable_policy_is_exposed() { + let err = AppError::internal("boom").redactable(); + assert!(matches!(err.edit_policy, MessageEditPolicy::Redact)); +} + #[test] fn log_uses_kind_and_code() { // Smoke test to ensure the method is callable; tracing output isn't asserted @@ -145,16 +314,246 @@ fn log_uses_kind_and_code() { err.log(); } +#[cfg(feature = "tracing")] #[test] -fn result_alias_is_generic() { - fn app() -> AppResult { - Ok(1) +fn telemetry_emits_single_tracing_event_with_trace_id() { + let _guard = TELEMETRY_GUARD.lock().expect("telemetry guard"); + + use std::{ + fmt, + sync::{Arc, Mutex} + }; + + use tracing::{ + Dispatch, Event, Subscriber, dispatcher, + field::{Field, Visit} + }; + use tracing_subscriber::{ + Registry, + layer::{Context, Layer, SubscriberExt} + }; + + #[derive(Default, Clone)] + struct RecordedEvent { + trace_id: Option, + code: Option, + category: Option + } + + struct RecordingLayer { + events: Arc>> + } + + impl Layer for RecordingLayer + where + S: Subscriber + { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + if event.metadata().target() != "masterror::error" { + return; + } + + let mut record = RecordedEvent::default(); + event.record(&mut EventVisitor { + record: &mut record + }); + self.events.lock().expect("events lock").push(record); + } + } + + struct EventVisitor<'a> { + record: &'a mut RecordedEvent + } + + impl<'a> Visit for EventVisitor<'a> { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + let normalized = normalize_debug(value); + match field.name() { + "trace_id" => self.record.trace_id = Some(normalized), + "code" => self.record.code = Some(normalized), + "category" => self.record.category = Some(normalized), + _ => {} + } + } + } + + fn normalize_debug(value: &dyn fmt::Debug) -> String { + let mut rendered = format!("{value:?}"); + while let Some(stripped) = rendered + .strip_prefix("Some(") + .and_then(|s| s.strip_suffix(')')) + { + rendered = stripped.to_owned(); + } + rendered.trim_matches('"').to_owned() + } + + let events = Arc::new(Mutex::new(Vec::new())); + let layer = RecordingLayer { + events: events.clone() + }; + let subscriber = Registry::default().with(layer); + let dispatch = Dispatch::new(subscriber); + + dispatcher::with_default(&dispatch, || { + log_mdc::insert("trace_id", "trace-123"); + let err = AppError::internal("boom"); + err.log(); + log_mdc::remove("trace_id"); + + let events = events.lock().expect("events lock"); + assert_eq!(events.len(), 1, "expected exactly one tracing event"); + + let event = &events[0]; + assert_eq!(event.code.as_deref(), Some(AppCode::Internal.as_str())); + assert_eq!(event.category.as_deref(), Some("Internal")); + assert!( + event + .trace_id + .as_deref() + .is_some_and(|value| value.contains("trace-123")) + ); + }); +} + +#[cfg(feature = "metrics")] +#[test] +fn metrics_counter_is_incremented_once() { + use std::{ + collections::HashMap, + sync::{Arc, Mutex} + }; + + use metrics::{ + Counter, CounterFn, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit + }; + + #[derive(Clone, Debug, Eq, PartialEq, Hash)] + struct CounterKey { + name: String, + labels: Vec<(String, String)> + } + + impl CounterKey { + fn new(name: String, labels: Vec<(String, String)>) -> Self { + Self { + name, + labels + } + } + } + + type CounterMap = HashMap; + type SharedCounterMap = Arc>; + + #[derive(Clone)] + struct MetricsCounterHandle { + key: CounterKey, + counts: SharedCounterMap + } + + impl CounterFn for MetricsCounterHandle { + fn increment(&self, value: u64) { + let mut map = self.counts.lock().expect("counter map"); + *map.entry(self.key.clone()).or_default() += value; + } + + fn absolute(&self, value: u64) { + let mut map = self.counts.lock().expect("counter map"); + map.insert(self.key.clone(), value); + } } - fn other() -> AppResult { - Ok(2) + struct CountingRecorder { + counts: SharedCounterMap } - assert_eq!(app().unwrap(), 1); - assert_eq!(other().unwrap(), 2); + impl Recorder for CountingRecorder { + fn describe_counter( + &self, + _key: KeyName, + _unit: Option, + _description: SharedString + ) { + } + + fn describe_gauge(&self, _key: KeyName, _unit: Option, _description: SharedString) {} + + fn describe_histogram( + &self, + _key: KeyName, + _unit: Option, + _description: SharedString + ) { + } + + fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter { + let labels = key + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + let counter_key = CounterKey::new(key.name().to_owned(), labels); + Counter::from_arc(Arc::new(MetricsCounterHandle { + key: counter_key, + counts: self.counts.clone() + })) + } + + fn register_gauge(&self, _key: &Key, _metadata: &Metadata<'_>) -> Gauge { + Gauge::noop() + } + + fn register_histogram(&self, _key: &Key, _metadata: &Metadata<'_>) -> Histogram { + Histogram::noop() + } + } + + use std::sync::OnceLock; + + static RECORDER_COUNTS: OnceLock = OnceLock::new(); + + let counts = RECORDER_COUNTS + .get_or_init(|| { + let counts = Arc::new(Mutex::new(HashMap::new())); + metrics::set_global_recorder(CountingRecorder { + counts: counts.clone() + }) + .expect("install recorder"); + counts + }) + .clone(); + + counts.lock().expect("counter map").clear(); + + let err = AppError::forbidden("denied"); + err.log(); + + let key = CounterKey::new( + "error_total".to_owned(), + vec![ + ("code".to_owned(), AppCode::Forbidden.as_str().to_owned()), + ("category".to_owned(), "Forbidden".to_owned()), + ] + ); + + let counts = counts.lock().expect("counter map"); + assert_eq!(counts.get(&key).copied(), Some(1)); +} + +#[test] +fn result_alias_is_generic() { + let default_result: super::AppResult = Ok(1); + let custom_result: super::AppResult = Ok(2); + + assert!(matches!(default_result, Ok(value) if value == 1)); + assert!(matches!(custom_result, Ok(value) if value == 2)); +} + +#[test] +fn app_error_fits_result_budget() { + let size = std::mem::size_of::(); + assert!( + size <= 128, + "AppError grew to {size} bytes; keep the Err variant lean" + ); } diff --git a/src/code.rs b/src/code.rs index 8ce7783..b9dc44a 100644 --- a/src/code.rs +++ b/src/code.rs @@ -69,4 +69,4 @@ mod app_code; -pub use app_code::AppCode; +pub use app_code::{AppCode, ParseAppCodeError}; diff --git a/src/code/app_code.rs b/src/code/app_code.rs index 11bf384..9afc1f3 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,4 +1,8 @@ -use std::fmt::{self, Display}; +use std::{ + error::Error as StdError, + fmt::{self, Display}, + str::FromStr +}; use serde::{Deserialize, Serialize}; #[cfg(feature = "openapi")] @@ -6,6 +10,21 @@ use utoipa::ToSchema; 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseAppCodeError; + +impl Display for ParseAppCodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("invalid app code") + } +} + +impl StdError for ParseAppCodeError {} + /// Stable machine-readable error code exposed to clients. /// /// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g., @@ -36,6 +55,11 @@ pub enum AppCode { /// 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**. @@ -149,6 +173,7 @@ impl AppCode { 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", @@ -182,6 +207,59 @@ impl Display for AppCode { } } +/// Parse an [`AppCode`] from its canonical string representation. +/// +/// # Errors +/// +/// Returns [`ParseAppCodeError`] when the input does not match any known code. +/// +/// # Examples +/// ``` +/// use std::str::FromStr; +/// +/// use masterror::{AppCode, ParseAppCodeError}; +/// +/// let code = AppCode::from_str("NOT_FOUND")?; +/// assert_eq!(code, AppCode::NotFound); +/// # Ok::<(), ParseAppCodeError>(()) +/// ``` +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) + } + } +} + impl From for AppCode { /// Map internal taxonomy (`AppErrorKind`) to public machine code /// (`AppCode`). @@ -221,7 +299,9 @@ impl From for AppCode { #[cfg(test)] mod tests { - use super::{AppCode, AppErrorKind}; + use std::str::FromStr; + + use super::{AppCode, AppErrorKind, ParseAppCodeError}; #[test] fn as_str_matches_json_serde_names() { @@ -258,4 +338,24 @@ mod tests { fn display_uses_screaming_snake_case() { assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST"); } + + #[test] + fn from_str_parses_known_codes() { + for code in [ + AppCode::NotFound, + AppCode::Validation, + AppCode::Unauthorized, + AppCode::Internal, + AppCode::Timeout + ] { + let parsed = AppCode::from_str(code.as_str()).expect("parse"); + assert_eq!(parsed, code); + } + } + + #[test] + fn from_str_rejects_unknown_code() { + 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 cd9258b..1dc4966 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -78,6 +78,10 @@ use std::io::Error as IoError; use crate::AppError; +#[cfg(feature = "axum")] +#[cfg_attr(docsrs, doc(cfg(feature = "axum")))] +mod axum; + #[cfg(all(feature = "axum", feature = "multipart"))] #[cfg_attr(docsrs, doc(cfg(all(feature = "axum", feature = "multipart"))))] mod multipart; @@ -122,6 +126,13 @@ mod teloxide; #[cfg_attr(docsrs, doc(cfg(feature = "telegram-webapp-sdk")))] mod telegram_webapp_sdk; +#[cfg(feature = "tonic")] +#[cfg_attr(docsrs, doc(cfg(feature = "tonic")))] +mod tonic; + +#[cfg(feature = "tonic")] +pub use self::tonic::StatusConversionError; + /// Map `std::io::Error` to an internal application error. /// /// Rationale: I/O failures are infrastructure-level and should not leak diff --git a/src/convert/actix.rs b/src/convert/actix.rs index 268e60a..b1c34f7 100644 --- a/src/convert/actix.rs +++ b/src/convert/actix.rs @@ -1,5 +1,4 @@ -//! Actix Web integration: `ResponseError` for [`AppError`] and helper JSON -//! payload. +//! Actix Web integration: `ResponseError` for [`AppError`] and RFC7807 payload. //! //! Enabled with the `actix` feature flag. //! @@ -7,19 +6,23 @@ //! - Implements `actix_web::ResponseError` for [`AppError`]. //! - This lets you `return AppResult<_>` from Actix handlers. //! - On error, Actix automatically builds an `HttpResponse` with the right -//! status code and JSON body (when the `serde_json` feature is enabled). +//! status code and RFC7807 JSON body (when the `serde_json` feature is +//! enabled). //! - Provides stable mapping from [`AppErrorKind`] to //! `actix_web::http::StatusCode`. //! - Ensures that only safe, public-facing fields are returned to the client -//! (`status`, `message`, `details?`). +//! (`type`, `title`, `status`, `detail?`, `metadata?`). //! //! ## Wire payload //! -//! When the `serde_json` feature is enabled, the body is [`ErrorResponse`] -//! with: +//! When the `serde_json` feature is enabled, the body is [`ProblemJson`] with: +//! - `type`: canonical URI describing the problem class +//! - `title`: short summary derived from [`AppErrorKind`] //! - `status`: numeric HTTP status (e.g. 404, 422, 500) -//! - `message`: explicit application message or a fallback from `AppErrorKind` -//! - `details`: currently `None`, but reserved for optional JSON/text payloads +//! - `detail?`: public message (redacted when the error is private) +//! - `metadata?`: sanitized structured fields carried from +//! [`Metadata`](crate::Metadata) +//! - `grpc?`: optional gRPC mapping for multi-protocol clients //! //! Without `serde_json`, Actix still returns a response with the correct status //! but with an empty body. @@ -49,7 +52,13 @@ //! The client will get a `403 Forbidden` response with a JSON body like: //! //! ```json -//! {"status":403,"message":"no access"} +//! { +//! "type":"https://errors.masterror.rs/forbidden", +//! "title":"Forbidden", +//! "status":403, +//! "detail":"no access", +//! "code":"FORBIDDEN" +//! } //! ``` //! //! ## Notes @@ -63,16 +72,12 @@ //! See also: Axum integration in [`convert::axum`]. #[cfg(feature = "actix")] -use actix_web::{ - HttpResponse, ResponseError, - http::{ - StatusCode as ActixStatus, - header::{RETRY_AFTER, WWW_AUTHENTICATE} - } -}; +use actix_web::{HttpResponse, ResponseError, http::StatusCode as ActixStatus}; #[cfg(feature = "actix")] -use crate::{AppError, ErrorResponse}; +use crate::response::actix_impl::respond_with_problem_json; +#[cfg(feature = "actix")] +use crate::{AppError, ProblemJson}; #[cfg(feature = "actix")] impl ResponseError for AppError { @@ -83,29 +88,25 @@ impl ResponseError for AppError { .unwrap_or(ActixStatus::INTERNAL_SERVER_ERROR) } - /// Produce JSON body with `ErrorResponse`. Does not leak sources. + /// Produce JSON body with [`ProblemJson`]. Does not leak sources. fn error_response(&self) -> HttpResponse { - let body = ErrorResponse::from(self); - let mut builder = HttpResponse::build(self.status_code()); - if let Some(retry) = body.retry { - builder.insert_header((RETRY_AFTER, retry.after_seconds.to_string())); - } - if let Some(ref ch) = body.www_authenticate { - builder.insert_header((WWW_AUTHENTICATE, ch.as_str())); - } - builder.json(body) + self.emit_telemetry(); + let problem = ProblemJson::from_ref(self); + respond_with_problem_json(problem) } } #[cfg(all(test, feature = "actix"))] mod actix_tests { + use std::str::FromStr; + use actix_web::{ ResponseError, body::to_bytes, http::header::{RETRY_AFTER, WWW_AUTHENTICATE} }; - use crate::{AppCode, AppError, AppErrorKind, AppResult, ErrorResponse}; + use crate::{AppCode, AppError, AppErrorKind, AppResult}; #[test] fn maps_status_consistently() { @@ -133,10 +134,23 @@ mod actix_tests { ); let bytes = to_bytes(resp.into_body()).await?; - let body: ErrorResponse = serde_json::from_slice(&bytes)?; - assert_eq!(body.status, 401); - assert!(matches!(body.code, AppCode::Unauthorized)); - assert_eq!(body.message, "no token"); + let body: serde_json::Value = serde_json::from_slice(&bytes)?; + assert_eq!( + body.get("status").and_then(|value| value.as_u64()), + Some(401) + ); + assert_eq!( + body.get("code") + .and_then(|value| value.as_str()) + .map(AppCode::from_str) + .transpose() + .expect("parse app code"), + Some(AppCode::Unauthorized) + ); + assert_eq!( + body.get("detail").and_then(|value| value.as_str()), + Some("no token") + ); Ok(()) } } diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 5c65a07..d0e5af5 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -6,16 +6,16 @@ //! - Adds an inherent `http_status()` on [`AppError`] that returns //! `axum::http::StatusCode` based on [`AppErrorKind`]. //! - Implements `IntoResponse` for [`AppError`] so handlers can `return -//! Err(...)` or directly `return AppError::...(...)` and get a JSON error -//! body (when the `serde_json` feature is enabled) or an empty body -//! otherwise. -//! - Logs each error once at the HTTP boundary using `tracing::error`. +//! Err(...)` or directly `return AppError::...(...)` and get an RFC7807 +//! problem+json body. +//! - Flushes [`AppError`] telemetry at the HTTP boundary (tracing event, +//! metrics counter, lazy backtrace). //! //! ## Wire payload //! -//! When the `serde_json` feature is enabled, the response body is -//! [`ErrorResponse`] with fields `{ status, message }`. `message` prefers the -//! explicit application message and falls back to the `AppErrorKind`’s display. +//! The response body is [`ProblemJson`] with fields `{ type, title, status, +//! detail, code, grpc, metadata }`. `detail` is redacted automatically when +//! the error is marked private. //! //! ## Example //! @@ -37,18 +37,14 @@ //! - This module does not expose internal error sources; only `kind`, `status`, //! and optional public `message` are surfaced. -#![cfg(feature = "axum")] #![cfg_attr(docsrs, doc(cfg(feature = "axum")))] use axum::{ http::StatusCode, response::{IntoResponse, Response} }; -use tracing::error; -use crate::AppError; -#[cfg(feature = "serde_json")] -use crate::response::ErrorResponse; +use crate::{AppError, response::ProblemJson}; impl AppError { /// Map this error to an HTTP status derived from its [`AppErrorKind`]. @@ -64,34 +60,20 @@ impl AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { - let status = self.http_status(); - - // Log once at the boundary with stable fields. - error!( - status = status.as_u16(), - kind = ?self.kind, - msg = self.message.as_deref().unwrap_or(""), - "AppError -> HTTP response" - ); - - #[cfg(feature = "serde_json")] - { - // Build the stable wire contract (includes `code`). - let body: ErrorResponse = self.into(); - return body.into_response(); - } - - #[allow(unreachable_code)] - (status, ()).into_response() + let err = self; + let problem = ProblemJson::from_app_error(err); + problem.into_response() } } #[cfg(test)] mod tests { + use std::str::FromStr; + use axum::http::StatusCode; use super::*; - use crate::{AppCode, AppErrorKind}; + use crate::AppCode; // --- http_status mapping ------------------------------------------------- @@ -107,51 +89,82 @@ mod tests { // --- IntoResponse with JSON body (serde_json enabled) -------------------- - #[cfg(feature = "serde_json")] #[tokio::test] - async fn into_response_builds_json_error_with_code_and_message() { - use axum::{body::to_bytes, response::IntoResponse}; - - let app_err = AppError::unauthorized("missing token"); + async fn into_response_builds_problem_json_with_headers() { + use axum::{ + body::to_bytes, + http::header::{CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE}, + response::IntoResponse + }; + + let app_err = AppError::unauthorized("missing token") + .with_retry_after_secs(7) + .with_www_authenticate("Bearer realm=\"api\""); let resp = app_err.into_response(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let content_type = resp + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .expect("content-type header"); + assert_eq!(content_type, "application/problem+json"); + + let retry_after = resp + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .expect("retry-after header"); + assert_eq!(retry_after, "7"); + + let www_authenticate = resp + .headers() + .get(WWW_AUTHENTICATE) + .and_then(|value| value.to_str().ok()) + .expect("www-authenticate header"); + assert_eq!(www_authenticate, "Bearer realm=\"api\""); + let bytes = to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - // Deserialize via our own type to ensure wire contract matches - let body: crate::response::ErrorResponse = - serde_json::from_slice(&bytes).expect("json body"); - - assert_eq!(body.status, 401); - assert!(matches!(body.code, AppCode::Unauthorized)); - assert_eq!(body.message, "missing token"); - - // Optional fields are absent by default - #[cfg(feature = "serde_json")] - { - assert!(body.details.is_none()); - } - assert!(body.retry.is_none()); - assert!(body.www_authenticate.is_none()); - } + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); - // --- IntoResponse without JSON body (serde_json disabled) ---------------- + assert_eq!( + body.get("status").and_then(|value| value.as_u64()), + Some(401) + ); + assert_eq!( + body.get("code") + .and_then(|value| value.as_str()) + .map(AppCode::from_str) + .transpose() + .expect("parse app code"), + Some(AppCode::Unauthorized) + ); + assert_eq!( + body.get("detail").and_then(|value| value.as_str()), + Some("missing token") + ); + assert!(body.get("metadata").is_none()); + assert!(body.get("grpc").is_some()); + } - #[cfg(not(feature = "serde_json"))] #[tokio::test] - async fn into_response_without_json_has_empty_body() { + async fn redacted_errors_hide_detail() { use axum::{body::to_bytes, response::IntoResponse}; - let app_err = AppError::not_found("nope"); + let app_err = AppError::internal("secret").redactable(); let resp = app_err.into_response(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); let bytes = to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - assert_eq!(bytes.len(), 0, "body should be empty without serde_json"); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); + + assert!(body.get("detail").is_none()); + assert!(body.get("metadata").is_none()); } } diff --git a/src/convert/config.rs b/src/convert/config.rs index e88ae5a..c8c1913 100644 --- a/src/convert/config.rs +++ b/src/convert/config.rs @@ -1,4 +1,4 @@ -//! Convert [`config::ConfigError`] into [`AppError`], +//! Convert [`config::ConfigError`] into [`Error`], //! producing [`AppErrorKind::Config`]. //! //! Enabled with the `config` feature. @@ -7,23 +7,90 @@ //! //! ```rust,ignore //! use config::ConfigError; -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! //! let err = ConfigError::Message("missing key".into()); -//! let app_err: AppError = err.into(); +//! let app_err: Error = err.into(); //! assert!(matches!(app_err.kind, AppErrorKind::Config)); //! ``` #[cfg(feature = "config")] use config::ConfigError; #[cfg(feature = "config")] -use crate::AppError; +use crate::{AppErrorKind, Context, Error, field}; #[cfg(feature = "config")] #[cfg_attr(docsrs, doc(cfg(feature = "config")))] -impl From for AppError { +impl From for Error { fn from(err: ConfigError) -> Self { - AppError::config(err.to_string()) + build_context(&err).into_error(err) + } +} + +#[cfg(feature = "config")] +fn build_context(error: &ConfigError) -> Context { + match error { + ConfigError::Frozen => { + Context::new(AppErrorKind::Config).with(field::str("config.phase", "frozen")) + } + ConfigError::NotFound(key) => Context::new(AppErrorKind::Config) + .with(field::str("config.phase", "not_found")) + .with(field::str("config.key", key.clone())), + ConfigError::PathParse { + .. + } => Context::new(AppErrorKind::Config).with(field::str("config.phase", "path_parse")), + ConfigError::FileParse { + uri, .. + } => { + let mut ctx = + Context::new(AppErrorKind::Config).with(field::str("config.phase", "file_parse")); + if let Some(path) = uri { + ctx = ctx.with(field::str("config.uri", path.clone())); + } + ctx + } + ConfigError::Type { + origin, + unexpected, + expected, + key + } => { + let mut ctx = Context::new(AppErrorKind::Config) + .with(field::str("config.phase", "type")) + .with(field::str("config.expected", *expected)) + .with(field::str("config.unexpected", unexpected.to_string())); + if let Some(origin) = origin { + ctx = ctx.with(field::str("config.origin", origin.clone())); + } + if let Some(key) = key { + ctx = ctx.with(field::str("config.key", key.clone())); + } + ctx + } + ConfigError::At { + origin, + key, + .. + } => { + let mut ctx = + Context::new(AppErrorKind::Config).with(field::str("config.phase", "at")); + if let Some(origin) = origin { + ctx = ctx.with(field::str("config.origin", origin.clone())); + } + if let Some(key) = key { + ctx = ctx.with(field::str("config.key", key.clone())); + } + ctx + } + ConfigError::Message(message) => Context::new(AppErrorKind::Config) + .with(field::str("config.phase", "message")) + .with(field::str("config.message", message.clone())), + ConfigError::Foreign(_) => { + Context::new(AppErrorKind::Config).with(field::str("config.phase", "foreign")) + } + other => Context::new(AppErrorKind::Config) + .with(field::str("config.phase", "unclassified")) + .with(field::str("config.debug", other.to_string())) } } @@ -31,12 +98,18 @@ impl From for AppError { mod tests { use config::ConfigError; - use crate::{AppError, AppErrorKind}; + use super::*; + use crate::{AppErrorKind, FieldValue}; #[test] fn maps_to_config_kind() { let err = ConfigError::Message("dummy".into()); - let app_err = AppError::from(err); + let app_err = Error::from(err); assert!(matches!(app_err.kind, AppErrorKind::Config)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("config.phase"), + Some(&FieldValue::Str("message".into())) + ); } } diff --git a/src/convert/multipart.rs b/src/convert/multipart.rs index f23750e..64510a3 100644 --- a/src/convert/multipart.rs +++ b/src/convert/multipart.rs @@ -1,4 +1,4 @@ -//! Maps [`MultipartError`] into [`AppError`] with +//! Maps [`MultipartError`] into [`Error`] with //! [`AppErrorKind::BadRequest`], preserving the original message. //! //! Intended for Axum multipart form parsing so that client mistakes are @@ -8,11 +8,25 @@ use axum::extract::multipart::MultipartError; -use crate::{AppError, AppErrorKind}; +use crate::{AppErrorKind, Context, Error, field}; -impl From for AppError { +impl From for Error { fn from(err: MultipartError) -> Self { - AppError::with(AppErrorKind::BadRequest, format!("Multipart error: {err}")) + let status = err.status(); + let body_text = err.body_text(); + let mut context = Context::new(AppErrorKind::BadRequest) + .with(field::str("multipart.reason", body_text)) + .with(field::u64("http.status", u64::from(status.as_u16()))) + .with(field::bool( + "http.is_client_error", + status.is_client_error() + )); + + if let Some(reason) = status.canonical_reason() { + context = context.with(field::str("http.status_reason", reason)); + } + + context.into_error(err) } } @@ -24,7 +38,7 @@ mod tests { http::Request }; - use crate::{AppError, AppErrorKind}; + use crate::{AppErrorKind, Error, FieldValue}; #[tokio::test] async fn multipart_error_maps_to_bad_request() { @@ -42,10 +56,29 @@ mod tests { .expect("extractor"); let err = multipart.next_field().await.expect_err("error"); - let expected = format!("Multipart error: {err}"); - let app_err: AppError = err.into(); + let status = err.status(); + let body_text = err.body_text(); + let app_err: Error = err.into(); assert_eq!(app_err.kind, AppErrorKind::BadRequest); - assert_eq!(app_err.message.as_deref(), Some(expected.as_str())); + assert_eq!( + app_err.metadata().get("multipart.reason"), + Some(&FieldValue::Str(body_text.into())) + ); + assert_eq!( + app_err.metadata().get("http.status"), + Some(&FieldValue::U64(u64::from(status.as_u16()))) + ); + assert_eq!( + app_err.metadata().get("http.status_reason"), + status + .canonical_reason() + .map(|reason| FieldValue::Str(reason.into())) + .as_ref() + ); + assert_eq!( + app_err.metadata().get("http.is_client_error"), + Some(&FieldValue::Bool(status.is_client_error())) + ); } } diff --git a/src/convert/redis.rs b/src/convert/redis.rs index 8494ae3..7a4a99c 100644 --- a/src/convert/redis.rs +++ b/src/convert/redis.rs @@ -1,12 +1,14 @@ -//! Conversion from [`redis::RedisError`] into [`AppError`]. +//! Conversion from [`redis::RedisError`] into [`Error`]. //! //! Enabled with the `redis` feature flag. //! //! ## Mapping //! -//! All Redis client errors are mapped to `AppErrorKind::Cache`. -//! The full error string from the driver is preserved in `message` for logs -//! and JSON payloads (if applicable). +//! All Redis client errors are mapped to `AppErrorKind::Cache` by default and +//! enriched with structured metadata (error kind, code, retry hints). Timeout +//! and infrastructure-level failures are promoted to `Timeout` or +//! `DependencyUnavailable` respectively. Metadata captures cluster redirects, +//! retry strategy and low-level flags without exposing sensitive payloads. //! //! This categorization treats Redis as a cache infrastructure dependency. //! If you need a different taxonomy (e.g. distinguishing caches from queues), @@ -16,10 +18,10 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use redis::RedisError; //! -//! fn handle_cache_error(e: RedisError) -> AppError { +//! fn handle_cache_error(e: RedisError) -> Error { //! e.into() //! } //! @@ -31,10 +33,10 @@ //! ``` #[cfg(feature = "redis")] -use redis::RedisError; +use redis::{ErrorKind, RedisError, RetryMethod}; #[cfg(feature = "redis")] -use crate::AppError; +use crate::{AppErrorKind, Context, Error, field}; /// Map any [`redis::RedisError`] into an [`AppError`] with kind `Cache`. /// @@ -42,10 +44,80 @@ use crate::AppError; /// Detailed driver errors are kept in the message for diagnostics. #[cfg(feature = "redis")] #[cfg_attr(docsrs, doc(cfg(feature = "redis")))] -impl From for AppError { +impl From for Error { fn from(err: RedisError) -> Self { - // Infrastructure cache issue -> cache-level error - AppError::cache(format!("Redis error: {err}")) + let (context, retry_after) = build_context(&err); + let mut error = context.into_error(err); + if let Some(secs) = retry_after { + error = error.with_retry_after_secs(secs); + } + error + } +} + +#[cfg(feature = "redis")] +fn build_context(err: &RedisError) -> (Context, Option) { + let mut context = Context::new(AppErrorKind::Cache) + .with(field::str("redis.kind", format!("{:?}", err.kind()))) + .with(field::str("redis.category", err.category().to_owned())) + .with(field::bool("redis.is_timeout", err.is_timeout())) + .with(field::bool( + "redis.is_cluster_error", + err.is_cluster_error() + )) + .with(field::bool( + "redis.is_connection_refused", + err.is_connection_refusal() + )) + .with(field::bool( + "redis.is_connection_dropped", + err.is_connection_dropped() + )); + + if let Some(code) = err.code() { + context = context.with(field::str("redis.code", code.to_owned())); + } + + if err.is_timeout() { + context = context.category(AppErrorKind::Timeout); + } else if err.is_connection_refusal() + || err.is_connection_dropped() + || err.is_cluster_error() + || err.is_io_error() + || matches!(err.kind(), ErrorKind::BusyLoadingError) + { + context = context.category(AppErrorKind::DependencyUnavailable); + } + + if let Some((addr, slot)) = err.redirect_node() { + context = context + .with(field::str("redis.redirect_addr", addr.to_owned())) + .with(field::u64("redis.redirect_slot", u64::from(slot))); + } + + let (retry_method_label, retry_after) = retry_method_details(err.retry_method()); + context = context.with(field::str("redis.retry_method", retry_method_label)); + + if let Some(secs) = retry_after { + context = context.with(field::u64("redis.retry_after_hint_secs", secs)); + } + + (context, retry_after) +} + +#[cfg(feature = "redis")] +const fn retry_method_details(method: RetryMethod) -> (&'static str, Option) { + match method { + RetryMethod::NoRetry => ("NoRetry", None), + RetryMethod::RetryImmediately => ("RetryImmediately", Some(0)), + RetryMethod::AskRedirect => ("AskRedirect", Some(0)), + RetryMethod::MovedRedirect => ("MovedRedirect", Some(0)), + RetryMethod::Reconnect => ("Reconnect", Some(1)), + RetryMethod::ReconnectFromInitialConnections => { + ("ReconnectFromInitialConnections", Some(1)) + } + RetryMethod::WaitAndRetry => ("WaitAndRetry", Some(2)), + _ => ("Other", None) } } @@ -54,12 +126,29 @@ mod tests { use redis::ErrorKind; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[test] fn maps_to_cache_kind() { let redis_err = RedisError::from((ErrorKind::IoError, "boom")); - let app_err: AppError = redis_err.into(); - assert!(matches!(app_err.kind, AppErrorKind::Cache)); + let app_err: Error = redis_err.into(); + assert!(matches!(app_err.kind, AppErrorKind::DependencyUnavailable)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("redis.kind"), + Some(&FieldValue::Str("IoError".into())) + ); + } + + #[test] + fn busy_loading_sets_retry_hint() { + let err = RedisError::from((ErrorKind::BusyLoadingError, "loading")); + let app_err: Error = err.into(); + assert_eq!(app_err.retry.map(|r| r.after_seconds), Some(2)); + assert!(matches!(app_err.kind, AppErrorKind::DependencyUnavailable)); + assert_eq!( + app_err.metadata().get("redis.retry_after_hint_secs"), + Some(&FieldValue::U64(2)) + ); } } diff --git a/src/convert/reqwest.rs b/src/convert/reqwest.rs index 19256e2..5fa756e 100644 --- a/src/convert/reqwest.rs +++ b/src/convert/reqwest.rs @@ -1,4 +1,4 @@ -//! Conversion from [`reqwest::Error`] into [`AppError`]. +//! Conversion from [`reqwest::Error`] into [`Error`]. //! //! Enabled with the `reqwest` feature flag. //! @@ -7,11 +7,16 @@ //! - [`reqwest::Error::is_timeout`] β†’ `AppErrorKind::Timeout` //! - [`reqwest::Error::is_connect`] or [`reqwest::Error::is_request`] β†’ //! `AppErrorKind::Network` -//! - [`reqwest::Error::is_status`] β†’ `AppErrorKind::ExternalApi` (with upstream -//! status info) +//! - HTTP status errors are classified by status family: +//! - `429` β†’ `AppErrorKind::RateLimited` +//! - `5xx` β†’ `AppErrorKind::DependencyUnavailable` +//! - `408` β†’ `AppErrorKind::Timeout` +//! - others β†’ `AppErrorKind::ExternalApi` //! - All other cases β†’ `AppErrorKind::ExternalApi` //! -//! The original error string is preserved in the `message` for observability. +//! Structured metadata captures the upstream endpoint, status code and +//! low-level flags (timeout/connect/request). Potentially sensitive data (URL) +//! is marked for hashing/redaction in public payloads. //! //! ## Rationale //! @@ -22,10 +27,10 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use reqwest::Error as ReqwestError; //! -//! fn handle_http_error(e: ReqwestError) -> AppError { +//! fn handle_http_error(e: ReqwestError) -> Error { //! e.into() //! } //! @@ -40,12 +45,11 @@ //! ``` #[cfg(feature = "reqwest")] -use reqwest::Error as ReqwestError; +use reqwest::{Error as ReqwestError, StatusCode}; -#[cfg(feature = "reqwest")] -use crate::AppError; +use crate::{AppErrorKind, Context, Error, FieldRedaction, field}; -/// Map a [`reqwest::Error`] into an [`AppError`] according to its category. +/// Map a [`reqwest::Error`] into an [`Error`] according to its category. /// /// - Timeout β†’ `Timeout` /// - Connect or request build error β†’ `Network` @@ -53,20 +57,81 @@ use crate::AppError; /// - Fallback for other cases β†’ `ExternalApi` #[cfg(feature = "reqwest")] #[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))] -impl From for AppError { +impl From for Error { fn from(err: ReqwestError) -> Self { - if err.is_timeout() { - AppError::timeout(format!("Request timeout: {err}")) - } else if err.is_connect() || err.is_request() { - AppError::network(format!("Network error: {err}")) - } else if err.is_status() { - AppError::external_api(format!("Upstream status error: {err}")) - } else { - AppError::external_api(format!("Upstream error: {err}")) + let (context, retry_after) = classify_reqwest_error(&err); + let mut error = context.into_error(err); + if let Some(secs) = retry_after { + error = error.with_retry_after_secs(secs); } + error } } +#[cfg(feature = "reqwest")] +fn classify_reqwest_error(err: &ReqwestError) -> (Context, Option) { + let mut context = Context::new(AppErrorKind::ExternalApi) + .with(field::bool("reqwest.is_timeout", err.is_timeout())) + .with(field::bool("reqwest.is_connect", err.is_connect())) + .with(field::bool("reqwest.is_request", err.is_request())) + .with(field::bool("reqwest.is_status", err.is_status())) + .with(field::bool("reqwest.is_body", err.is_body())) + .with(field::bool("reqwest.is_decode", err.is_decode())) + .with(field::bool("reqwest.is_redirect", err.is_redirect())); + + let mut retry_after = None; + + if err.is_timeout() { + context = context.category(AppErrorKind::Timeout); + } else if err.is_connect() || err.is_request() { + context = context.category(AppErrorKind::Network); + } + + if let Some(status) = err.status() { + let status_code = u16::from(status); + context = context.with(field::u64("http.status", u64::from(status_code))); + if let Some(reason) = status.canonical_reason() { + context = context.with(field::str("http.status_reason", reason)); + } + + context = match status { + StatusCode::TOO_MANY_REQUESTS => { + retry_after = Some(1); + context.category(AppErrorKind::RateLimited) + } + StatusCode::REQUEST_TIMEOUT => context.category(AppErrorKind::Timeout), + s if s.is_server_error() => context.category(AppErrorKind::DependencyUnavailable), + _ => context + }; + } + + if let Some(url) = err.url() { + context = context + .with(field::str("http.url", url.to_string())) + .redact_field("http.url", FieldRedaction::Hash); + + if let Some(host) = url.host_str() { + context = context.with(field::str("http.host", host.to_owned())); + } + + if let Some(port) = url.port() { + context = context.with(field::u64("http.port", u64::from(port))); + } + + let path = url.path(); + if !path.is_empty() { + context = context.with(field::str("http.path", path.to_owned())); + } + + let scheme = url.scheme(); + if !scheme.is_empty() { + context = context.with(field::str("http.scheme", scheme.to_owned())); + } + } + + (context, retry_after) +} + #[cfg(all(test, feature = "reqwest", feature = "tokio"))] mod tests { use std::time::Duration; @@ -75,9 +140,10 @@ mod tests { use tokio::{net::TcpListener, time::sleep}; use super::*; + use crate::{AppCode, AppErrorKind, FieldRedaction, FieldValue}; #[tokio::test] - async fn timeout_message_includes_original_error() { + async fn timeout_sets_category_and_metadata() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind listener"); @@ -99,14 +165,51 @@ mod tests { .await .expect_err("expected timeout"); - assert!(err.is_timeout()); + let app_err: Error = err.into(); + assert_eq!(app_err.kind, AppErrorKind::Timeout); + + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("reqwest.is_timeout"), + Some(&FieldValue::Bool(true)) + ); + assert_eq!(metadata.redaction("http.url"), Some(FieldRedaction::Hash)); + + server.abort(); + } + + #[tokio::test] + async fn status_error_maps_retry_and_rate_limit() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut buf = [0_u8; 1024]; + let _ = socket.read(&mut buf).await; + let response = b"HTTP/1.1 429 Too Many Requests\r\ncontent-length: 0\r\n\r\n"; + let _ = socket.write_all(response).await; + }); + + let client = Client::new(); + let response = client + .get(format!("http://{addr}")) + .send() + .await + .expect("send"); + let err = response.error_for_status().expect_err("status error"); - let err_str = err.to_string(); - let app_err: AppError = err.into(); - let msg = app_err.message.expect("app error message"); - assert!( - msg.contains(err_str.as_str()), - "{msg} does not contain {err_str}" + let app_err: Error = err.into(); + assert_eq!(app_err.kind, AppErrorKind::RateLimited); + assert_eq!(app_err.code, AppCode::RateLimited); + assert_eq!(app_err.retry.map(|r| r.after_seconds), Some(1)); + let metadata = app_err.metadata(); + assert_eq!(metadata.get("http.status"), Some(&FieldValue::U64(429))); + assert_eq!( + metadata.get("http.port"), + Some(&FieldValue::U64(u64::from(addr.port()))) ); server.abort(); diff --git a/src/convert/serde_json.rs b/src/convert/serde_json.rs index f0cb308..bd22754 100644 --- a/src/convert/serde_json.rs +++ b/src/convert/serde_json.rs @@ -1,4 +1,4 @@ -//! Conversion from [`serde_json::Error`] into [`AppError`]. +//! Conversion from [`serde_json::Error`] into [`Error`]. //! //! Enabled with the `serde_json` feature flag. //! @@ -18,10 +18,10 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use serde_json::Error as SjError; //! -//! fn handle_json_error(e: SjError) -> AppError { +//! fn handle_json_error(e: SjError) -> Error { //! e.into() //! } //! @@ -30,11 +30,14 @@ //! assert!(matches!(app_err.kind, AppErrorKind::Deserialization)); //! ``` +#[cfg(feature = "serde_json")] +use std::convert::TryFrom; + #[cfg(feature = "serde_json")] use serde_json::{Error as SjError, error::Category}; #[cfg(feature = "serde_json")] -use crate::AppError; +use crate::{AppErrorKind, Context, Error, field}; /// Map a [`serde_json::Error`] into an [`AppError`]. /// @@ -43,15 +46,47 @@ use crate::AppError; /// logs and optional JSON payloads. #[cfg(feature = "serde_json")] #[cfg_attr(docsrs, doc(cfg(feature = "serde_json")))] -impl From for AppError { +impl From for Error { fn from(err: SjError) -> Self { - match err.classify() { - Category::Io => AppError::serialization(err.to_string()), - Category::Syntax | Category::Data | Category::Eof => { - AppError::deserialization(err.to_string()) - } + build_context(&err).into_error(err) + } +} + +#[cfg(feature = "serde_json")] +fn build_context(err: &SjError) -> Context { + let category = err.classify(); + let mut context = match category { + Category::Io => Context::new(AppErrorKind::Serialization), + Category::Syntax | Category::Data | Category::Eof => { + Context::new(AppErrorKind::Deserialization) } } + .with(field::str("serde_json.category", format!("{:?}", category))); + + let line = err.line(); + if line != 0 { + let value = match u64::try_from(line) { + Ok(converted) => converted, + Err(_) => u64::MAX + }; + context = context.with(field::u64("serde_json.line", value)); + } + let column = err.column(); + if column != 0 { + let value = match u64::try_from(column) { + Ok(converted) => converted, + Err(_) => u64::MAX + }; + context = context.with(field::u64("serde_json.column", value)); + } + if line != 0 && column != 0 { + context = context.with(field::str( + "serde_json.position", + format!("{line}:{column}") + )); + } + + context } #[cfg(test)] @@ -61,7 +96,7 @@ mod tests { use serde_json::json; use super::*; - use crate::kind::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[test] fn io_maps_to_serialization() { @@ -78,14 +113,27 @@ mod tests { } let err = serde_json::to_writer(FailWriter, &json!({"k": "v"})).unwrap_err(); - let app: AppError = err.into(); + let app: Error = err.into(); assert!(matches!(app.kind, AppErrorKind::Serialization)); + assert_eq!( + app.metadata().get("serde_json.category"), + Some(&FieldValue::Str("Io".into())) + ); } #[test] fn syntax_maps_to_deserialization() { let err = serde_json::from_str::("not-json").unwrap_err(); - let app: AppError = err.into(); + let app: Error = err.into(); assert!(matches!(app.kind, AppErrorKind::Deserialization)); + let metadata = app.metadata(); + assert_eq!( + metadata.get("serde_json.category"), + Some(&FieldValue::Str("Syntax".into())) + ); + assert_eq!( + metadata.get("serde_json.position"), + Some(&FieldValue::Str("1:2".into())) + ); } } diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index 89fd254..d16f549 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -1,4 +1,4 @@ -//! Conversions from `sqlx` errors into `AppError`. +//! Conversions from `sqlx` errors into [`Error`]. //! //! Feature flags: //! - `sqlx` β†’ maps `sqlx_core::error::Error` @@ -7,21 +7,25 @@ //! ## Mappings //! //! - `sqlx_core::error::Error::RowNotFound` β†’ `AppErrorKind::NotFound` -//! - any other `sqlx_core::error::Error` β†’ `AppErrorKind::Database` -//! - `sqlx::migrate::MigrateError` β†’ `AppErrorKind::Database` +//! - Database constraint errors capture SQLSTATE/constraint metadata and map to +//! `Conflict`/`Validation` +//! - Transient SQLSTATEs (e.g. `40001`, `55P03`) attach retry hints +//! - `sqlx::migrate::MigrateError` β†’ `AppErrorKind::Database` with migration +//! phase metadata //! -//! The original error message is preserved in `AppError.message` for -//! observability. SQL driver–specific details are **not** mapped to separate -//! kinds to keep the taxonomy stable. +//! Structured metadata includes SQLSTATE codes, constraint names and migration +//! phases to aid observability while keeping secrets out of public payloads. +//! Known SQLSTATE codes override [`AppCode`] (`UNIQUE_VIOLATION` β†’ +//! `USER_ALREADY_EXISTS`). //! //! ## Example //! //! ```rust,ignore //! // Requires: features = ["sqlx"] -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use sqlx_core::error::Error as SqlxError; //! -//! fn handle_db_error(e: SqlxError) -> AppError { +//! fn handle_db_error(e: SqlxError) -> Error { //! e.into() //! } //! @@ -33,58 +37,391 @@ #[cfg(feature = "sqlx-migrate")] use sqlx::migrate::MigrateError; #[cfg(feature = "sqlx")] -use sqlx_core::error::Error as SqlxError; +use sqlx_core::error::{DatabaseError, Error as SqlxError, ErrorKind as SqlxErrorKind}; #[cfg(any(feature = "sqlx", feature = "sqlx-migrate"))] -use crate::AppError; +use crate::{AppCode, AppErrorKind, Context, Error, field}; -/// Map a `sqlx_core::error::Error` into an `AppError`. +#[cfg(feature = "sqlx")] +const SQLSTATE_CODE_OVERRIDES: &[(&str, AppCode)] = &[ + ("23505", AppCode::UserAlreadyExists), + ("23503", AppCode::Conflict), + ("23502", AppCode::Validation), + ("23514", AppCode::Validation) +]; + +#[cfg(feature = "sqlx")] +const SQLSTATE_RETRY_HINTS: &[(&str, u64)] = &[("40001", 1), ("55P03", 1)]; + +/// Map a `sqlx_core::error::Error` into [`Error`]. /// /// - `RowNotFound` β†’ `AppErrorKind::NotFound` -/// - all other cases β†’ `AppErrorKind::Database` -/// -/// The database error message is preserved for debugging and log correlation. +/// - database constraint errors attach SQLSTATE and constraint metadata +/// - concurrency SQLSTATEs attach retry hints #[cfg(feature = "sqlx")] #[cfg_attr(docsrs, doc(cfg(feature = "sqlx")))] -impl From for AppError { +impl From for Error { fn from(err: SqlxError) -> Self { - match err { - SqlxError::RowNotFound => AppError::not_found("Record not found"), - other => AppError::database_with_message(other.to_string()) + let (context, retry_after) = build_sqlx_context(&err); + let mut error = context.into_error(err); + if let Some(secs) = retry_after { + error = error.with_retry_after_secs(secs); } + error } } -/// Map a `sqlx::migrate::MigrateError` into an `AppError`. +/// Map a `sqlx::migrate::MigrateError` into [`Error`]. /// -/// All migration errors are considered `AppErrorKind::Database`. -/// The error string is preserved in `message`. +/// Errors are categorised as `Database` with metadata describing the failing +/// migration phase. #[cfg(feature = "sqlx-migrate")] #[cfg_attr(docsrs, doc(cfg(feature = "sqlx-migrate")))] -impl From for AppError { +impl From for Error { fn from(err: MigrateError) -> Self { - AppError::database_with_message(err.to_string()) + build_migrate_context(&err).into_error(err) + } +} + +#[cfg(feature = "sqlx")] +fn build_sqlx_context(err: &SqlxError) -> (Context, Option) { + let (mut context, retry_after) = match err { + SqlxError::RowNotFound => ( + Context::new(AppErrorKind::NotFound).with(field::str("db.reason", "row_not_found")), + None + ), + SqlxError::Database(db_err) => classify_database_error(db_err.as_ref()), + SqlxError::Io(io_err) => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "io_error")) + .with(field::str("io.kind", format!("{:?}", io_err.kind()))), + None + ), + SqlxError::PoolTimedOut => ( + Context::new(AppErrorKind::Timeout).with(field::str("db.reason", "pool_timeout")), + Some(1) + ), + SqlxError::PoolClosed => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "pool_closed")), + None + ), + SqlxError::WorkerCrashed => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "worker_crashed")), + Some(1) + ), + SqlxError::Configuration(source) => ( + Context::new(AppErrorKind::Config) + .with(field::str("db.reason", "configuration")) + .with(field::str("db.detail", source.to_string())), + None + ), + SqlxError::InvalidArgument(message) => ( + Context::new(AppErrorKind::BadRequest) + .with(field::str("db.reason", "invalid_argument")) + .with(field::str("db.argument", message.clone())), + None + ), + SqlxError::ColumnDecode { + index, .. + } => ( + Context::new(AppErrorKind::Deserialization) + .with(field::str("db.reason", "column_decode")) + .with(field::str("db.column", index.clone())), + None + ), + SqlxError::ColumnNotFound(name) => ( + Context::new(AppErrorKind::Internal) + .with(field::str("db.reason", "column_not_found")) + .with(field::str("db.column", name.clone())), + None + ), + SqlxError::ColumnIndexOutOfBounds { + index, + len + } => ( + Context::new(AppErrorKind::Internal) + .with(field::str("db.reason", "column_index_out_of_bounds")) + .with(field::u64("db.index", *index as u64)) + .with(field::u64("db.len", *len as u64)), + None + ), + SqlxError::TypeNotFound { + type_name + } => ( + Context::new(AppErrorKind::Internal) + .with(field::str("db.reason", "type_not_found")) + .with(field::str("db.type", type_name.clone())), + None + ), + SqlxError::Encode(_) => ( + Context::new(AppErrorKind::Serialization).with(field::str("db.reason", "encode")), + None + ), + SqlxError::Decode(_) => ( + Context::new(AppErrorKind::Deserialization).with(field::str("db.reason", "decode")), + None + ), + SqlxError::Protocol(detail) => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "protocol")) + .with(field::str("db.detail", detail.clone())), + Some(1) + ), + SqlxError::Tls(_) => ( + Context::new(AppErrorKind::Network).with(field::str("db.reason", "tls")), + Some(1) + ), + SqlxError::AnyDriverError(_) => ( + Context::new(AppErrorKind::Database).with(field::str("db.reason", "driver_error")), + None + ), + SqlxError::InvalidSavePointStatement => ( + Context::new(AppErrorKind::Internal) + .with(field::str("db.reason", "invalid_savepoint")), + None + ), + SqlxError::BeginFailed => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "begin_failed")), + Some(1) + ), + other => ( + Context::new(AppErrorKind::Database) + .with(field::str("db.reason", "unclassified")) + .with(field::str("db.detail", format!("{:?}", other))), + None + ) + }; + + if let Some(secs) = retry_after { + context = context.with(field::u64("db.retry_after_hint_secs", secs)); + } + + (context, retry_after) +} + +#[cfg(feature = "sqlx")] +fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, Option) { + let mut context = Context::new(AppErrorKind::Database) + .with(field::str("db.reason", "database_error")) + .with(field::str("db.message", error.message().to_owned())); + + if let Some(constraint) = error.constraint() { + context = context.with(field::str("db.constraint", constraint.to_owned())); + } + if let Some(table) = error.table() { + context = context.with(field::str("db.table", table.to_owned())); + } + + let mut retry_after = None; + let mut code_override = None; + + let code = error.code().map(|code| code.into_owned()); + if let Some(ref sqlstate) = code { + context = context.with(field::str("db.code", sqlstate.clone())); + if let Some((_, secs)) = SQLSTATE_RETRY_HINTS + .iter() + .find(|(state, _)| *state == sqlstate.as_str()) + { + retry_after = Some(*secs); + } + if let Some((_, app_code)) = SQLSTATE_CODE_OVERRIDES + .iter() + .find(|(state, _)| *state == sqlstate.as_str()) + { + code_override = Some(*app_code); + } + } + + let category = match error.kind() { + SqlxErrorKind::UniqueViolation => AppErrorKind::Conflict, + SqlxErrorKind::ForeignKeyViolation => AppErrorKind::Conflict, + SqlxErrorKind::NotNullViolation | SqlxErrorKind::CheckViolation => { + AppErrorKind::Validation + } + _ => AppErrorKind::Database + }; + + context = context.category(category); + if let Some(code) = code_override { + context = context.code(code); + } + + (context, retry_after) +} + +#[cfg(feature = "sqlx-migrate")] +fn build_migrate_context(err: &MigrateError) -> Context { + if is_invalid_mix(err) { + return Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "invalid_mix")); + } + + match err { + MigrateError::Execute(inner) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "execute")) + .with(field::str("migration.source", inner.to_string())), + MigrateError::ExecuteMigration(inner, version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "execute_migration")) + .with(field::i64("migration.version", *version)) + .with(field::str("migration.source", inner.to_string())), + MigrateError::Source(source) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "source")) + .with(field::str("migration.source", source.to_string())), + MigrateError::VersionMissing(version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_missing")) + .with(field::i64("migration.version", *version)), + MigrateError::VersionMismatch(version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_mismatch")) + .with(field::i64("migration.version", *version)), + MigrateError::VersionNotPresent(version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_not_present")) + .with(field::i64("migration.version", *version)), + MigrateError::VersionTooOld(version, latest) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_too_old")) + .with(field::i64("migration.version", *version)) + .with(field::i64("migration.latest", *latest)), + MigrateError::VersionTooNew(version, latest) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_too_new")) + .with(field::i64("migration.version", *version)) + .with(field::i64("migration.latest", *latest)), + MigrateError::ForceNotSupported => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "force_not_supported")), + MigrateError::Dirty(version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "dirty")) + .with(field::i64("migration.version", *version)), + _ => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "unclassified")) + .with(field::str("migration.detail", err.to_string())) + } +} + +#[cfg(feature = "sqlx-migrate")] +fn is_invalid_mix(err: &MigrateError) -> bool { + #[allow(deprecated)] + { + matches!(err, MigrateError::InvalidMixReversibleAndSimple) } } #[cfg(all(test, feature = "sqlx"))] mod tests_sqlx { - use std::io; + use std::fmt; use super::*; - use crate::AppErrorKind; + use crate::{AppCode, AppErrorKind, FieldValue}; #[test] fn row_not_found_maps_to_not_found() { - let err: AppError = SqlxError::RowNotFound.into(); + let err: Error = SqlxError::RowNotFound.into(); assert!(matches!(err.kind, AppErrorKind::NotFound)); } #[test] - fn other_error_maps_to_database() { - // Prefer modern constructor; avoids clippy::io-other-error - let io_err = io::Error::other("boom"); - let err: AppError = SqlxError::Io(io_err).into(); - assert!(matches!(err.kind, AppErrorKind::Database)); + fn io_error_maps_to_dependency_unavailable() { + let io_err = std::io::Error::other("boom"); + let err: Error = SqlxError::Io(io_err).into(); + assert!(matches!(err.kind, AppErrorKind::DependencyUnavailable)); + let metadata = err.metadata(); + assert_eq!( + metadata.get("db.reason"), + Some(&FieldValue::Str("io_error".into())) + ); + } + + #[test] + fn unique_violation_sets_code_override() { + let db_err = DummyDbError { + message: "duplicate key".into(), + code: Some("23505".into()), + constraint: Some("users_email_key".into()), + table: Some("users".into()), + kind: SqlxErrorKind::UniqueViolation + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.kind, AppErrorKind::Conflict); + assert_eq!(err.code, AppCode::UserAlreadyExists); + let metadata = err.metadata(); + assert_eq!( + metadata.get("db.constraint"), + Some(&FieldValue::Str("users_email_key".into())) + ); + } + + #[test] + fn serialization_failure_carries_retry_hint() { + let db_err = DummyDbError { + message: "serialization failure".into(), + code: Some("40001".into()), + constraint: None, + table: None, + kind: SqlxErrorKind::Other + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + assert_eq!( + err.metadata().get("db.retry_after_hint_secs"), + Some(&FieldValue::U64(1)) + ); + } + + #[derive(Debug)] + struct DummyDbError { + message: String, + code: Option, + constraint: Option, + table: Option, + kind: SqlxErrorKind + } + + impl fmt::Display for DummyDbError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } + } + + impl std::error::Error for DummyDbError {} + + impl DatabaseError for DummyDbError { + fn message(&self) -> &str { + &self.message + } + + fn code(&self) -> Option> { + self.code.as_deref().map(std::borrow::Cow::Borrowed) + } + + fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn into_error(self: Box) -> Box { + self + } + + fn constraint(&self) -> Option<&str> { + self.constraint.as_deref() + } + + fn table(&self) -> Option<&str> { + self.table.as_deref() + } + + fn kind(&self) -> SqlxErrorKind { + match self.kind { + SqlxErrorKind::UniqueViolation => SqlxErrorKind::UniqueViolation, + SqlxErrorKind::ForeignKeyViolation => SqlxErrorKind::ForeignKeyViolation, + SqlxErrorKind::NotNullViolation => SqlxErrorKind::NotNullViolation, + SqlxErrorKind::CheckViolation => SqlxErrorKind::CheckViolation, + SqlxErrorKind::Other => SqlxErrorKind::Other, + _ => SqlxErrorKind::Other + } + } } } diff --git a/src/convert/telegram_webapp_sdk.rs b/src/convert/telegram_webapp_sdk.rs index 252a089..d5529b0 100644 --- a/src/convert/telegram_webapp_sdk.rs +++ b/src/convert/telegram_webapp_sdk.rs @@ -1,6 +1,6 @@ //! Conversion from //! [`telegram_webapp_sdk::utils::validate_init_data::ValidationError`] into -//! [`AppError`]. +//! [`Error`]. //! //! Enabled with the `telegram-webapp-sdk` feature flag. //! @@ -22,10 +22,10 @@ //! # #[cfg(feature = "telegram-webapp-sdk")] //! # { //! '''rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use telegram_webapp_sdk::utils::validate_init_data::ValidationError; //! -//! fn convert(err: ValidationError) -> AppError { +//! fn convert(err: ValidationError) -> Error { //! err.into() //! } //! @@ -39,14 +39,34 @@ use telegram_webapp_sdk::utils::validate_init_data::ValidationError; #[cfg(feature = "telegram-webapp-sdk")] -use crate::AppError; +use crate::{AppErrorKind, Context, Error, field}; /// Map [`ValidationError`] into an [`AppError`] with kind `TelegramAuth`. #[cfg(feature = "telegram-webapp-sdk")] #[cfg_attr(docsrs, doc(cfg(feature = "telegram-webapp-sdk")))] -impl From for AppError { +impl From for Error { fn from(err: ValidationError) -> Self { - AppError::telegram_auth(err.to_string()) + build_context(&err).into_error(err) + } +} + +#[cfg(feature = "telegram-webapp-sdk")] +fn build_context(error: &ValidationError) -> Context { + match error { + ValidationError::MissingField(field) => Context::new(AppErrorKind::TelegramAuth) + .with(field::str("telegram_webapp.reason", "missing_field")) + .with(field::str("telegram_webapp.field", (*field).to_owned())), + ValidationError::InvalidEncoding => Context::new(AppErrorKind::TelegramAuth) + .with(field::str("telegram_webapp.reason", "invalid_encoding")), + ValidationError::InvalidSignatureEncoding => Context::new(AppErrorKind::TelegramAuth) + .with(field::str( + "telegram_webapp.reason", + "invalid_signature_encoding" + )), + ValidationError::SignatureMismatch => Context::new(AppErrorKind::TelegramAuth) + .with(field::str("telegram_webapp.reason", "signature_mismatch")), + ValidationError::InvalidPublicKey => Context::new(AppErrorKind::TelegramAuth) + .with(field::str("telegram_webapp.reason", "invalid_public_key")) } } @@ -55,7 +75,7 @@ mod tests { use telegram_webapp_sdk::utils::validate_init_data::ValidationError; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[test] fn all_variants_map_to_telegram_auth_and_preserve_message() { @@ -68,16 +88,19 @@ mod tests { ]; for case in cases { - let msg = case.to_string(); - let app: AppError = case.into(); + let app: Error = case.into(); assert!(matches!(app.kind, AppErrorKind::TelegramAuth)); - assert_eq!(app.message.as_deref(), Some(msg.as_str())); + assert!(app.metadata().get("telegram_webapp.reason").is_some()); } } #[test] fn validation_error_maps_to_telegram_auth() { - let err: AppError = ValidationError::SignatureMismatch.into(); + let err: Error = ValidationError::SignatureMismatch.into(); assert!(matches!(err.kind, AppErrorKind::TelegramAuth)); + assert_eq!( + err.metadata().get("telegram_webapp.reason"), + Some(&FieldValue::Str("signature_mismatch".into())) + ); } } diff --git a/src/convert/teloxide.rs b/src/convert/teloxide.rs index 6e30032..4f29f2b 100644 --- a/src/convert/teloxide.rs +++ b/src/convert/teloxide.rs @@ -1,11 +1,12 @@ -//! Conversion from [`teloxide_core::RequestError`] into [`AppError`]. +//! Conversion from [`teloxide_core::RequestError`] into [`Error`]. //! //! Enabled with the `teloxide` feature flag. //! //! ## Mapping //! -//! - [`RequestError::Api`] or [`RequestError::MigrateToChatId`] β†’ -//! `AppErrorKind::ExternalApi` +//! - [`RequestError::Api`] β†’ `AppErrorKind::ExternalApi` (invalid token β†’ +//! `AppErrorKind::Unauthorized`) +//! - [`RequestError::MigrateToChatId`] β†’ `AppErrorKind::ExternalApi` //! - [`RequestError::RetryAfter`] β†’ `AppErrorKind::RateLimited` //! - [`RequestError::Network`] β†’ `AppErrorKind::Network` //! - [`RequestError::InvalidJson`] β†’ `AppErrorKind::Deserialization` @@ -16,71 +17,176 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use teloxide_core::{errors::ApiError, RequestError, types::Seconds}; //! use std::{io, sync::Arc}; //! -//! fn map(err: RequestError) -> AppError { err.into() } +//! fn map(err: RequestError) -> Error { err.into() } //! //! let err = RequestError::RetryAfter(Seconds::from_seconds(1)); //! let app_err = map(err); //! assert!(matches!(app_err.kind, AppErrorKind::RateLimited)); //! ``` #[cfg(feature = "teloxide")] -use teloxide_core::RequestError; +use teloxide_core::{RequestError, errors::ApiError}; #[cfg(feature = "teloxide")] -use crate::AppError; +use crate::{AppErrorKind, Context, Error, FieldRedaction, field}; #[cfg(feature = "teloxide")] #[cfg_attr(docsrs, doc(cfg(feature = "teloxide")))] -impl From for AppError { +impl From for Error { fn from(err: RequestError) -> Self { - match err { - RequestError::Api(api) => AppError::external_api(format!("Telegram API error: {api}")), - RequestError::MigrateToChatId(id) => { - AppError::external_api(format!("Group migrated to {id}")) - } - RequestError::RetryAfter(secs) => { - AppError::rate_limited(format!("Retry after {secs}")) + let (context, retry_after) = build_teloxide_context(&err); + let mut error = context.into_error(err); + if let Some(secs) = retry_after { + error = error.with_retry_after_secs(secs); + } + error + } +} + +#[cfg(feature = "teloxide")] +fn build_teloxide_context(err: &RequestError) -> (Context, Option) { + match err { + RequestError::Api(api) => { + let mut context = Context::new(AppErrorKind::ExternalApi) + .with(field::str("telegram.reason", "api")) + .with(field::str("telegram.api_error", api.to_string())) + .with(field::str( + "telegram.api_error_variant", + format!("{:?}", api) + )); + + if matches!(api, ApiError::InvalidToken) { + context = context.category(AppErrorKind::Unauthorized); } - RequestError::Network(e) => AppError::network(format!("Network error: {e}")), - RequestError::InvalidJson { - source, .. - } => AppError::deserialization(format!("Invalid Telegram JSON: {source}")), - RequestError::Io(e) => AppError::internal(format!("I/O error: {e}")) + + (context, None) } + RequestError::MigrateToChatId(id) => ( + Context::new(AppErrorKind::ExternalApi) + .with(field::str("telegram.reason", "migrate_to_chat")) + .with(field::i64("telegram.chat_id", id.0)), + None + ), + RequestError::RetryAfter(secs) => { + let seconds = u64::from(secs.seconds()); + ( + Context::new(AppErrorKind::RateLimited) + .with(field::str("telegram.reason", "retry_after")) + .with(field::u64("telegram.retry_after_secs", seconds)), + Some(seconds) + ) + } + RequestError::Network(e) => ( + Context::new(AppErrorKind::Network) + .with(field::str("telegram.reason", "network")) + .with(field::str("telegram.detail", e.to_string())) + .redact_field("telegram.detail", FieldRedaction::Hash), + None + ), + RequestError::InvalidJson { + source, + raw + } => ( + Context::new(AppErrorKind::Deserialization) + .with(field::str("telegram.reason", "invalid_json")) + .with(field::str("telegram.detail", source.to_string())) + .with(field::u64("telegram.payload_len", raw.len() as u64)), + None + ), + RequestError::Io(e) => ( + Context::new(AppErrorKind::Internal) + .with(field::str("telegram.reason", "io")) + .with(field::str("io.kind", format!("{:?}", e.kind()))), + None + ) } } #[cfg(all(test, feature = "teloxide"))] mod tests { + #[cfg(feature = "reqwest")] + use std::time::Duration; use std::{io, sync::Arc}; use teloxide_core::{errors::ApiError, types::Seconds}; + #[cfg(feature = "reqwest")] + use tokio::runtime::Builder; use super::*; - use crate::AppErrorKind; + #[cfg(feature = "reqwest")] + use crate::FieldRedaction; + use crate::{AppCode, AppErrorKind, FieldValue}; #[test] fn api_maps_to_external_api() { let err = RequestError::Api(ApiError::BotBlocked); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert!(matches!(app_err.kind, AppErrorKind::ExternalApi)); + assert_eq!( + app_err.metadata().get("telegram.api_error"), + Some(&FieldValue::Str(ApiError::BotBlocked.to_string().into())) + ); } #[test] fn retry_after_maps_to_rate_limited() { let err = RequestError::RetryAfter(Seconds::from_seconds(5)); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert!(matches!(app_err.kind, AppErrorKind::RateLimited)); + assert_eq!(app_err.retry.map(|r| r.after_seconds), Some(5)); } #[test] fn io_maps_to_internal() { let io_err = Arc::new(io::Error::other("disk")); let err = RequestError::Io(io_err); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert!(matches!(app_err.kind, AppErrorKind::Internal)); + assert_eq!( + app_err.metadata().get("telegram.reason"), + Some(&FieldValue::Str("io".into())) + ); + } + + #[test] + fn invalid_token_maps_to_unauthorized() { + let err = RequestError::Api(ApiError::InvalidToken); + let app_err: Error = err.into(); + assert_eq!(app_err.kind, AppErrorKind::Unauthorized); + assert_eq!(app_err.code, AppCode::Unauthorized); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("telegram.api_error_variant"), + Some(&FieldValue::Str("InvalidToken".into())) + ); + } + + #[cfg(feature = "reqwest")] + #[test] + fn network_detail_is_hashed() { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let reqwest_err = runtime.block_on(async { + reqwest::Client::builder() + .timeout(Duration::from_millis(10)) + .build() + .expect("client") + .get("http://127.0.0.1:65535") + .send() + .await + .expect_err("expected failure") + }); + let err = RequestError::Network(Arc::new(reqwest_err)); + let app_err: Error = err.into(); + let metadata = app_err.metadata(); + assert_eq!( + metadata.redaction("telegram.detail"), + Some(FieldRedaction::Hash) + ); } } diff --git a/src/convert/tokio.rs b/src/convert/tokio.rs index 8592095..f436017 100644 --- a/src/convert/tokio.rs +++ b/src/convert/tokio.rs @@ -1,4 +1,4 @@ -//! Conversion from [`tokio::time::error::Elapsed`] into [`AppError`]. +//! Conversion from [`tokio::time::error::Elapsed`] into [`Error`]. //! //! Enabled with the `tokio` feature flag. //! @@ -17,7 +17,7 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use tokio::time::{sleep, timeout, Duration}; //! //! #[tokio::main] @@ -26,7 +26,7 @@ //! let res = timeout(Duration::from_millis(10), fut).await; //! //! let err = res.unwrap_err(); // tokio::time::error::Elapsed -//! let app_err: AppError = err.into(); +//! let app_err: Error = err.into(); //! //! assert!(matches!(app_err.kind, AppErrorKind::Timeout)); //! } @@ -36,7 +36,7 @@ use tokio::time::error::Elapsed; #[cfg(feature = "tokio")] -use crate::AppError; +use crate::{AppErrorKind, Context, Error, field}; /// Map a [`tokio::time::error::Elapsed`] into an [`AppError`] with kind /// `Timeout`. @@ -44,9 +44,11 @@ use crate::AppError; /// Message is fixed to avoid leaking timing specifics to the client. #[cfg(feature = "tokio")] #[cfg_attr(docsrs, doc(cfg(feature = "tokio")))] -impl From for AppError { - fn from(_: Elapsed) -> Self { - AppError::timeout("Operation timed out") +impl From for Error { + fn from(err: Elapsed) -> Self { + Context::new(AppErrorKind::Timeout) + .with(field::str("timeout.source", "tokio::time::timeout")) + .into_error(err) } } @@ -55,7 +57,7 @@ mod tests { use tokio::time::{Duration, sleep, timeout}; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[tokio::test] async fn elapsed_maps_to_timeout() { @@ -63,7 +65,11 @@ mod tests { let err = timeout(Duration::from_millis(1), fut) .await .expect_err("expect timeout"); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert!(matches!(app_err.kind, AppErrorKind::Timeout)); + assert_eq!( + app_err.metadata().get("timeout.source"), + Some(&FieldValue::Str("tokio::time::timeout".into())) + ); } } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs new file mode 100644 index 0000000..648074c --- /dev/null +++ b/src/convert/tonic.rs @@ -0,0 +1,213 @@ +//! Tonic integration: convert [`crate::Error`] into [`tonic::Status`]. +//! +//! Enabled with the `tonic` feature flag. +//! +//! ## Behavior +//! - Maps [`AppCode`] to the corresponding gRPC [`tonic::Code`]. +//! - Emits retry/authentication hints via metadata when available. +//! - Propagates public metadata only when the error is not marked as +//! redactable. +//! - Redacts the message automatically when the error is private. +//! +//! ## Example +//! +//! ```rust,ignore +//! use masterror::AppError; +//! +//! let status = tonic::Status::from(AppError::not_found("missing")); +//! assert_eq!(status.code(), tonic::Code::NotFound); +//! ``` + +use core::convert::Infallible; +use std::borrow::Cow; + +use tonic::{ + Code, Status, + metadata::{MetadataMap, MetadataValue} +}; + +#[cfg(test)] +use crate::CODE_MAPPINGS; +use crate::{ + AppErrorKind, Error, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, RetryAdvice, + mapping_for_code +}; + +/// 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. +/// +/// # Examples +/// ```rust,ignore +/// use masterror::{AppError, StatusConversionError}; +/// use tonic::{Code, Status}; +/// +/// let status: Result = Status::try_from( +/// AppError::not_found("missing") +/// ); +/// let status = status.expect("conversion cannot fail"); +/// assert_eq!(status.code(), Code::NotFound); +/// ``` +pub type StatusConversionError = Infallible; + +impl From for Status { + fn from(error: Error) -> Self { + status_from_error(&error) + } +} + +fn status_from_error(error: &Error) -> Status { + error.emit_telemetry(); + + 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() + ); + insert_ascii(&mut meta, "app-problem-type", mapping.problem_type()); + + if let Some(advice) = error.retry { + insert_retry(&mut meta, advice); + } + if let Some(challenge) = error.www_authenticate.as_deref() + && is_ascii_metadata_value(challenge) + { + insert_ascii(&mut meta, "www-authenticate", challenge); + } + + if !matches!(error.edit_policy, MessageEditPolicy::Redact) { + attach_metadata(&mut meta, error.metadata()); + } + + Status::with_metadata(grpc_code, detail, meta) +} + +fn sanitize_detail( + message: Option<&Cow<'static, str>>, + kind: AppErrorKind, + policy: MessageEditPolicy +) -> String { + if matches!(policy, MessageEditPolicy::Redact) { + return kind.to_string(); + } + + message.map_or_else(|| kind.to_string(), |msg| msg.as_ref().to_owned()) +} + +fn insert_retry(meta: &mut MetadataMap, retry: RetryAdvice) { + insert_ascii(meta, "retry-after", retry.after_seconds.to_string()); +} + +fn attach_metadata(meta: &mut MetadataMap, metadata: &Metadata) { + for (name, value, redaction) in metadata.iter_with_redaction() { + if !matches!(redaction, FieldRedaction::None) { + continue; + } + if !is_safe_metadata_key(name) { + continue; + } + if let Some(serialized) = metadata_value_to_ascii(value) { + insert_ascii(meta, name, serialized); + } + } +} + +fn insert_ascii(meta: &mut MetadataMap, key: &'static str, value: impl AsRef) { + if !is_safe_metadata_key(key) { + return; + } + let value = value.as_ref(); + if !is_ascii_metadata_value(value) { + return; + } + if let Ok(metadata_value) = MetadataValue::try_from(value) { + let _ = meta.insert(key, metadata_value); + } +} + +fn metadata_value_to_ascii(value: &FieldValue) -> Option> { + match value { + FieldValue::Str(value) => { + let text = value.as_ref(); + is_ascii_metadata_value(text).then_some(Cow::Borrowed(text)) + } + FieldValue::I64(value) => Some(Cow::Owned(value.to_string())), + FieldValue::U64(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())) + } +} + +fn is_safe_metadata_key(key: &str) -> bool { + !key.is_empty() + && key + .bytes() + .all(|ch| matches!(ch, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.')) +} + +fn is_ascii_metadata_value(value: &str) -> bool { + value.bytes().all(|ch| matches!(ch, 0x20..=0x7E)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AppError, AppErrorKind, field}; + + #[test] + fn status_maps_codes_correctly() { + for (code, mapping) in CODE_MAPPINGS.iter() { + let err = AppError::with(mapping.kind(), format!("{:?}", code)); + let status = Status::from(err); + assert_eq!(status.code(), Code::from_i32(mapping.grpc().value)); + let expected_detail = format!("{:?}", code); + assert_eq!( + status.message(), + expected_detail, + "unexpected message for {:?}", + code + ); + } + } + + #[test] + fn redacted_errors_hide_metadata() { + let err = AppError::internal("secret") + .redactable() + .with_field(field::str("request_id", "abc")); + let status = Status::from(err); + assert_eq!(status.message(), AppErrorKind::Internal.to_string()); + assert!(status.metadata().get("request_id").is_none()); + } + + #[test] + fn public_metadata_is_propagated() { + let err = AppError::service("downstream") + .with_field(field::str("request_id", "abc")) + .with_field(field::u64("attempt", 2)); + let status = Status::from(err); + assert_eq!( + status + .metadata() + .get("request_id") + .and_then(|value| value.to_str().ok()), + Some("abc") + ); + assert_eq!( + status + .metadata() + .get("attempt") + .and_then(|value| value.to_str().ok()), + Some("2") + ); + } +} diff --git a/src/convert/validator.rs b/src/convert/validator.rs index e5f7f47..fd15b69 100644 --- a/src/convert/validator.rs +++ b/src/convert/validator.rs @@ -1,4 +1,4 @@ -//! Conversion from [`validator::ValidationErrors`] into [`AppError`]. +//! Conversion from [`validator::ValidationErrors`] into [`Error`]. //! //! Enabled with the `validator` feature flag. //! @@ -19,7 +19,7 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind, AppResult}; +//! use masterror::{AppErrorKind, AppResult, Error}; //! use validator::{Validate, ValidationError}; //! //! #[derive(Validate)] @@ -39,10 +39,10 @@ //! ``` #[cfg(feature = "validator")] -use validator::ValidationErrors; +use validator::{ValidationErrors, ValidationErrorsKind}; #[cfg(feature = "validator")] -use crate::AppError; +use crate::{AppErrorKind, Context, Error, field}; /// Map [`validator::ValidationErrors`] into an [`AppError`] with kind /// `Validation`. @@ -51,18 +51,74 @@ use crate::AppError; /// Consider extending `AppError` if you want to expose structured details. #[cfg(feature = "validator")] #[cfg_attr(docsrs, doc(cfg(feature = "validator")))] -impl From for AppError { +impl From for Error { fn from(err: ValidationErrors) -> Self { - AppError::validation(err.to_string()) + build_context(&err).into_error(err) } } +#[cfg(feature = "validator")] +fn build_context(errors: &ValidationErrors) -> Context { + let mut context = Context::new(AppErrorKind::Validation); + + let field_errors = errors.field_errors(); + if !field_errors.is_empty() { + context = context.with(field::u64( + "validation.field_count", + field_errors.len() as u64 + )); + + let total: u64 = field_errors.values().map(|errs| errs.len() as u64).sum(); + if total > 0 { + context = context.with(field::u64("validation.error_count", total)); + } + + let mut names = String::new(); + for (idx, name) in field_errors.keys().take(3).enumerate() { + if idx > 0 { + names.push(','); + } + names.push_str(name.as_ref()); + } + if !names.is_empty() { + context = context.with(field::str("validation.fields", names)); + } + + let mut codes: Vec = Vec::new(); + for errors in field_errors.values() { + for error in *errors { + let code = error.code.as_ref(); + if codes.len() >= 3 { + break; + } + if codes.iter().any(|existing| existing == code) { + continue; + } + codes.push(code.to_string()); + } + } + if !codes.is_empty() { + context = context.with(field::str("validation.codes", codes.join(","))); + } + } + + let has_nested = errors + .errors() + .values() + .any(|kind| !matches!(kind, ValidationErrorsKind::Field(_))); + if has_nested { + context = context.with(field::bool("validation.has_nested", true)); + } + + context +} + #[cfg(all(test, feature = "validator"))] mod tests { use validator::Validate; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[derive(Validate)] struct Payload { @@ -75,7 +131,12 @@ mod tests { let bad = Payload { val: 0 }; - let err: AppError = bad.validate().unwrap_err().into(); + let err: Error = bad.validate().unwrap_err().into(); assert!(matches!(err.kind, AppErrorKind::Validation)); + let metadata = err.metadata(); + assert_eq!( + metadata.get("validation.field_count"), + Some(&FieldValue::U64(1)) + ); } } diff --git a/src/lib.rs b/src/lib.rs index ce7fe29..dff5e40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,15 +7,20 @@ //! feature flags. //! //! Core types: -//! - [`AppError`] β€” thin wrapper around a semantic error kind and optional -//! message +//! - [`AppError`] β€” rich error capturing code, taxonomy, message, metadata and +//! transport hints //! - [`AppErrorKind`] β€” stable internal taxonomy of application errors //! - [`AppResult`] β€” convenience alias for returning [`AppError`] -//! - [`ErrorResponse`] β€” stable wire-level JSON payload for HTTP APIs +//! - [`ProblemJson`] β€” RFC7807 payload emitted by HTTP/gRPC adapters +//! - [`ErrorResponse`] β€” legacy wire-level JSON payload for HTTP APIs //! - [`AppCode`] β€” public, machine-readable error code for clients +//! - [`Metadata`] β€” structured telemetry attached to [`AppError`] +//! - [`field`] β€” helper functions to build [`Metadata`] without manual enums //! //! Key properties: //! - Stable, predictable error categories (`AppErrorKind`). +//! - Explicit, overridable machine-readable codes (`AppCode`). +//! - Structured metadata for observability without ad-hoc `String` maps. //! - Conservative and stable HTTP mappings. //! - Internal error sources are never serialized to clients (only logged). //! - Messages are safe to expose (human-oriented, non-sensitive). @@ -29,10 +34,12 @@ //! //! Enable only what you need: //! -//! - `axum` β€” implements `IntoResponse` for [`AppError`] and [`ErrorResponse`] -//! with JSON body -//! - `actix` β€” implements `Responder` for [`ErrorResponse`] (and Actix -//! integration for [`AppError`]) +//! - `axum` β€” implements `IntoResponse` for [`AppError`] and [`ProblemJson`] +//! with RFC7807 body +//! - `actix` β€” implements `Responder` for [`ProblemJson`] and Actix +//! `ResponseError` for [`AppError`] +//! - `tonic` β€” converts [`struct@Error`] into `tonic::Status` with sanitized +//! metadata //! - `openapi` β€” derives an OpenAPI schema for [`ErrorResponse`] (via `utoipa`) //! - `sqlx` β€” `From` mapping //! - `redis` β€” `From` mapping @@ -46,18 +53,19 @@ //! mapping //! - `frontend` β€” convert errors into `wasm_bindgen::JsValue` and emit //! `console.error` logs in WASM/browser contexts -//! - `serde_json` β€” support for structured JSON details in [`ErrorResponse`]; -//! also pulled transitively by `axum` +//! - `serde_json` β€” support for structured JSON details in [`ErrorResponse`] +//! and [`ProblemJson`]; also pulled transitively by `axum` //! - `multipart` β€” compatibility flag for Axum multipart //! - `turnkey` β€” domain taxonomy and conversions for Turnkey errors, exposed in //! the `turnkey` module //! //! # Derive macros and telemetry //! -//! The [`masterror::Error`](crate::Error) derive mirrors `thiserror` while -//! adding `#[app_error]` and `#[provide]` attributes. Annotate your domain -//! errors once to surface structured telemetry via [`std::error::Request`] and -//! generate conversions into [`AppError`] / [`AppCode`]. +//! The [`masterror::Error`](derive@crate::Error) derive mirrors `thiserror` +//! while adding `#[app_error]` and `#[provide]` attributes. Annotate your +//! domain errors once to surface structured telemetry via +//! [`std::error::Request`] and generate conversions into [`AppError`] / +//! [`AppCode`]. //! //! ```rust //! use masterror::{AppCode, AppError, AppErrorKind, Error}; @@ -79,6 +87,76 @@ //! Use `#[provide]` to forward typed telemetry that downstream consumers can //! extract from [`AppError`] via `std::error::Request`. //! +//! ## Masterror derive: end-to-end domain errors +//! +//! `#[derive(Masterror)]` builds on top of `#[derive(Error)]`, wiring a domain +//! error directly into [`struct@crate::Error`] with typed telemetry, redaction +//! policy and transport hints. The `#[masterror(...)]` attribute mirrors the +//! `thiserror` style while keeping redaction decisions and metadata in one +//! place. +//! +//! ```rust +//! use masterror::{ +//! AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy, mapping::HttpMapping +//! }; +//! +//! #[derive(Debug, Masterror)] +//! #[error("user {user_id} missing flag {flag}")] +//! #[masterror( +//! code = AppCode::NotFound, +//! category = AppErrorKind::NotFound, +//! message, +//! redact(message, fields("user_id" = hash)), +//! telemetry( +//! Some(masterror::field::str("user_id", user_id.clone())), +//! attempt.map(|value| masterror::field::u64("attempt", value)) +//! ), +//! map.grpc = 5, +//! map.problem = "https://errors.example.com/not-found" +//! )] +//! struct MissingFlag { +//! user_id: String, +//! flag: &'static str, +//! attempt: Option, +//! #[source] +//! source: Option +//! } +//! +//! let err = MissingFlag { +//! user_id: "alice".into(), +//! flag: "beta", +//! attempt: Some(2), +//! source: None +//! }; +//! let converted: Error = err.into(); +//! assert_eq!(converted.code, AppCode::NotFound); +//! assert_eq!(converted.kind, AppErrorKind::NotFound); +//! assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +//! assert!(converted.metadata().get("user_id").is_some()); +//! assert_eq!( +//! MissingFlag::HTTP_MAPPING, +//! HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +//! ); +//! ``` +//! +//! - `code` β€” public [`AppCode`]. +//! - `category` β€” semantic [`AppErrorKind`]. +//! - `message` β€” expose the formatted [`core::fmt::Display`] output as the +//! public message. +//! - `redact(message)` β€” mark the message as redactable at the transport +//! boundary, `fields("name" = hash, "card" = last4)` override metadata +//! policies (`hash`, `last4`, `redact`, `none`). +//! - `telemetry(...)` β€” list of expressions producing +//! `Option` to be inserted into [`Metadata`]. +//! - `map.grpc` / `map.problem` β€” optional gRPC status (as `i32`) and +//! problem+json type for generated mapping tables. Access them via +//! `TYPE::HTTP_MAPPING`, `TYPE::GRPC_MAPPING`/`MAPPINGS` and +//! `TYPE::PROBLEM_MAPPING`/`MAPPINGS`. +//! +//! The derive continues to honour `#[from]`, `#[source]` and `#[backtrace]` +//! field attributes, automatically attaching sources and captured backtraces to +//! the resulting [`struct@Error`]. +//! //! # Domain integrations: Turnkey //! //! With the `turnkey` feature enabled, the crate exports a `turnkey` module @@ -125,6 +203,16 @@ //! assert!(matches!(err.kind, AppErrorKind::BadRequest)); //! ``` //! +//! Attach structured metadata for telemetry and logging: +//! ```rust +//! use masterror::{AppError, AppErrorKind, field}; +//! +//! let err = AppError::service("downstream degraded") +//! .with_field(field::str("request_id", "abc123")) +//! .with_field(field::i64("attempt", 2)); +//! assert_eq!(err.metadata().len(), 2); +//! ``` +//! //! [`AppErrorKind`] controls the default HTTP status mapping. //! [`AppCode`] provides a stable machine-readable code for clients. //! Together, they form the wire contract in [`ErrorResponse`]. @@ -158,6 +246,35 @@ //! assert!(matches!(resp.code, AppCode::NotFound)); //! ``` //! +//! # Typed control-flow macros +//! +//! Reach for [`ensure!`] and [`fail!`] when you need to exit early with a typed +//! error without paying for string formatting or heap allocations on the +//! success path. +//! +//! ```rust +//! use masterror::{AppError, AppErrorKind, AppResult}; +//! +//! fn guard(flag: bool) -> AppResult<()> { +//! masterror::ensure!(flag, AppError::bad_request("flag must be set")); +//! Ok(()) +//! } +//! +//! fn bail() -> AppResult<()> { +//! masterror::fail!(AppError::unauthorized("token expired")); +//! } +//! +//! assert!(guard(true).is_ok()); +//! assert!(matches!( +//! guard(false).unwrap_err().kind, +//! AppErrorKind::BadRequest +//! )); +//! assert!(matches!( +//! bail().unwrap_err().kind, +//! AppErrorKind::Unauthorized +//! )); +//! ``` +//! //! # Axum integration //! //! With the `axum` feature enabled, you can return [`AppError`] directly from @@ -214,10 +331,12 @@ mod code; mod convert; pub mod error; mod kind; +mod macros; #[cfg(error_generic_member_access)] #[doc(hidden)] pub mod provide; mod response; +mod result_ext; #[cfg(feature = "frontend")] #[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] @@ -230,10 +349,16 @@ pub mod turnkey; /// Minimal prelude re-exporting core types for handler signatures. pub mod prelude; -pub use app_error::{AppError, AppResult}; -pub use code::AppCode; +/// Transport mapping descriptors for generated domain errors. +pub mod mapping; + +pub use app_error::{ + AppError, AppResult, Context, Error, Field, FieldRedaction, FieldValue, MessageEditPolicy, + Metadata, field +}; +pub use code::{AppCode, ParseAppCodeError}; pub use kind::AppErrorKind; -/// Re-export derive macros so users only depend on [`masterror`]. +/// Re-export derive macros so users only depend on this crate. /// /// # Examples /// @@ -259,5 +384,16 @@ pub use kind::AppErrorKind; /// .into(); /// assert!(matches!(code, AppCode::BadRequest)); /// ``` -pub use masterror_derive::*; -pub use response::{ErrorResponse, RetryAdvice}; +pub use masterror_derive::{Error, Masterror}; +pub use response::{ + ErrorResponse, ProblemJson, RetryAdvice, + problem_json::{ + CODE_MAPPINGS, CodeMapping, GrpcCode, ProblemMetadata, ProblemMetadataValue, + mapping_for_code + } +}; +pub use result_ext::ResultExt; + +#[cfg(feature = "tonic")] +#[cfg_attr(docsrs, doc(cfg(feature = "tonic")))] +pub use crate::convert::StatusConversionError; diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..321f9a0 --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,100 @@ +//! Control-flow macros for early returns with typed errors. +//! +//! These macros complement the typed [`AppError`](crate::AppError) APIs by +//! providing a lightweight, allocation-free way to short-circuit functions when +//! invariants are violated. Unlike the dynamic formatting helpers offered by +//! `anyhow` or `eyre`, the macros operate on pre-constructed error values so +//! the compiler keeps strong typing guarantees and no formatting work happens +//! on the success path. +//! +//! ```rust +//! use masterror::{AppError, AppErrorKind, AppResult}; +//! +//! fn guard(flag: bool) -> AppResult<()> { +//! masterror::ensure!(flag, AppError::bad_request("flag must be true")); +//! Ok(()) +//! } +//! +//! assert!(guard(true).is_ok()); +//! assert!(matches!( +//! guard(false).unwrap_err().kind, +//! AppErrorKind::BadRequest +//! )); +//! ``` + +/// Abort the enclosing function with an error when a condition fails. +/// +/// The macro takes either a bare condition and error expression, or the more +/// explicit `cond = ..., else = ...` form. The error expression is evaluated +/// lazily only when the condition is false. +/// +/// # Examples +/// +/// Short-circuit a typed error: +/// +/// ```rust +/// use masterror::{AppError, AppErrorKind, AppResult}; +/// +/// fn require(flag: bool) -> AppResult<()> { +/// masterror::ensure!(flag, AppError::bad_request("flag required")); +/// Ok(()) +/// } +/// +/// assert!(matches!( +/// require(false).unwrap_err().kind, +/// AppErrorKind::BadRequest +/// )); +/// ``` +/// +/// Use the verbose syntax for clarity in complex conditions: +/// +/// ```rust +/// use masterror::{AppError, AppResult}; +/// +/// fn bounded(value: i32, max: i32) -> AppResult<()> { +/// masterror::ensure!( +/// cond = value <= max, +/// else = AppError::service("value too large") +/// ); +/// Ok(()) +/// } +/// +/// assert!(bounded(2, 3).is_ok()); +/// assert!(bounded(5, 3).is_err()); +/// ``` +#[macro_export] +macro_rules! ensure { + (cond = $cond:expr, else = $err:expr $(,)?) => { + $crate::ensure!($cond, $err) + }; + ($cond:expr, $err:expr $(,)?) => { + if !$cond { + return Err($err); + } + }; +} + +/// Abort the enclosing function with the provided error. +/// +/// This macro is a typed alternative to `anyhow::bail!`, delegating the +/// decision of how to construct the error to the caller. It never performs +/// formatting or allocations on the success path. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppError, AppErrorKind, AppResult}; +/// +/// fn reject() -> AppResult<()> { +/// masterror::fail!(AppError::unauthorized("token expired")); +/// } +/// +/// let err = reject().unwrap_err(); +/// assert!(matches!(err.kind, AppErrorKind::Unauthorized)); +/// ``` +#[macro_export] +macro_rules! fail { + ($err:expr $(,)?) => { + return Err($err); + }; +} diff --git a/src/mapping.rs b/src/mapping.rs new file mode 100644 index 0000000..8b56f3d --- /dev/null +++ b/src/mapping.rs @@ -0,0 +1,129 @@ +//! Transport mapping descriptors generated by `#[derive(Masterror)]`. +//! +//! The derive macro produces compile-time tables describing how each domain +//! error maps to transport-specific representations. Use these helpers to +//! integrate with HTTP, gRPC or RFC 7807 problem+json responses without +//! duplicating per-variant logic. + +use crate::{AppCode, AppErrorKind}; + +/// HTTP mapping for a domain error. +/// +/// 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)] +pub struct HttpMapping { + code: AppCode, + kind: AppErrorKind +} + +impl HttpMapping { + /// Create a new HTTP mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind) -> Self { + Self { + code, + kind + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// Derive the HTTP status code from the error kind. + #[must_use] + pub fn status(&self) -> u16 { + self.kind.http_status() + } +} + +/// gRPC mapping for a domain error. +/// +/// Stores the [`AppCode`], [`AppErrorKind`] and a gRPC status code (as `i32`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GrpcMapping { + code: AppCode, + kind: AppErrorKind, + status: i32 +} + +impl GrpcMapping { + /// Create a new gRPC mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind, status: i32) -> Self { + Self { + code, + kind, + status + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// gRPC status code (matching `tonic::Code` discriminants). + #[must_use] + pub const fn status(&self) -> i32 { + self.status + } +} + +/// RFC 7807 problem+json mapping. +/// +/// Associates an error with the [`AppCode`], [`AppErrorKind`] and a canonical +/// problem `type` URI. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProblemMapping { + code: AppCode, + kind: AppErrorKind, + r#type: &'static str +} + +impl ProblemMapping { + /// Create a new problem+json mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind, type_uri: &'static str) -> Self { + Self { + code, + kind, + r#type: type_uri + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// Canonical problem `type` URI. + #[must_use] + pub const fn type_uri(&self) -> &'static str { + self.r#type + } +} diff --git a/src/response.rs b/src/response.rs index 9f5c1f3..5f7d926 100644 --- a/src/response.rs +++ b/src/response.rs @@ -2,29 +2,26 @@ //! //! # Purpose //! -//! [`ErrorResponse`] is a stable JSON structure intended to be returned -//! directly from HTTP handlers. It represents the **public-facing contract** -//! for error reporting in web APIs. +//! [`ProblemJson`] serializes an RFC7807 payload designed for HTTP responses. +//! It augments the legacy [`ErrorResponse`] (still available for manual usage) +//! with: //! -//! It deliberately contains only *safe-to-expose* fields: +//! - canonical problem `type` URIs derived from [`AppCode`] +//! - a `title` computed from [`AppErrorKind`] +//! - the stable machine code plus optional gRPC mapping (`grpc.code`, +//! `grpc.value`) +//! - retry/authentication hints surfaced via the `Retry-After` and +//! `WWW-Authenticate` headers +//! - sanitized [`Metadata`] values when the error is not marked redactable //! -//! - [`status`](ErrorResponse::status): HTTP status code chosen by the service -//! - [`code`](ErrorResponse::code): stable, machine-readable error code -//! ([`AppCode`]) -//! - [`message`](ErrorResponse::message): human-oriented, non-sensitive text -//! - [`details`](ErrorResponse::details): optional structured payload -//! (`serde_json::Value` if the `serde_json` feature is enabled, otherwise -//! plain text) -//! - [`retry`](ErrorResponse::retry): optional retry advice, rendered as the -//! `Retry-After` header in HTTP adapters; set via -//! [`with_retry_after_secs`](ErrorResponse::with_retry_after_secs) or -//! [`with_retry_after_duration`](ErrorResponse::with_retry_after_duration) -//! - [`www_authenticate`](ErrorResponse::www_authenticate): optional -//! authentication challenge string, rendered as the `WWW-Authenticate` header +//! When the message is tagged redactable (`AppError::redactable` or +//! `Context::redact(true)`), both `detail` and metadata are omitted to avoid +//! leaking sensitive information. The HTTP adapters (`axum`, `actix`) emit +//! `application/problem+json` bodies automatically via [`ProblemJson`]. //! -//! Internal error sources (the [`std::error::Error`] chain inside [`AppError`]) -//! are **never leaked** into this type. They should be logged at the boundary, -//! but not serialized into responses. +//! [`ErrorResponse`] remains available for backwards compatibility with +//! existing wire contracts and can be converted into [`ProblemJson`] via +//! [`ProblemJson::from_error_response`]. //! //! # Example //! @@ -61,17 +58,21 @@ mod core; mod details; +pub mod internal; mod legacy; mod mapping; mod metadata; +pub mod problem_json; #[cfg(feature = "axum")] mod axum_impl; #[cfg(feature = "actix")] -mod actix_impl; +pub(crate) mod actix_impl; pub use core::{ErrorResponse, RetryAdvice}; +pub use problem_json::ProblemJson; + #[cfg(test)] mod tests; diff --git a/src/response/actix_impl.rs b/src/response/actix_impl.rs index 3359f63..a3a338a 100644 --- a/src/response/actix_impl.rs +++ b/src/response/actix_impl.rs @@ -1,33 +1,52 @@ -//! Actix integration: implements [`Responder`] for [`ErrorResponse`]. +//! Actix integration: implements [`Responder`] for [`ProblemJson`] and +//! [`ErrorResponse`]. //! //! Behavior: -//! - Serializes the response as JSON with the given status. -//! - Adds `Retry-After` if [`ErrorResponse::retry`] is present. -//! - Adds `WWW-Authenticate` if [`ErrorResponse::www_authenticate`] is present. +//! - Serializes the response as RFC7807 `application/problem+json`. +//! - Adds `Retry-After` when retry advice is present. +//! - Adds `WWW-Authenticate` when an authentication challenge is provided. +//! - Redacts message and metadata when the error is marked private. use actix_web::{ HttpRequest, HttpResponse, Responder, body::BoxBody, - http::header::{RETRY_AFTER, WWW_AUTHENTICATE} + http::header::{CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE} }; -use super::ErrorResponse; +use super::{ErrorResponse, ProblemJson}; + +pub(crate) fn respond_with_problem_json(mut problem: ProblemJson) -> HttpResponse { + let http_status = problem.status_code(); + let status = actix_web::http::StatusCode::from_u16(http_status.as_u16()) + .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR); + let retry_after = problem.retry_after; + let www_authenticate = problem.www_authenticate.take(); + + let mut builder = HttpResponse::build(status); + builder.insert_header((CONTENT_TYPE, "application/problem+json")); + + if let Some(retry) = retry_after { + builder.insert_header((RETRY_AFTER, retry.to_string())); + } + if let Some(challenge) = www_authenticate { + builder.insert_header((WWW_AUTHENTICATE, challenge)); + } + + builder.json(problem) +} + +impl Responder for ProblemJson { + type Body = BoxBody; + + fn respond_to(self, _req: &HttpRequest) -> HttpResponse { + respond_with_problem_json(self) + } +} impl Responder for ErrorResponse { type Body = BoxBody; fn respond_to(self, _req: &HttpRequest) -> HttpResponse { - let mut builder = HttpResponse::build( - actix_web::http::StatusCode::from_u16(self.status) - .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR) - ); - if let Some(retry) = self.retry { - builder.insert_header((RETRY_AFTER, retry.after_seconds.to_string())); - } - if let Some(ref ch) = self.www_authenticate { - // Pass &str, not &String, to satisfy TryIntoHeaderPair - builder.insert_header((WWW_AUTHENTICATE, ch.as_str())); - } - builder.json(self) + respond_with_problem_json(ProblemJson::from_error_response(self)) } } diff --git a/src/response/axum_impl.rs b/src/response/axum_impl.rs index 2dacae7..69fe5f0 100644 --- a/src/response/axum_impl.rs +++ b/src/response/axum_impl.rs @@ -1,36 +1,44 @@ -//! Axum integration: implements [`IntoResponse`] for [`ErrorResponse`]. +//! Axum integration: implements [`IntoResponse`] for [`ProblemJson`] and +//! [`ErrorResponse`]. //! //! Behavior: -//! - Serializes the response as JSON with the given status. -//! - Adds `Retry-After` if [`ErrorResponse::retry`] is present. -//! - Adds `WWW-Authenticate` if [`ErrorResponse::www_authenticate`] is present. +//! - Serializes the response as `application/problem+json` with the given +//! status. +//! - Adds `Retry-After` if retry advice is present. +//! - Adds `WWW-Authenticate` if an authentication challenge is present. +//! - Redacts the message and metadata when the error is marked as private. use axum::{ Json, http::{ HeaderValue, - header::{RETRY_AFTER, WWW_AUTHENTICATE} + header::{CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE} }, response::{IntoResponse, Response} }; -use super::ErrorResponse; -use crate::AppError; +use super::{ErrorResponse, ProblemJson}; -impl IntoResponse for ErrorResponse { +impl IntoResponse for ProblemJson { fn into_response(self) -> Response { - let status = self.status_code(); + let mut body = self; + let status = body.status_code(); + let retry_after = body.retry_after; + let www_authenticate = body.www_authenticate.take(); + let mut response = (status, Json(body)).into_response(); - // Serialize JSON body first (borrow self for payload). - let mut response = (status, Json(&self)).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/problem+json") + ); - if let Some(retry) = self.retry - && let Ok(hv) = HeaderValue::from_str(&retry.after_seconds.to_string()) + 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(ch) = &self.www_authenticate - && let Ok(hv) = HeaderValue::from_str(ch) + if let Some(challenge) = www_authenticate + && let Ok(hv) = HeaderValue::from_str(&challenge) { response.headers_mut().insert(WWW_AUTHENTICATE, hv); } @@ -39,11 +47,8 @@ impl IntoResponse for ErrorResponse { } } -/// Convert `AppError` into the stable wire model and reuse its `IntoResponse`. -impl IntoResponse for AppError { +impl IntoResponse for ErrorResponse { fn into_response(self) -> Response { - // Use the canonical mapping defined in `From<&AppError> for ErrorResponse` - let wire: ErrorResponse = (&self).into(); - wire.into_response() + ProblemJson::from_error_response(self).into_response() } } diff --git a/src/response/core.rs b/src/response/core.rs index f6bb5fe..6b429f4 100644 --- a/src/response/core.rs +++ b/src/response/core.rs @@ -62,6 +62,7 @@ impl ErrorResponse { /// # Errors /// /// Returns [`AppError`] if `status` is not a valid HTTP status code. + #[allow(clippy::result_large_err)] pub fn new(status: u16, code: AppCode, message: impl Into) -> AppResult { StatusCode::from_u16(status) .map_err(|_| AppError::bad_request(format!("invalid HTTP status: {status}")))?; @@ -91,4 +92,10 @@ impl ErrorResponse { pub fn status_code(&self) -> StatusCode { StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) } + + /// Formatter exposing internals for diagnostic logs. + #[must_use] + pub fn internal(&self) -> crate::response::internal::ErrorResponseFormatter<'_> { + crate::response::internal::ErrorResponseFormatter::new(self) + } } diff --git a/src/response/details.rs b/src/response/details.rs index ceca799..faa699e 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -54,6 +54,7 @@ impl ErrorResponse { /// assert!(resp.details.is_some()); /// # } /// ``` + #[allow(clippy::result_large_err)] pub fn with_details(self, payload: T) -> AppResult where T: Serialize diff --git a/src/response/internal.rs b/src/response/internal.rs new file mode 100644 index 0000000..1cd90bd --- /dev/null +++ b/src/response/internal.rs @@ -0,0 +1,74 @@ +use std::fmt::{self, Debug, Display, Formatter}; + +use super::{core::ErrorResponse, problem_json::ProblemJson}; + +/// Formatter exposing response internals for opt-in diagnostics. +#[derive(Clone, Copy)] +pub struct ErrorResponseFormatter<'a> { + inner: &'a ErrorResponse +} + +impl<'a> ErrorResponseFormatter<'a> { + pub(crate) fn new(inner: &'a ErrorResponse) -> Self { + Self { + inner + } + } +} + +impl Debug for ErrorResponseFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("ErrorResponse") + .field("status", &self.inner.status) + .field("code", &self.inner.code) + .field("message", &self.inner.message) + .field("details", &self.inner.details) + .field("retry", &self.inner.retry) + .field("www_authenticate", &self.inner.www_authenticate) + .finish() + } +} + +impl Display for ErrorResponseFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self.inner, f) + } +} + +/// Formatter exposing problem-json internals for opt-in diagnostics. +#[derive(Clone, Copy)] +pub struct ProblemJsonFormatter<'a> { + inner: &'a ProblemJson +} + +impl<'a> ProblemJsonFormatter<'a> { + pub(crate) fn new(inner: &'a ProblemJson) -> Self { + Self { + inner + } + } +} + +impl Debug for ProblemJsonFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("ProblemJson") + .field("type", &self.inner.type_uri) + .field("title", &self.inner.title) + .field("status", &self.inner.status) + .field("detail", &self.inner.detail) + .field("code", &self.inner.code) + .field("grpc", &self.inner.grpc) + .field("metadata", &self.inner.metadata) + .finish() + } +} + +impl Display for ProblemJsonFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {}: {:?}", + self.inner.status, self.inner.code, self.inner.detail + ) + } +} diff --git a/src/response/mapping.rs b/src/response/mapping.rs index 866b8c5..c0d2302 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -1,10 +1,7 @@ -use std::{ - borrow::Cow, - fmt::{Display, Formatter, Result as FmtResult} -}; +use std::fmt::{Display, Formatter, Result as FmtResult}; use super::core::ErrorResponse; -use crate::{AppCode, AppError}; +use crate::AppError; impl Display for ErrorResponse { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { @@ -14,19 +11,17 @@ impl Display for ErrorResponse { } impl From for ErrorResponse { - fn from(err: AppError) -> Self { - let AppError { - kind, - message, - retry, - www_authenticate - } = err; + fn from(mut err: AppError) -> Self { + let kind = err.kind; + let code = err.code; + let retry = err.retry.take(); + let www_authenticate = err.www_authenticate.take(); + let policy = err.edit_policy; let status = kind.http_status(); - let code = AppCode::from(kind); - let message = match message { - Some(msg) => msg.into_owned(), - None => String::from("An error occurred") + let message = match err.message.take() { + Some(msg) if !matches!(policy, crate::MessageEditPolicy::Redact) => msg.into_owned(), + _ => kind.to_string() }; Self { @@ -43,17 +38,15 @@ impl From for ErrorResponse { impl From<&AppError> for ErrorResponse { fn from(err: &AppError) -> Self { let status = err.kind.http_status(); - let code = AppCode::from(err.kind); - - let message = err - .message - .clone() - .unwrap_or(Cow::Borrowed("An error occurred")) - .into_owned(); + let message = if matches!(err.edit_policy, crate::MessageEditPolicy::Redact) { + err.kind.to_string() + } else { + err.render_message().into_owned() + }; Self { status, - code, + code: err.code, message, details: None, retry: err.retry, diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs new file mode 100644 index 0000000..388da07 --- /dev/null +++ b/src/response/problem_json.rs @@ -0,0 +1,975 @@ +use std::{borrow::Cow, collections::BTreeMap, fmt::Write}; + +use http::StatusCode; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use super::core::ErrorResponse; +use crate::{ + AppCode, AppError, AppErrorKind, FieldRedaction, FieldValue, MessageEditPolicy, Metadata +}; + +/// Canonical mapping for a public [`AppCode`]. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppCode, mapping_for_code}; +/// +/// let mapping = mapping_for_code(AppCode::NotFound); +/// assert_eq!(mapping.http_status(), 404); +/// assert_eq!( +/// mapping.problem_type(), +/// "https://errors.masterror.rs/not-found" +/// ); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CodeMapping { + http_status: u16, + grpc: GrpcCode, + problem_type: &'static str, + kind: AppErrorKind +} + +impl CodeMapping { + /// HTTP status code associated with the [`AppCode`]. + #[cfg_attr(not(any(test, feature = "tonic")), allow(dead_code))] + #[must_use] + pub const fn http_status(&self) -> u16 { + self.http_status + } + + /// gRPC code mapping (`tonic::Code` discriminant). + #[must_use] + pub const fn grpc(&self) -> GrpcCode { + self.grpc + } + + /// Canonical RFC 7807 problem type URI. + #[must_use] + pub const fn problem_type(&self) -> &'static str { + self.problem_type + } + + /// Canonical error kind for presentation. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } +} + +/// gRPC status metadata used in RFC7807 payloads and tonic mapping. +/// +/// The `value` matches the discriminant of `tonic::Code`, allowing direct +/// conversion when the `tonic` feature is enabled. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppCode, mapping_for_code}; +/// +/// let grpc = mapping_for_code(AppCode::Internal).grpc(); +/// assert_eq!(grpc.name, "INTERNAL"); +/// assert_eq!(grpc.value, 13); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct GrpcCode { + /// Canonical name (e.g. `"NOT_FOUND"`). + pub name: &'static str, + /// Numeric discriminant matching `tonic::Code`. + pub value: i32 +} + +/// RFC7807 `application/problem+json` payload enriched with machine-readable +/// metadata. +/// +/// Instances are produced by [`ProblemJson::from_app_error`] or +/// [`ProblemJson::from_ref`]. They power the HTTP adapters and expose +/// transport-neutral data for tests. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppError, ProblemJson}; +/// +/// let problem = ProblemJson::from_ref(&AppError::not_found("missing")); +/// assert_eq!(problem.status, 404); +/// assert_eq!(problem.code.as_str(), "NOT_FOUND"); +/// ``` +#[derive(Clone, Debug, Serialize)] +pub struct ProblemJson { + /// Canonical type URI describing the problem class. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_uri: Option>, + /// Short, human-friendly title describing the error category. + pub title: Cow<'static, str>, + /// HTTP status code returned to the client. + pub status: u16, + /// Optional human-readable detail (redacted when marked private). + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option>, + /// Stable machine-readable code. + pub code: AppCode, + /// Optional gRPC mapping for multi-protocol clients. + #[serde(skip_serializing_if = "Option::is_none")] + pub grpc: Option, + /// Structured metadata derived from [`Metadata`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// Retry advice propagated as the `Retry-After` header. + #[serde(skip)] + pub retry_after: Option, + /// Authentication challenge propagated as `WWW-Authenticate`. + #[serde(skip)] + pub www_authenticate: Option +} + +impl ProblemJson { + /// Build a problem payload from an owned [`AppError`]. + /// + /// # Preconditions + /// - `error.code` must be a public [`AppCode`] (guaranteed by + /// construction). + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppCode, AppError, ProblemJson}; + /// + /// let problem = ProblemJson::from_app_error(AppError::conflict("exists")); + /// assert_eq!(problem.code, AppCode::Conflict); + /// assert_eq!(problem.status, 409); + /// ``` + #[must_use] + pub fn from_app_error(mut error: AppError) -> Self { + error.emit_telemetry(); + + let code = error.code; + let kind = error.kind; + let message = error.message.take(); + let metadata = core::mem::take(&mut error.metadata); + let edit_policy = error.edit_policy; + let retry = error.retry.take(); + let www_authenticate = error.www_authenticate.take(); + + let mapping = mapping_for_code(code); + let status = kind.http_status(); + let title = Cow::Owned(kind.to_string()); + let detail = sanitize_detail(message, kind, edit_policy); + let metadata = sanitize_metadata_owned(metadata, edit_policy); + + Self { + type_uri: Some(Cow::Borrowed(mapping.problem_type())), + title, + status, + detail, + code, + grpc: Some(mapping.grpc()), + metadata, + retry_after: retry.map(|value| value.after_seconds), + www_authenticate + } + } + + /// Build a problem payload from a borrowed [`AppError`]. + /// + /// This is useful inside middleware that logs while forwarding the error + /// downstream without consuming it. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, ProblemJson}; + /// + /// let err = AppError::bad_request("invalid"); + /// let problem = ProblemJson::from_ref(&err); + /// assert_eq!(problem.status, 400); + /// assert!(problem.detail.is_some()); + /// ``` + #[must_use] + pub fn from_ref(error: &AppError) -> Self { + let mapping = mapping_for_code(error.code); + let status = error.kind.http_status(); + let title = Cow::Owned(error.kind.to_string()); + let detail = sanitize_detail_ref(error); + let metadata = sanitize_metadata_ref(error.metadata(), error.edit_policy); + + Self { + type_uri: Some(Cow::Borrowed(mapping.problem_type())), + title, + status, + detail, + code: error.code, + grpc: Some(mapping.grpc()), + metadata, + retry_after: error.retry.map(|value| value.after_seconds), + www_authenticate: error.www_authenticate.clone() + } + } + + /// Build a problem payload from a plain [`ErrorResponse`]. + /// + /// Metadata and redaction hints are not available in this conversion. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppCode, ErrorResponse, ProblemJson}; + /// + /// let legacy = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); + /// let problem = ProblemJson::from_error_response(legacy); + /// assert_eq!(problem.status, 404); + /// assert_eq!(problem.code.as_str(), "NOT_FOUND"); + /// ``` + #[must_use] + pub fn from_error_response(response: ErrorResponse) -> Self { + let mapping = mapping_for_code(response.code); + let detail = if response.message.is_empty() { + None + } else { + Some(Cow::Owned(response.message)) + }; + + Self { + type_uri: Some(Cow::Borrowed(mapping.problem_type())), + title: Cow::Owned(mapping.kind().to_string()), + status: response.status, + detail, + code: response.code, + grpc: Some(mapping.grpc()), + metadata: None, + retry_after: response.retry.map(|value| value.after_seconds), + www_authenticate: response.www_authenticate + } + } + + /// Convert numeric status into [`StatusCode`]. + /// + /// Falls back to `500 Internal Server Error` if the value is invalid. + /// + /// # Examples + /// + /// ```rust + /// use http::StatusCode; + /// use masterror::{AppError, ProblemJson}; + /// + /// let problem = ProblemJson::from_app_error(AppError::service("oops")); + /// assert_eq!(problem.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + /// ``` + #[must_use] + pub fn status_code(&self) -> StatusCode { + match StatusCode::from_u16(self.status) { + Ok(status) => status, + Err(_) => StatusCode::INTERNAL_SERVER_ERROR + } + } + + /// Formatter exposing internals for diagnostic logging. + #[must_use] + pub fn internal(&self) -> crate::response::internal::ProblemJsonFormatter<'_> { + crate::response::internal::ProblemJsonFormatter::new(self) + } +} + +/// Metadata section of a [`ProblemJson`] payload. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppError, ProblemJson}; +/// +/// let err = AppError::service("retry").with_field(masterror::field::u64("attempt", 1)); +/// let problem = ProblemJson::from_ref(&err); +/// assert!(problem.metadata.is_some()); +/// ``` +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct ProblemMetadata(BTreeMap, ProblemMetadataValue>); + +impl ProblemMetadata { + #[cfg(test)] + fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// Individual metadata value serialized in problem payloads. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{ProblemMetadataValue, field}; +/// +/// let (_name, field_value, _redaction) = field::u64("attempt", 2).into_parts(); +/// let value = ProblemMetadataValue::from(field_value); +/// assert!(matches!(value, ProblemMetadataValue::U64(2))); +/// ``` +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +pub enum ProblemMetadataValue { + /// String value preserved as-is. + String(Cow<'static, str>), + /// Signed 64-bit integer. + I64(i64), + /// Unsigned 64-bit integer. + U64(u64), + /// Boolean flag serialized as `true`/`false`. + Bool(bool) +} + +impl From for ProblemMetadataValue { + fn from(value: FieldValue) -> Self { + match value { + FieldValue::Str(value) => Self::String(value), + FieldValue::I64(value) => Self::I64(value), + FieldValue::U64(value) => Self::U64(value), + FieldValue::Bool(value) => Self::Bool(value), + FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())) + } + } +} + +impl From<&FieldValue> for ProblemMetadataValue { + fn from(value: &FieldValue) -> Self { + match value { + FieldValue::Str(value) => Self::String(value.clone()), + FieldValue::I64(value) => Self::I64(*value), + FieldValue::U64(value) => Self::U64(*value), + FieldValue::Bool(value) => Self::Bool(*value), + FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())) + } + } +} + +fn sanitize_detail( + message: Option>, + kind: AppErrorKind, + policy: MessageEditPolicy +) -> Option> { + if matches!(policy, MessageEditPolicy::Redact) { + return None; + } + + Some(message.unwrap_or_else(|| Cow::Owned(kind.to_string()))) +} + +fn sanitize_detail_ref(error: &AppError) -> Option> { + if matches!(error.edit_policy, MessageEditPolicy::Redact) { + return None; + } + + Some(Cow::Owned(error.render_message().into_owned())) +} + +fn sanitize_metadata_owned( + metadata: Metadata, + policy: MessageEditPolicy +) -> Option { + if matches!(policy, MessageEditPolicy::Redact) || metadata.is_empty() { + return None; + } + + let mut public = BTreeMap::new(); + for field in metadata { + let (name, value, redaction) = field.into_parts(); + if let Some(sanitized) = sanitize_problem_metadata_value_owned(value, redaction) { + public.insert(Cow::Borrowed(name), sanitized); + } + } + + if public.is_empty() { + None + } else { + Some(ProblemMetadata(public)) + } +} + +fn sanitize_metadata_ref( + metadata: &Metadata, + policy: MessageEditPolicy +) -> Option { + if matches!(policy, MessageEditPolicy::Redact) || metadata.is_empty() { + return None; + } + + let mut public = BTreeMap::new(); + for (name, value, redaction) in metadata.iter_with_redaction() { + if let Some(sanitized) = sanitize_problem_metadata_value_ref(value, redaction) { + public.insert(Cow::Borrowed(name), sanitized); + } + } + + if public.is_empty() { + None + } else { + Some(ProblemMetadata(public)) + } +} + +const REDACTED_PLACEHOLDER: &str = "[REDACTED]"; + +fn sanitize_problem_metadata_value_owned( + value: FieldValue, + redaction: FieldRedaction +) -> Option { + match redaction { + FieldRedaction::None => Some(ProblemMetadataValue::from(value)), + FieldRedaction::Redact => Some(ProblemMetadataValue::String(Cow::Borrowed( + REDACTED_PLACEHOLDER + ))), + FieldRedaction::Hash => Some(ProblemMetadataValue::String(Cow::Owned(hash_field_value( + &value + )))), + FieldRedaction::Last4 => mask_last4_field_value(&value) + .map(|masked| ProblemMetadataValue::String(Cow::Owned(masked))) + } +} + +fn sanitize_problem_metadata_value_ref( + value: &FieldValue, + redaction: FieldRedaction +) -> Option { + match redaction { + FieldRedaction::None => Some(ProblemMetadataValue::from(value)), + FieldRedaction::Redact => Some(ProblemMetadataValue::String(Cow::Borrowed( + REDACTED_PLACEHOLDER + ))), + FieldRedaction::Hash => Some(ProblemMetadataValue::String(Cow::Owned(hash_field_value( + value + )))), + FieldRedaction::Last4 => mask_last4_field_value(value) + .map(|masked| ProblemMetadataValue::String(Cow::Owned(masked))) + } +} + +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()); + } + FieldValue::U64(value) => { + let string = value.to_string(); + hasher.update(string.as_bytes()); + } + FieldValue::Bool(value) => { + if *value { + hasher.update(b"true"); + } else { + hasher.update(b"false"); + } + } + FieldValue::Uuid(value) => { + let string = value.to_string(); + hasher.update(string.as_bytes()); + } + } + let digest = hasher.finalize(); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(&mut hex, "{:02x}", byte); + } + hex +} + +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::Uuid(value) => Some(mask_last4(&value.to_string())), + FieldValue::Bool(_) => None + } +} + +fn mask_last4(value: &str) -> String { + let chars: Vec = value.chars().collect(); + let total = chars.len(); + if total == 0 { + return String::new(); + } + + 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 +} + +/// Canonical mapping table covering every built-in [`AppCode`]. +/// +/// # Examples +/// +/// ```rust +/// use masterror::CODE_MAPPINGS; +/// +/// assert!( +/// CODE_MAPPINGS +/// .iter() +/// .any(|(code, _)| code.as_str() == "NOT_FOUND") +/// ); +/// ``` +pub const CODE_MAPPINGS: &[(AppCode, CodeMapping)] = &[ + ( + AppCode::NotFound, + CodeMapping { + http_status: 404, + grpc: GrpcCode { + name: "NOT_FOUND", + value: 5 + }, + problem_type: "https://errors.masterror.rs/not-found", + kind: AppErrorKind::NotFound + } + ), + ( + AppCode::Validation, + CodeMapping { + http_status: 422, + grpc: GrpcCode { + name: "INVALID_ARGUMENT", + value: 3 + }, + problem_type: "https://errors.masterror.rs/validation", + kind: AppErrorKind::Validation + } + ), + ( + AppCode::Conflict, + CodeMapping { + http_status: 409, + grpc: GrpcCode { + name: "ALREADY_EXISTS", + value: 6 + }, + problem_type: "https://errors.masterror.rs/conflict", + kind: AppErrorKind::Conflict + } + ), + ( + AppCode::UserAlreadyExists, + CodeMapping { + http_status: 409, + grpc: GrpcCode { + name: "ALREADY_EXISTS", + value: 6 + }, + problem_type: "https://errors.masterror.rs/user-already-exists", + kind: AppErrorKind::Conflict + } + ), + ( + AppCode::Unauthorized, + CodeMapping { + http_status: 401, + grpc: GrpcCode { + name: "UNAUTHENTICATED", + value: 16 + }, + problem_type: "https://errors.masterror.rs/unauthorized", + kind: AppErrorKind::Unauthorized + } + ), + ( + AppCode::Forbidden, + CodeMapping { + http_status: 403, + grpc: GrpcCode { + name: "PERMISSION_DENIED", + value: 7 + }, + problem_type: "https://errors.masterror.rs/forbidden", + kind: AppErrorKind::Forbidden + } + ), + ( + AppCode::NotImplemented, + CodeMapping { + http_status: 501, + grpc: GrpcCode { + name: "UNIMPLEMENTED", + value: 12 + }, + problem_type: "https://errors.masterror.rs/not-implemented", + kind: AppErrorKind::NotImplemented + } + ), + ( + AppCode::BadRequest, + CodeMapping { + http_status: 400, + grpc: GrpcCode { + name: "INVALID_ARGUMENT", + value: 3 + }, + problem_type: "https://errors.masterror.rs/bad-request", + kind: AppErrorKind::BadRequest + } + ), + ( + AppCode::RateLimited, + CodeMapping { + http_status: 429, + grpc: GrpcCode { + name: "RESOURCE_EXHAUSTED", + value: 8 + }, + problem_type: "https://errors.masterror.rs/rate-limited", + kind: AppErrorKind::RateLimited + } + ), + ( + AppCode::TelegramAuth, + CodeMapping { + http_status: 401, + grpc: GrpcCode { + name: "UNAUTHENTICATED", + value: 16 + }, + problem_type: "https://errors.masterror.rs/telegram-auth", + kind: AppErrorKind::TelegramAuth + } + ), + ( + AppCode::InvalidJwt, + CodeMapping { + http_status: 401, + grpc: GrpcCode { + name: "UNAUTHENTICATED", + value: 16 + }, + problem_type: "https://errors.masterror.rs/invalid-jwt", + kind: AppErrorKind::InvalidJwt + } + ), + ( + AppCode::Internal, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/internal", + kind: AppErrorKind::Internal + } + ), + ( + AppCode::Database, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/database", + kind: AppErrorKind::Database + } + ), + ( + AppCode::Service, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/service", + kind: AppErrorKind::Service + } + ), + ( + AppCode::Config, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/config", + kind: AppErrorKind::Config + } + ), + ( + AppCode::Turnkey, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/turnkey", + kind: AppErrorKind::Turnkey + } + ), + ( + AppCode::Timeout, + CodeMapping { + http_status: 504, + grpc: GrpcCode { + name: "DEADLINE_EXCEEDED", + value: 4 + }, + problem_type: "https://errors.masterror.rs/timeout", + kind: AppErrorKind::Timeout + } + ), + ( + AppCode::Network, + CodeMapping { + http_status: 503, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/network", + kind: AppErrorKind::Network + } + ), + ( + AppCode::DependencyUnavailable, + CodeMapping { + http_status: 503, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/dependency-unavailable", + kind: AppErrorKind::DependencyUnavailable + } + ), + ( + AppCode::Serialization, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/serialization", + kind: AppErrorKind::Serialization + } + ), + ( + AppCode::Deserialization, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/deserialization", + kind: AppErrorKind::Deserialization + } + ), + ( + AppCode::ExternalApi, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/external-api", + kind: AppErrorKind::ExternalApi + } + ), + ( + AppCode::Queue, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/queue", + kind: AppErrorKind::Queue + } + ), + ( + AppCode::Cache, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/cache", + kind: AppErrorKind::Cache + } + ) +]; + +const DEFAULT_MAPPING: CodeMapping = CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/internal", + kind: AppErrorKind::Internal +}; + +/// Lookup helper returning canonical mapping for a given [`AppCode`]. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppCode, mapping_for_code}; +/// +/// let mapping = mapping_for_code(AppCode::Timeout); +/// assert_eq!(mapping.grpc().name, "DEADLINE_EXCEEDED"); +/// ``` +#[must_use] +pub fn mapping_for_code(code: AppCode) -> CodeMapping { + CODE_MAPPINGS + .iter() + .find_map(|(candidate, mapping)| { + if *candidate == code { + Some(*mapping) + } else { + None + } + }) + .unwrap_or(DEFAULT_MAPPING) +} + +#[cfg(test)] +mod tests { + use std::fmt::Write; + + use serde_json::Value; + use sha2::{Digest, Sha256}; + + use super::*; + use crate::AppError; + + #[test] + fn metadata_is_skipped_when_redacted() { + let err = AppError::internal("secret") + .redactable() + .with_field(crate::field::str("token", "super-secret")); + let problem = ProblemJson::from_ref(&err); + assert!(problem.detail.is_none()); + assert!(problem.metadata.is_none()); + } + + #[test] + fn metadata_is_serialized_when_allowed() { + let err = AppError::internal("oops").with_field(crate::field::u64("attempt", 2)); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + assert!(!metadata.is_empty()); + } + + #[test] + fn redacted_metadata_uses_placeholder() { + let err = AppError::internal("oops").with_field(crate::field::str("password", "secret")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("password").expect("password field"); + match value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.as_ref(), super::REDACTED_PLACEHOLDER); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn hashed_metadata_masks_original_value() { + let err = AppError::internal("oops").with_field(crate::field::str("token", "super")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("token").expect("token field"); + match value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.len(), 64); + assert_ne!(text.as_ref(), "super"); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn last4_metadata_preserves_suffix() { + let err = AppError::internal("oops") + .with_field(crate::field::str("card_number", "4111111111111111")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("card_number").expect("card number"); + match value { + ProblemMetadataValue::String(text) => { + assert!(text.ends_with("1111")); + assert!(text.starts_with("************")); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn problem_json_serialization_masks_sensitive_metadata() { + let secret = "super-secret"; + let err = AppError::internal("oops").with_field(crate::field::str("token", secret)); + let problem = ProblemJson::from_ref(&err); + let json = serde_json::to_value(&problem).expect("serialize problem"); + + let metadata = json + .get("metadata") + .and_then(Value::as_object) + .expect("metadata present"); + let hashed = metadata + .get("token") + .and_then(Value::as_str) + .expect("hashed token"); + + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + let digest = hasher.finalize(); + let expected = digest + .iter() + .fold(String::with_capacity(64), |mut acc, byte| { + let _ = write!(&mut acc, "{:02x}", byte); + acc + }); + + assert_eq!(hashed, expected); + assert!(!json.to_string().contains(secret)); + + let debug_repr = format!("{:?}", problem.internal()); + assert!(debug_repr.contains("metadata")); + assert!(!debug_repr.contains(secret)); + } + + #[test] + fn problem_json_serialization_omits_metadata_when_redacted() { + let secret_value = "sensitive-value"; + let err = AppError::internal("secret") + .redactable() + .with_field(crate::field::str("token", secret_value)); + let problem = ProblemJson::from_ref(&err); + let json = serde_json::to_value(&problem).expect("serialize problem"); + + assert!(json.get("metadata").is_none()); + assert!(!json.to_string().contains(secret_value)); + + let debug_repr = format!("{:?}", problem.internal()); + assert!(debug_repr.contains("ProblemJson")); + } + + #[test] + fn mapping_for_every_code_matches_http_status() { + for (code, mapping) in CODE_MAPPINGS { + let status = mapping.http_status(); + let expected = mapping.kind().http_status(); + assert_eq!(status, expected, "status mismatch for {:?}", code); + } + } +} diff --git a/src/response/tests.rs b/src/response/tests.rs index 38e7be8..d7f11b0 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -1,5 +1,5 @@ use super::ErrorResponse; -use crate::{AppCode, AppError, AppErrorKind}; +use crate::{AppCode, AppError, AppErrorKind, ProblemJson}; // --- Basic constructors and fields -------------------------------------- @@ -144,7 +144,7 @@ fn from_app_error_uses_default_message_when_none() { let e: ErrorResponse = (&app).into(); assert_eq!(e.status, 500); assert!(matches!(e.code, AppCode::Internal)); - assert_eq!(e.message, "An error occurred"); + assert_eq!(e.message, AppErrorKind::Internal.to_string()); } #[test] @@ -168,7 +168,43 @@ fn from_owned_app_error_defaults_message_when_absent() { assert_eq!(resp.status, 500); assert!(matches!(resp.code, AppCode::Internal)); - assert_eq!(resp.message, "An error occurred"); + assert_eq!(resp.message, AppErrorKind::Internal.to_string()); +} + +#[test] +fn from_app_error_bare_uses_kind_display_as_message() { + let app = AppError::bare(AppErrorKind::Timeout); + let resp: ErrorResponse = app.into(); + + assert_eq!(resp.status, 504); + assert!(matches!(resp.code, AppCode::Timeout)); + assert_eq!(resp.message, AppErrorKind::Timeout.to_string()); +} + +#[test] +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()); + + let borrowed = AppError::internal("private").redactable(); + let resp_ref: ErrorResponse = (&borrowed).into(); + assert_eq!(resp_ref.message, AppErrorKind::Internal.to_string()); +} + +#[test] +fn error_response_serialization_hides_redacted_message() { + let secret = "super-secret"; + let resp: ErrorResponse = AppError::internal(secret).redactable().into(); + let json = serde_json::to_value(&resp).expect("serialize response"); + + let fallback = AppErrorKind::Internal.to_string(); + assert_eq!( + json.get("message").and_then(|value| value.as_str()), + Some(fallback.as_str()) + ); + assert!(!json.to_string().contains(secret)); } // --- Display formatting -------------------------------------------------- @@ -291,6 +327,17 @@ fn serialized_json_contains_core_fields() { assert!(s.contains("\"after_seconds\":1")); } +#[test] +fn internal_formatters_are_opt_in() { + let resp = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); + let formatted = format!("{:?}", resp.internal()); + assert!(formatted.contains("ErrorResponse")); + + let problem = ProblemJson::from_ref(&AppError::not_found("missing")); + let formatted_problem = format!("{:?}", problem.internal()); + assert!(formatted_problem.contains("ProblemJson")); +} + #[cfg(feature = "axum")] #[test] fn app_error_into_response_maps_status() { diff --git a/src/result_ext.rs b/src/result_ext.rs new file mode 100644 index 0000000..31d5f6c --- /dev/null +++ b/src/result_ext.rs @@ -0,0 +1,218 @@ +use std::error::Error as StdError; + +use crate::app_error::{Context, Error}; + +/// Extension trait for enriching `Result` errors with [`Context`]. +/// +/// The [`ctx`](ResultExt::ctx) method converts the error side of a `Result` +/// into [`Error`] while attaching metadata, category and edit policy captured +/// by [`Context`]. +/// +/// # Examples +/// +/// ```rust +/// use std::io::{Error as IoError, ErrorKind}; +/// +/// use masterror::{AppErrorKind, Context, ResultExt, field}; +/// +/// fn validate() -> Result<(), IoError> { +/// Err(IoError::from(ErrorKind::Other)) +/// } +/// +/// let err = validate() +/// .ctx(|| Context::new(AppErrorKind::Validation).with(field::str("phase", "validate"))) +/// .unwrap_err(); +/// +/// assert_eq!(err.kind, AppErrorKind::Validation); +/// assert!(err.metadata().get("phase").is_some()); +/// ``` +pub trait ResultExt { + /// Convert an error into [`Error`] using [`Context`] supplied by `build`. + #[allow(clippy::result_large_err)] + fn ctx(self, build: impl FnOnce() -> Context) -> Result + where + E: StdError + Send + Sync + 'static; +} + +impl ResultExt for Result { + fn ctx(self, build: impl FnOnce() -> Context) -> Result + where + E: StdError + Send + Sync + 'static + { + self.map_err(|err| build().into_error(err)) + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "backtrace")] + use std::sync::Mutex; + use std::{ + borrow::Cow, + error::Error as StdError, + fmt::{Display, Formatter, Result as FmtResult}, + sync::Arc + }; + + use super::ResultExt; + #[cfg(feature = "backtrace")] + use crate::app_error::{reset_backtrace_preference, set_backtrace_preference_override}; + use crate::{ + AppCode, AppErrorKind, + app_error::{Context, FieldValue, MessageEditPolicy}, + field + }; + + #[cfg(feature = "backtrace")] + static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); + + #[derive(Debug)] + struct DummyError; + + impl Display for DummyError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + f.write_str("dummy") + } + } + + impl StdError for DummyError {} + + #[derive(Debug)] + struct LayeredError { + inner: DummyError + } + + impl Display for LayeredError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&self.inner, f) + } + } + + impl StdError for LayeredError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.inner) + } + } + + #[test] + fn ctx_preserves_ok() { + let res: Result = Ok(5); + let value = res + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect("ok"); + assert_eq!(value, 5); + } + + #[test] + fn ctx_wraps_err_with_context() { + let result: Result<(), DummyError> = Err(DummyError); + let err = result + .ctx(|| { + Context::new(AppErrorKind::Service) + .with(field::str("operation", "sync")) + .redact(true) + .track_caller() + }) + .expect_err("err"); + + assert_eq!(err.kind, AppErrorKind::Service); + assert_eq!(err.code, AppCode::Service); + assert!(matches!(err.edit_policy, MessageEditPolicy::Redact)); + + let metadata = err.metadata(); + assert_eq!( + metadata.get("operation"), + Some(&FieldValue::Str(Cow::Borrowed("sync"))) + ); + let caller_file = metadata.get("caller.file").expect("caller file field"); + assert_eq!(caller_file, &FieldValue::Str(Cow::Borrowed(file!()))); + assert!(metadata.get("caller.line").is_some()); + assert!(metadata.get("caller.column").is_some()); + } + + #[test] + fn ctx_preserves_error_chain() { + let err = Result::<(), LayeredError>::Err(LayeredError { + inner: DummyError + }) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + + let mut source = StdError::source(&err).expect("layered source"); + assert!(source.is::()); + source = source.source().expect("inner source"); + assert!(source.is::()); + } + + #[derive(Debug, Clone)] + struct SharedError(Arc); + + #[derive(Debug)] + struct InnerError; + + impl Display for InnerError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + f.write_str("inner") + } + } + + impl StdError for InnerError {} + + impl Display for SharedError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&*self.0, f) + } + } + + impl StdError for SharedError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&*self.0) + } + } + + #[test] + fn ctx_preserves_source_without_extra_arc_clone() { + let inner = Arc::new(InnerError); + let shared = SharedError(inner.clone()); + let err = Result::<(), SharedError>::Err(shared.clone()) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + + drop(shared); + assert_eq!(Arc::strong_count(&inner), 2); + + let stored = err + .source_ref() + .and_then(|src| src.downcast_ref::()) + .expect("shared source"); + assert!(Arc::ptr_eq(&stored.0, &inner)); + } + + #[cfg(feature = "backtrace")] + fn with_backtrace_preference(value: Option, test: impl FnOnce()) { + let _guard = BACKTRACE_ENV_GUARD.lock().expect("env guard"); + reset_backtrace_preference(); + set_backtrace_preference_override(value); + test(); + set_backtrace_preference_override(None); + reset_backtrace_preference(); + } + + #[cfg(feature = "backtrace")] + #[test] + fn ctx_respects_backtrace_environment() { + with_backtrace_preference(Some(false), || { + let err = Result::<(), DummyError>::Err(DummyError) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + assert!(err.backtrace().is_none()); + }); + + with_backtrace_preference(Some(true), || { + let err = Result::<(), DummyError>::Err(DummyError) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + assert!(err.backtrace().is_some()); + }); + } +} diff --git a/tests/ensure_fail.rs b/tests/ensure_fail.rs new file mode 100644 index 0000000..992dffc --- /dev/null +++ b/tests/ensure_fail.rs @@ -0,0 +1,78 @@ +use core::sync::atomic::{AtomicUsize, Ordering}; + +use masterror::{AppError, AppErrorKind, AppResult}; + +static CALLS: AtomicUsize = AtomicUsize::new(0); + +#[test] +fn ensure_allows_success_path() { + fn run(flag: bool) -> AppResult<&'static str> { + masterror::ensure!(flag, AppError::bad_request("flag required")); + Ok("ok") + } + + assert_eq!(run(true).unwrap(), "ok"); +} + +#[test] +fn ensure_yields_error_once() { + fn build_error() -> AppError { + CALLS.fetch_add(1, Ordering::SeqCst); + AppError::service("bounded") + } + + fn run(flag: bool) -> AppResult<()> { + masterror::ensure!(cond = flag, else = build_error()); + Ok(()) + } + + CALLS.store(0, Ordering::SeqCst); + assert!(run(false).is_err()); + assert_eq!(CALLS.load(Ordering::SeqCst), 1); + + CALLS.store(0, Ordering::SeqCst); + assert!(run(true).is_ok()); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); +} + +#[test] +fn ensure_preserves_error_kind() { + fn run(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::unauthorized("token expired")); + Ok(()) + } + + let err = run(false).unwrap_err(); + assert!(matches!(err.kind, AppErrorKind::Unauthorized)); +} + +#[test] +fn fail_returns_error() { + fn run() -> AppResult<()> { + masterror::fail!(AppError::forbidden("admin only")); + } + + let err = run().unwrap_err(); + assert!(matches!(err.kind, AppErrorKind::Forbidden)); +} + +#[derive(Debug, PartialEq, Eq)] +struct CustomError(&'static str); + +type CustomResult = Result; + +#[test] +fn macros_work_with_custom_error_types() { + fn guard(flag: bool) -> CustomResult<&'static str> { + masterror::ensure!(flag, CustomError("custom failure")); + Ok("ok") + } + + fn bail() -> CustomResult<()> { + masterror::fail!(CustomError("fail")); + } + + assert_eq!(guard(true).unwrap(), "ok"); + assert_eq!(guard(false).unwrap_err(), CustomError("custom failure")); + assert_eq!(bail().unwrap_err(), CustomError("fail")); +} diff --git a/tests/error_derive_from_trybuild.rs b/tests/error_derive_from_trybuild.rs index 0879942..8f267bf 100644 --- a/tests/error_derive_from_trybuild.rs +++ b/tests/error_derive_from_trybuild.rs @@ -41,3 +41,15 @@ fn app_error_attribute_compile_failures() { let t = TestCases::new(); t.compile_fail("tests/ui/app_error/fail/*.rs"); } + +#[test] +fn masterror_attribute_passes() { + let t = TestCases::new(); + t.pass("tests/ui/masterror/pass/*.rs"); +} + +#[test] +fn masterror_attribute_compile_failures() { + let t = TestCases::new(); + t.compile_fail("tests/ui/masterror/fail/*.rs"); +} diff --git a/tests/masterror_macro.rs b/tests/masterror_macro.rs new file mode 100644 index 0000000..4ea50d0 --- /dev/null +++ b/tests/masterror_macro.rs @@ -0,0 +1,204 @@ +#![allow(non_shorthand_field_patterns)] + +use std::{error::Error as StdError, sync::Arc}; + +use masterror::{ + AppCode, AppErrorKind, Error as MasterrorError, FieldRedaction, Masterror, MessageEditPolicy, + mapping::{GrpcMapping, HttpMapping, ProblemMapping} +}; + +#[derive(Debug, Masterror)] +#[error("missing feature flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message, fields("user_id" = hash)), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +#[derive(Debug, Masterror)] +enum ApiError { + #[error("invalid payload: {details}")] + #[masterror( + code = AppCode::BadRequest, + category = AppErrorKind::BadRequest, + message, + telemetry(Some(masterror::field::str("details", details))), + map.problem = "https://errors.example.com/bad-request" + )] + BadPayload { + details: &'static str, + #[allow(non_shorthand_field_patterns)] + #[source] + _source: std::io::Error + }, + #[error("storage offline")] + #[masterror( + code = AppCode::Service, + category = AppErrorKind::Service, + telemetry(), + map.grpc = 14 + )] + StorageOffline +} + +#[test] +fn struct_masterror_conversion_populates_metadata_and_source() { + let source = std::io::Error::other("backend down"); + let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(3), + source: Some(source) + }; + + let converted: MasterrorError = err.into(); + + assert_eq!(converted.code, AppCode::NotFound); + assert_eq!(converted.kind, AppErrorKind::NotFound); + assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); + assert!( + converted + .message + .as_deref() + .is_some_and(|message| message.contains("beta")) + ); + + let user_id = converted + .metadata() + .get("user_id") + .and_then(|value| match value { + masterror::FieldValue::Str(value) => Some(value.as_ref()), + _ => None + }); + assert_eq!(user_id, Some("alice")); + + let attempt = converted + .metadata() + .get("attempt") + .and_then(|value| match value { + masterror::FieldValue::U64(value) => Some(*value), + _ => None + }); + assert_eq!(attempt, Some(3)); + + assert!(converted.source_ref().is_some()); + let converted_source = StdError::source(&converted).expect("masterror source"); + assert!(converted_source.is::()); + + assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) + ); + assert_eq!( + converted.metadata().redaction("user_id"), + Some(FieldRedaction::Hash) + ); + assert_eq!(MissingFlag::HTTP_MAPPING.status(), 404); + + let grpc = MissingFlag::GRPC_MAPPING.expect("grpc mapping"); + assert_eq!(grpc.status(), 5); + assert_eq!(grpc.kind(), AppErrorKind::NotFound); + + let problem = MissingFlag::PROBLEM_MAPPING.expect("problem mapping"); + assert_eq!(problem.type_uri(), "https://errors.example.com/not-found"); +} + +#[test] +fn enum_masterror_conversion_handles_variants() { + let io_error = std::io::Error::new(std::io::ErrorKind::InvalidInput, "format"); + let payload = ApiError::BadPayload { + details: "missing field", + _source: io_error + }; + + let converted: MasterrorError = payload.into(); + assert_eq!(converted.code, AppCode::BadRequest); + assert_eq!(converted.kind, AppErrorKind::BadRequest); + assert!(converted.metadata().get("details").is_some_and( + |value| matches!(value, masterror::FieldValue::Str(detail) if detail == "missing field") + )); + assert!(converted.source_ref().is_some()); + + let offline: MasterrorError = ApiError::StorageOffline.into(); + assert_eq!(offline.code, AppCode::Service); + assert_eq!(offline.kind, AppErrorKind::Service); + assert!(offline.metadata().is_empty()); + + assert_eq!(ApiError::HTTP_MAPPINGS.len(), 2); + assert!( + ApiError::HTTP_MAPPINGS + .iter() + .any(|mapping| mapping.kind() == AppErrorKind::BadRequest) + ); + + assert_eq!( + ApiError::GRPC_MAPPINGS, + &[GrpcMapping::new( + AppCode::Service, + AppErrorKind::Service, + 14 + )] + ); + + assert_eq!( + ApiError::PROBLEM_MAPPINGS, + &[ProblemMapping::new( + AppCode::BadRequest, + AppErrorKind::BadRequest, + "https://errors.example.com/bad-request" + )] + ); +} + +#[test] +fn masterror_preserves_arc_source_without_extra_clone() { + let source = Arc::new(ArcLeafError); + let converted: MasterrorError = ArcSourceError { + source: source.clone() + } + .into(); + + assert_eq!(Arc::strong_count(&source), 2); + + let stored = converted + .source_ref() + .and_then(|src| src.downcast_ref::()) + .expect("arc source"); + assert!(std::ptr::eq(stored, &*source)); +} +#[derive(Debug)] +struct ArcLeafError; + +impl std::fmt::Display for ArcLeafError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("arc leaf") + } +} + +impl std::error::Error for ArcLeafError {} + +#[derive(Debug, Masterror)] +#[error("arc leaf source")] +#[masterror( + code = AppCode::Internal, + category = AppErrorKind::Internal, + message +)] +struct ArcSourceError { + #[source] + source: Arc +} diff --git a/tests/ui/masterror/fail/duplicate_attr.rs b/tests/ui/masterror/fail/duplicate_attr.rs new file mode 100644 index 0000000..026649f --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_attr.rs @@ -0,0 +1,9 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("dup")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] +struct Duplicate; + +fn main() {} diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr new file mode 100644 index 0000000..c3fb86b --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -0,0 +1,13 @@ +error: duplicate #[masterror(...)] attribute + --> tests/ui/masterror/fail/duplicate_attr.rs:6:1 + | +6 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/duplicate_attr.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.rs b/tests/ui/masterror/fail/duplicate_telemetry.rs new file mode 100644 index 0000000..fb0b43d --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_telemetry.rs @@ -0,0 +1,13 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror( + code = AppCode::Internal, + category = AppErrorKind::Internal, + telemetry(), + telemetry() +)] +struct DuplicateTelemetry; + +fn main() {} diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr new file mode 100644 index 0000000..b331baa --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -0,0 +1,13 @@ +error: duplicate telemetry(...) block + --> tests/ui/masterror/fail/duplicate_telemetry.rs:9:5 + | +9 | telemetry() + | ^^^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/duplicate_telemetry.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/empty_redact.rs b/tests/ui/masterror/fail/empty_redact.rs new file mode 100644 index 0000000..0a4c67d --- /dev/null +++ b/tests/ui/masterror/fail/empty_redact.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, redact())] +struct EmptyRedact; + +fn main() {} diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr new file mode 100644 index 0000000..b2658a1 --- /dev/null +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -0,0 +1,13 @@ +error: redact(...) requires at least one option + --> tests/ui/masterror/fail/empty_redact.rs:5:74 + | +5 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, redact())] + | ^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/empty_redact.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.rs b/tests/ui/masterror/fail/enum_missing_variant.rs new file mode 100644 index 0000000..d6ae160 --- /dev/null +++ b/tests/ui/masterror/fail/enum_missing_variant.rs @@ -0,0 +1,12 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +enum Mixed { + #[error("with")] + #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] + With, + #[error("missing")] + Missing +} + +fn main() {} diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr new file mode 100644 index 0000000..83d517f --- /dev/null +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -0,0 +1,13 @@ +error: all variants must use #[masterror(...)] to derive masterror::Error conversion + --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 + | +8 | #[error("missing")] + | ^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_attr.rs b/tests/ui/masterror/fail/missing_attr.rs new file mode 100644 index 0000000..92197f2 --- /dev/null +++ b/tests/ui/masterror/fail/missing_attr.rs @@ -0,0 +1,7 @@ +use masterror::Masterror; + +#[derive(Debug, Masterror)] +#[error("no attribute")] +struct Missing; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_attr.stderr b/tests/ui/masterror/fail/missing_attr.stderr new file mode 100644 index 0000000..3c757ed --- /dev/null +++ b/tests/ui/masterror/fail/missing_attr.stderr @@ -0,0 +1,5 @@ +error: #[derive(Masterror)] requires #[masterror(...)] on structs + --> tests/ui/masterror/fail/missing_attr.rs:5:8 + | +5 | struct Missing; + | ^^^^^^^ diff --git a/tests/ui/masterror/fail/missing_category.rs b/tests/ui/masterror/fail/missing_category.rs new file mode 100644 index 0000000..2b8a52c --- /dev/null +++ b/tests/ui/masterror/fail/missing_category.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal)] +struct MissingCategory; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr new file mode 100644 index 0000000..f929951 --- /dev/null +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -0,0 +1,13 @@ +error: missing `category = ...` in #[masterror(...)] + --> tests/ui/masterror/fail/missing_category.rs:5:1 + | +5 | #[masterror(code = AppCode::Internal)] + | ^ + +warning: unused import: `AppCode` + --> tests/ui/masterror/fail/missing_category.rs:1:17 + | +1 | use masterror::{AppCode, Masterror}; + | ^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_code.rs b/tests/ui/masterror/fail/missing_code.rs new file mode 100644 index 0000000..72c21e0 --- /dev/null +++ b/tests/ui/masterror/fail/missing_code.rs @@ -0,0 +1,8 @@ +use masterror::{AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(category = AppErrorKind::Internal)] +struct MissingCode; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr new file mode 100644 index 0000000..34abc91 --- /dev/null +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -0,0 +1,13 @@ +error: missing `code = ...` in #[masterror(...)] + --> tests/ui/masterror/fail/missing_code.rs:5:1 + | +5 | #[masterror(category = AppErrorKind::Internal)] + | ^ + +warning: unused import: `AppErrorKind` + --> tests/ui/masterror/fail/missing_code.rs:1:17 + | +1 | use masterror::{AppErrorKind, Masterror}; + | ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/unknown_option.rs b/tests/ui/masterror/fail/unknown_option.rs new file mode 100644 index 0000000..67aa586 --- /dev/null +++ b/tests/ui/masterror/fail/unknown_option.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, unknown)] +struct Unknown; + +fn main() {} diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr new file mode 100644 index 0000000..d579838 --- /dev/null +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -0,0 +1,13 @@ +error: unknown #[masterror] option `unknown` + --> tests/ui/masterror/fail/unknown_option.rs:5:74 + | +5 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, unknown)] + | ^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/unknown_option.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/pass/struct.rs b/tests/ui/masterror/pass/struct.rs new file mode 100644 index 0000000..19c2b5d --- /dev/null +++ b/tests/ui/masterror/pass/struct.rs @@ -0,0 +1,19 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("simple {value}")] +#[masterror( + code = AppCode::Internal, + category = AppErrorKind::Internal, + telemetry(), + map.problem = "urn:example:internal" +)] +struct Simple { + value: u8 +} + +fn main() { + let err = Simple { value: 1 }; + let converted: masterror::Error = err.into(); + assert_eq!(converted.code, AppCode::Internal); +}