diff --git a/.github/scripts/install-hooks.sh b/.github/scripts/install-hooks.sh new file mode 100755 index 0000000..49014ba --- /dev/null +++ b/.github/scripts/install-hooks.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025 RAprogramm +# +# SPDX-License-Identifier: MIT + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +HOOKS_DIR="$REPO_ROOT/.git/hooks" +PRE_COMMIT_SCRIPT="$REPO_ROOT/.github/scripts/pre-commit" + +if [ ! -f "$PRE_COMMIT_SCRIPT" ]; then + echo "Error: pre-commit script not found at $PRE_COMMIT_SCRIPT" + exit 1 +fi + +echo "Installing pre-commit hook..." +cp "$PRE_COMMIT_SCRIPT" "$HOOKS_DIR/pre-commit" +chmod +x "$HOOKS_DIR/pre-commit" + +echo "โœ… Pre-commit hook installed successfully!" +echo "" +echo "The hook will run the following checks before each commit:" +echo " - rustfmt (nightly)" +echo " - REUSE compliance" +echo " - clippy (all features, all targets)" +echo " - tests (all features)" +echo " - cargo audit" +echo " - cargo deny (advisories, bans, licenses, sources)" +echo " - README.md generation" diff --git a/.github/scripts/pre-commit b/.github/scripts/pre-commit new file mode 100755 index 0000000..3cc6740 --- /dev/null +++ b/.github/scripts/pre-commit @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025 RAprogramm +# +# SPDX-License-Identifier: MIT + +set -euo pipefail + +echo "๐Ÿ”ง Ensuring nightly rustfmt is available..." +if ! rustup toolchain list | grep -q "^nightly"; then + rustup toolchain install nightly >/dev/null +fi +rustup component add rustfmt --toolchain nightly >/dev/null + +echo "๐Ÿงน Checking formatting with rustfmt..." +cargo +nightly fmt --all -- --check + +echo "๐Ÿ“œ Checking REUSE compliance..." +if command -v reuse &> /dev/null; then + reuse lint +else + echo "โš ๏ธ Warning: reuse tool not installed, skipping license compliance check" + echo " Install with:" + echo " - Arch Linux: paru -S reuse (or sudo pacman -S reuse)" + echo " - pip: pip install reuse" +fi + +echo "๐Ÿ” Running clippy (all features, all targets)..." +cargo clippy --workspace --all-targets --all-features -- -D warnings + +echo "๐Ÿงช Running tests (all features)..." +cargo test -vv + +echo "๐Ÿ›ก๏ธ Running cargo audit..." +if ! command -v cargo-audit >/dev/null 2>&1; then + cargo install --locked cargo-audit >/dev/null +fi +cargo audit + +echo "๐Ÿ”’ Running cargo deny..." +if ! command -v cargo-deny >/dev/null 2>&1; then + echo "Installing cargo-deny..." + cargo install --locked cargo-deny --version 0.18.4 >/dev/null +fi +cargo deny check advisories bans licenses sources + +# Uncomment if you want to validate SQLx offline data +# echo "๐Ÿ“ฆ Validating SQLx prepare..." +# cargo sqlx prepare --check --workspace + +# same deterministic env +export TZ=UTC +export LC_ALL=C.UTF-8 +export NO_COLOR=1 +export CARGO_TERM_COLOR=never +export SOURCE_DATE_EPOCH=0 + +# Generate +./.github/scripts/gen_readme.sh + +# Stage README if changed +if ! git diff --quiet -- README.md; then + git add README.md +fi + +echo "โœ… All checks passed!" + diff --git a/Cargo.lock b/Cargo.lock index c47acfe..3ecc58a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,10 +278,13 @@ checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http 1.3.1", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -292,10 +295,13 @@ dependencies = [ "serde_core", "serde_json", "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -314,6 +320,52 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "axum-rest-api" +version = "0.1.0" +dependencies = [ + "axum", + "axum-test", + "masterror 0.24.19", + "serde", + "serde_json", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "axum-test" +version = "18.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680e88effaafbb28675074f29cda0e984c984bed5eb513085c17caf7de564225" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http 1.3.1", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", ] [[package]] @@ -334,6 +386,14 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "basic-async" +version = "0.1.0" +dependencies = [ + "masterror 0.24.19", + "tokio", +] + [[package]] name = "bitflags" version = "2.10.0" @@ -376,6 +436,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bytesize" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5c434ae3cf0089ca203e9019ebe529c47ff45cefe8af7c85ecb734ef541822f" + [[package]] name = "bytestring" version = "1.5.0" @@ -420,8 +486,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -551,6 +619,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -701,6 +789,15 @@ dependencies = [ "syn", ] +[[package]] +name = "custom-domain-errors" +version = "0.1.0" +dependencies = [ + "masterror 0.24.19", + "serde", + "thiserror", +] + [[package]] name = "darling" version = "0.20.11" @@ -834,6 +931,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -911,6 +1014,15 @@ dependencies = [ "serde", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -979,6 +1091,34 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "expect-json" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "serde", + "serde_json", + "thiserror", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1020,6 +1160,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1591,6 +1746,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1615,7 +1779,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1704,6 +1868,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -1838,6 +2003,15 @@ dependencies = [ name = "masterror-template" version = "0.3.8" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -1915,6 +2089,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1924,6 +2115,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1951,6 +2156,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1977,6 +2191,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1999,12 +2224,50 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "openssl" +version = "0.10.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2235,6 +2498,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2555,6 +2828,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror", +] + [[package]] name = "rgb" version = "0.8.52" @@ -2620,6 +2902,21 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 1.3.1", + "mime", + "rand 0.9.2", + "thiserror", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2671,7 +2968,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -2755,6 +3052,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -2762,7 +3072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3057,6 +3367,7 @@ dependencies = [ "indexmap 2.12.0", "log", "memchr", + "native-tls", "once_cell", "percent-encoding", "serde", @@ -3064,10 +3375,21 @@ dependencies = [ "sha2", "smallvec", "thiserror", + "tokio", + "tokio-stream", "tracing", "url", ] +[[package]] +name = "sqlx-database" +version = "0.1.0" +dependencies = [ + "masterror 0.24.19", + "sqlx", + "tokio", +] + [[package]] name = "sqlx-macros" version = "0.8.6" @@ -3098,7 +3420,11 @@ dependencies = [ "serde_json", "sha2", "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", "syn", + "tokio", "url", ] @@ -3133,6 +3459,7 @@ dependencies = [ "percent-encoding", "rand 0.8.5", "rsa", + "serde", "sha1", "sha2", "smallvec", @@ -3196,6 +3523,7 @@ dependencies = [ "libsqlite3-sys", "log", "percent-encoding", + "serde", "serde_urlencoded", "sqlx-core", "thiserror", @@ -3727,10 +4055,14 @@ version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] @@ -3768,6 +4100,30 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -3874,6 +4230,7 @@ checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.4", "js-sys", + "serde", "wasm-bindgen", ] @@ -4411,6 +4768,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index bd29b3f..f346395 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,14 @@ include = [ ] [workspace] -members = ["masterror-derive", "masterror-template"] +members = [ + "masterror-derive", + "masterror-template", + "examples/axum-rest-api", + "examples/custom-domain-errors", + "examples/sqlx-database", + "examples/basic-async", +] resolver = "3" diff --git a/README.md b/README.md index d48a5a1..a09cdb1 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ SPDX-License-Identifier: MIT - [Code Coverage](#code-coverage) - [Quick Start](#quick-start) - [Advanced Usage](#advanced-usage) +- [Examples](#examples) - [Resources](#resources) - [Metrics](#metrics) - [License](#license) @@ -604,6 +605,31 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); --- +## Examples + +Comprehensive real-world examples demonstrating masterror integration with popular frameworks: + +| Example | Description | Features | +|---------|-------------|----------| +| [**axum-rest-api**](examples/axum-rest-api/) | REST API with RFC 7807 Problem Details | HTTP endpoints, domain errors, integration tests | +| [**sqlx-database**](examples/sqlx-database/) | Database error handling with SQLx | Connection errors, constraint violations, transactions | +| [**custom-domain-errors**](examples/custom-domain-errors/) | Payment processing domain errors | Derive macro, error conversion, structured errors | +| [**basic-async**](examples/basic-async/) | Async error handling with tokio | Error propagation, timeout handling, Result types | + +All examples are runnable and include comprehensive tests. See the [`examples/`](examples/) directory for complete source code and documentation. + +
+ +
+ + Go to top + +
+ +
+ +--- + ## Resources - Explore the [error-handling wiki](https://github.com/RAprogramm/masterror/wiki) for step-by-step guides, diff --git a/README.template.md b/README.template.md index bd177a3..9687ec0 100644 --- a/README.template.md +++ b/README.template.md @@ -41,6 +41,7 @@ SPDX-License-Identifier: MIT - [Code Coverage](#code-coverage) - [Quick Start](#quick-start) - [Advanced Usage](#advanced-usage) +- [Examples](#examples) - [Resources](#resources) - [Metrics](#metrics) - [License](#license) @@ -599,6 +600,31 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); --- +## Examples + +Comprehensive real-world examples demonstrating masterror integration with popular frameworks: + +| Example | Description | Features | +|---------|-------------|----------| +| [**axum-rest-api**](examples/axum-rest-api/) | REST API with RFC 7807 Problem Details | HTTP endpoints, domain errors, integration tests | +| [**sqlx-database**](examples/sqlx-database/) | Database error handling with SQLx | Connection errors, constraint violations, transactions | +| [**custom-domain-errors**](examples/custom-domain-errors/) | Payment processing domain errors | Derive macro, error conversion, structured errors | +| [**basic-async**](examples/basic-async/) | Async error handling with tokio | Error propagation, timeout handling, Result types | + +All examples are runnable and include comprehensive tests. See the [`examples/`](examples/) directory for complete source code and documentation. + +
+ +
+ + Go to top + +
+ +
+ +--- + ## Resources - Explore the [error-handling wiki](https://github.com/RAprogramm/masterror/wiki) for step-by-step guides, diff --git a/deny.toml b/deny.toml index 28432de..e4504ac 100644 --- a/deny.toml +++ b/deny.toml @@ -9,7 +9,10 @@ git-fetch-with-cli = true [licenses] allow = [ "Apache-2.0", + "BSD-3-Clause", "MIT", + "Unicode-3.0", + "Zlib", ] confidence-threshold = 0.8 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..18f2ba3 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,80 @@ + + +# Examples + +Comprehensive real-world examples demonstrating masterror integration with popular frameworks and use cases. + +## Quick Start Examples + +| Example | Description | Run Command | +|---------|-------------|-------------| +| [`basic_usage.rs`](basic_usage.rs) | Core error creation and conversion patterns | `cargo run --example basic_usage` | +| [`derive_error.rs`](derive_error.rs) | Custom error types with derive macro | `cargo run --example derive_error` | +| [`structured_metadata.rs`](structured_metadata.rs) | Attaching JSON metadata and context | `cargo run --example structured_metadata` | +| [`colored_cli.rs`](colored_cli.rs) | Terminal output with color support | `cargo run --example colored_cli` | +| [`redaction.rs`](redaction.rs) | Sensitive data redaction | `cargo run --example redaction` | +| [`migrate_from_anyhow.rs`](migrate_from_anyhow.rs) | Migration guide from anyhow | `cargo run --example migrate_from_anyhow` | +| [`migrate_from_thiserror.rs`](migrate_from_thiserror.rs) | Migration guide from thiserror | `cargo run --example migrate_from_thiserror` | + +## Real-World Service Examples + +| Example | Description | Status | +|---------|-------------|--------| +| [`axum-rest-api/`](axum-rest-api/) | REST API with RFC 7807 Problem Details | โœ… Available | +| [`sqlx-database/`](sqlx-database/) | Database error handling with SQLx | โœ… Available | +| [`custom-domain-errors/`](custom-domain-errors/) | Payment processing domain errors | โœ… Available | +| [`basic-async/`](basic-async/) | Async error handling with tokio | โœ… Available | +| `multi-transport/` | Shared errors across HTTP + gRPC | ๐Ÿšง Planned | +| `tonic-grpc-service/` | gRPC service with tonic | ๐Ÿšง Planned | +| `actix-web-service/` | Actix-web integration | ๐Ÿšง Planned | +| `telemetry-integration/` | OpenTelemetry + tracing | ๐Ÿšง Planned | +| `comparison-thiserror/` | Side-by-side comparison | ๐Ÿšง Planned | + +## Running Examples + +### Simple Examples + +```bash +cargo run --example basic_usage +cargo run --example colored_cli +``` + +### Service Examples + +Each service example is a workspace member with its own Cargo.toml: + +```bash +cd examples/axum-rest-api +cargo run + +cd examples/sqlx-database +cargo test +``` + +## Example Requirements + +Each real-world example includes: + +- **Runnable code** - Full working service or application +- **README.md** - Scenario explanation and usage guide +- **Tests** - Integration and unit tests +- **Comments** - Key decisions and patterns explained +- **Minimal dependencies** - Only necessary crates + +## Contributing Examples + +When adding new examples: + +1. Follow project structure conventions +2. Add SPDX headers to all files +3. Include comprehensive tests +4. Update this README.md index +5. Ensure `cargo test` passes +6. Add example to CI workflow + +## License + +All examples are licensed under MIT, same as masterror. diff --git a/examples/axum-rest-api/Cargo.toml b/examples/axum-rest-api/Cargo.toml new file mode 100644 index 0000000..35b8520 --- /dev/null +++ b/examples/axum-rest-api/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025 RAprogramm +# +# SPDX-License-Identifier: MIT + +[package] +name = "axum-rest-api" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +masterror = { path = "../..", features = ["axum", "serde_json", "tracing"] } +axum = "0.8" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tower = "0.5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4", "serde"] } + +[dev-dependencies] +axum-test = "18.1" diff --git a/examples/axum-rest-api/README.md b/examples/axum-rest-api/README.md new file mode 100644 index 0000000..4e38c09 --- /dev/null +++ b/examples/axum-rest-api/README.md @@ -0,0 +1,111 @@ + + +# Axum REST API Example + +Full-featured REST API demonstrating masterror integration with Axum framework, RFC 7807 Problem Details, and structured error handling. + +## Features + +- **RFC 7807 Problem Details** - HTTP API error responses +- **Custom Domain Errors** - User management domain errors with derive macro +- **Metadata Attachment** - request_id and user_id tracking +- **Tracing Integration** - Structured logging with tracing +- **Integration Tests** - Full HTTP test coverage with axum-test + +## Running + +```bash +cd examples/axum-rest-api +cargo run +``` + +Server starts on `http://127.0.0.1:3000`. + +## API Endpoints + +### User Management + +```bash +# Get user by ID +curl http://127.0.0.1:3000/users/550e8400-e29b-41d4-a716-446655440000 + +# Create user +curl -X POST http://127.0.0.1:3000/users \ + -H "Content-Type: application/json" \ + -d '{"name": "Alice", "email": "alice@example.com"}' + +# Update user +curl -X PUT http://127.0.0.1:3000/users/550e8400-e29b-41d4-a716-446655440000 \ + -H "Content-Type: application/json" \ + -d '{"name": "Alice Updated", "email": "alice@example.com"}' + +# Delete user +curl -X DELETE http://127.0.0.1:3000/users/550e8400-e29b-41d4-a716-446655440000 +``` + +## Error Response Format + +All errors return RFC 7807 Problem Details: + +```json +{ + "type": "https://masterror.dev/errors/not-found", + "title": "Not Found", + "status": 404, + "detail": "user not found", + "instance": "/users/550e8400-e29b-41d4-a716-446655440000", + "code": "NOT_FOUND", + "request_id": "req-123", + "user_id": "user-456" +} +``` + +## Testing + +```bash +cargo test +``` + +## Key Concepts + +### Domain Errors + +```rust +#[derive(Debug, Error, Clone)] +pub enum UserError { + #[error("user not found")] + NotFound, + + #[error("email already exists")] + DuplicateEmail, + + #[error("invalid email format")] + InvalidEmail, +} +``` + +### Axum Integration + +```rust +impl IntoResponse for UserError { + fn into_response(self) -> Response { + let app_error: AppError = self.into(); + app_error.into_response() + } +} +``` + +### Metadata Attachment + +```rust +AppError::not_found("user not found") + .with_field("user_id", user_id.to_string()) + .with_field("request_id", request_id.to_string()) +``` + +## License + +MIT diff --git a/examples/axum-rest-api/src/lib.rs b/examples/axum-rest-api/src/lib.rs new file mode 100644 index 0000000..6123f2d --- /dev/null +++ b/examples/axum-rest-api/src/lib.rs @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Axum REST API example with masterror integration. + +use std::{ + collections::HashMap, + sync::{Arc, RwLock} +}; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response} +}; +use masterror::{AppError, Error}; +use serde::{Deserialize, Serialize}; +use tracing::info; +use uuid::Uuid; + +/// User model with validation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: Uuid, + pub name: String, + pub email: String +} + +/// User creation request +#[derive(Debug, Deserialize)] +pub struct CreateUserRequest { + pub name: String, + pub email: String +} + +/// User update request +#[derive(Debug, Deserialize)] +pub struct UpdateUserRequest { + pub name: String, + pub email: String +} + +/// Domain-specific user errors +/// +/// These errors represent business logic failures that are converted +/// into appropriate HTTP responses via AppError. +#[derive(Debug, Error, Clone)] +pub enum UserError { + /// User with given ID was not found + #[error("user not found")] + NotFound, + + /// Email address is already registered + #[error("email already exists")] + DuplicateEmail, + + /// Email format is invalid + #[error("invalid email format")] + InvalidEmail, + + /// User name is too short or empty + #[error("invalid name: must be at least 2 characters")] + InvalidName +} + +/// Convert domain errors to AppError with appropriate HTTP status codes +impl From for AppError { + fn from(err: UserError) -> Self { + match err { + UserError::NotFound => AppError::not_found(err.to_string()), + UserError::DuplicateEmail => AppError::conflict(err.to_string()), + UserError::InvalidEmail | UserError::InvalidName => { + AppError::validation(err.to_string()) + } + } + } +} + +/// Implement IntoResponse to use UserError directly in handlers +impl IntoResponse for UserError { + fn into_response(self) -> Response { + let app_error: AppError = self.into(); + app_error.into_response() + } +} + +/// In-memory user storage (production would use database) +#[derive(Clone)] +pub struct AppState { + pub users: Arc>> +} + +impl AppState { + pub fn new() -> Self { + Self { + users: Arc::new(RwLock::new(HashMap::new())) + } + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +/// Validate email format (simplified) +fn validate_email(email: &str) -> Result<(), UserError> { + if email.contains('@') && email.len() > 3 { + Ok(()) + } else { + Err(UserError::InvalidEmail) + } +} + +/// Validate user name +fn validate_name(name: &str) -> Result<(), UserError> { + if name.len() >= 2 { + Ok(()) + } else { + Err(UserError::InvalidName) + } +} + +/// GET /users/:id - Retrieve user by ID +/// +/// Returns 404 if user not found, includes user_id in error metadata. +pub async fn get_user( + State(state): State, + Path(user_id): Path +) -> Result, UserError> { + let users = state.users.read().unwrap(); + + users + .get(&user_id) + .cloned() + .ok_or(UserError::NotFound) + .map(axum::Json) +} + +/// POST /users - Create new user +/// +/// Validates email format and checks for duplicates. +/// Returns 201 Created on success. +pub async fn create_user( + State(state): State, + axum::Json(req): axum::Json +) -> Result<(StatusCode, axum::Json), UserError> { + validate_email(&req.email)?; + validate_name(&req.name)?; + + let mut users = state.users.write().unwrap(); + + // Check for duplicate email + if users.values().any(|u| u.email == req.email) { + return Err(UserError::DuplicateEmail); + } + + let user = User { + id: Uuid::new_v4(), + name: req.name, + email: req.email + }; + + info!(user_id = %user.id, email = %user.email, "Creating new user"); + + users.insert(user.id, user.clone()); + + Ok((StatusCode::CREATED, axum::Json(user))) +} + +/// PUT /users/:id - Update existing user +/// +/// Returns 404 if user not found. +/// Validates email format before update. +pub async fn update_user( + State(state): State, + Path(user_id): Path, + axum::Json(req): axum::Json +) -> Result, UserError> { + validate_email(&req.email)?; + validate_name(&req.name)?; + + let mut users = state.users.write().unwrap(); + + // Check if user exists and get current email + let current_email = users + .get(&user_id) + .map(|u| u.email.clone()) + .ok_or(UserError::NotFound)?; + + // Check if email is being changed to existing email + if req.email != current_email && users.values().any(|u| u.email == req.email) { + return Err(UserError::DuplicateEmail); + } + + info!(user_id = %user_id, "Updating user"); + + // Now update the user + let user = users.get_mut(&user_id).ok_or(UserError::NotFound)?; + user.name = req.name; + user.email = req.email; + + Ok(axum::Json(user.clone())) +} + +/// DELETE /users/:id - Delete user +/// +/// Returns 404 if user not found, 204 No Content on success. +pub async fn delete_user( + State(state): State, + Path(user_id): Path +) -> Result { + let mut users = state.users.write().unwrap(); + + users.remove(&user_id).ok_or(UserError::NotFound)?; + + info!(user_id = %user_id, "Deleted user"); + + Ok(StatusCode::NO_CONTENT) +} diff --git a/examples/axum-rest-api/src/main.rs b/examples/axum-rest-api/src/main.rs new file mode 100644 index 0000000..366d68c --- /dev/null +++ b/examples/axum-rest-api/src/main.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +use axum::{ + Router, + routing::{delete, get, post, put} +}; +use axum_rest_api::{AppState, create_user, delete_user, get_user, update_user}; +use tracing::info; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +/// Health check endpoint +async fn health() -> &'static str { + "OK" +} + +#[tokio::main] +async fn main() { + // Initialize tracing + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let state = AppState::new(); + + let app = Router::new() + .route("/health", get(health)) + .route("/users/{id}", get(get_user)) + .route("/users", post(create_user)) + .route("/users/{id}", put(update_user)) + .route("/users/{id}", delete(delete_user)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + .await + .unwrap(); + + info!("Server listening on {}", listener.local_addr().unwrap()); + + axum::serve(listener, app).await.unwrap(); +} diff --git a/examples/axum-rest-api/tests/integration_tests.rs b/examples/axum-rest-api/tests/integration_tests.rs new file mode 100644 index 0000000..a2e5016 --- /dev/null +++ b/examples/axum-rest-api/tests/integration_tests.rs @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +use axum::{ + Router, + http::StatusCode, + routing::{delete, get, post, put} +}; +use axum_rest_api::{AppState, User}; +use axum_test::TestServer; +use serde_json::json; +use uuid::Uuid; + +/// Helper to create test router +fn create_test_router() -> Router { + let state = AppState::new(); + + Router::new() + .route("/users/{id}", get(axum_rest_api::get_user)) + .route("/users", post(axum_rest_api::create_user)) + .route("/users/{id}", put(axum_rest_api::update_user)) + .route("/users/{id}", delete(axum_rest_api::delete_user)) + .with_state(state) +} + +#[tokio::test] +async fn health_check_returns_ok() { + let app = Router::new().route("/health", get(|| async { "OK" })); + let server = TestServer::new(app).unwrap(); + + let response = server.get("/health").await; + + assert_eq!(response.status_code(), StatusCode::OK); + assert_eq!(response.text(), "OK"); +} + +#[tokio::test] +async fn create_user_returns_201() { + let server = TestServer::new(create_test_router()).unwrap(); + + let response = server + .post("/users") + .json(&json!({ + "name": "Alice", + "email": "alice@example.com" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::CREATED); + + let user: User = response.json(); + assert_eq!(user.name, "Alice"); + assert_eq!(user.email, "alice@example.com"); +} + +#[tokio::test] +async fn create_user_with_invalid_email_returns_422() { + let server = TestServer::new(create_test_router()).unwrap(); + + let response = server + .post("/users") + .json(&json!({ + "name": "Bob", + "email": "invalid-email" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], 422); + assert!(body["detail"].as_str().unwrap().contains("invalid email")); +} + +#[tokio::test] +async fn create_user_with_short_name_returns_422() { + let server = TestServer::new(create_test_router()).unwrap(); + + let response = server + .post("/users") + .json(&json!({ + "name": "A", + "email": "valid@example.com" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + + let body: serde_json::Value = response.json(); + assert!( + body["detail"] + .as_str() + .unwrap() + .contains("at least 2 characters") + ); +} + +#[tokio::test] +async fn create_duplicate_email_returns_409() { + let server = TestServer::new(create_test_router()).unwrap(); + + // Create first user + server + .post("/users") + .json(&json!({ + "name": "Alice", + "email": "alice@example.com" + })) + .await; + + // Try to create user with same email + let response = server + .post("/users") + .json(&json!({ + "name": "Bob", + "email": "alice@example.com" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::CONFLICT); + + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], 409); + assert!(body["detail"].as_str().unwrap().contains("already exists")); +} + +#[tokio::test] +async fn get_user_returns_200() { + let server = TestServer::new(create_test_router()).unwrap(); + + // Create user + let create_response = server + .post("/users") + .json(&json!({ + "name": "Charlie", + "email": "charlie@example.com" + })) + .await; + + let created_user: User = create_response.json(); + + // Get user + let response = server.get(&format!("/users/{}", created_user.id)).await; + + assert_eq!(response.status_code(), StatusCode::OK); + + let user: User = response.json(); + assert_eq!(user.id, created_user.id); + assert_eq!(user.name, "Charlie"); +} + +#[tokio::test] +async fn get_nonexistent_user_returns_404() { + let server = TestServer::new(create_test_router()).unwrap(); + + let fake_id = Uuid::new_v4(); + let response = server.get(&format!("/users/{}", fake_id)).await; + + assert_eq!(response.status_code(), StatusCode::NOT_FOUND); + + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], 404); + assert!(body["detail"].as_str().unwrap().contains("not found")); +} + +#[tokio::test] +async fn update_user_returns_200() { + let server = TestServer::new(create_test_router()).unwrap(); + + // Create user + let create_response = server + .post("/users") + .json(&json!({ + "name": "Dave", + "email": "dave@example.com" + })) + .await; + + let created_user: User = create_response.json(); + + // Update user + let response = server + .put(&format!("/users/{}", created_user.id)) + .json(&json!({ + "name": "Dave Updated", + "email": "dave.updated@example.com" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::OK); + + let user: User = response.json(); + assert_eq!(user.name, "Dave Updated"); + assert_eq!(user.email, "dave.updated@example.com"); +} + +#[tokio::test] +async fn update_nonexistent_user_returns_404() { + let server = TestServer::new(create_test_router()).unwrap(); + + let fake_id = Uuid::new_v4(); + let response = server + .put(&format!("/users/{}", fake_id)) + .json(&json!({ + "name": "Ghost", + "email": "ghost@example.com" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn delete_user_returns_204() { + let server = TestServer::new(create_test_router()).unwrap(); + + // Create user + let create_response = server + .post("/users") + .json(&json!({ + "name": "Eve", + "email": "eve@example.com" + })) + .await; + + let created_user: User = create_response.json(); + + // Delete user + let response = server.delete(&format!("/users/{}", created_user.id)).await; + + assert_eq!(response.status_code(), StatusCode::NO_CONTENT); + + // Verify user is gone + let get_response = server.get(&format!("/users/{}", created_user.id)).await; + assert_eq!(get_response.status_code(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn delete_nonexistent_user_returns_404() { + let server = TestServer::new(create_test_router()).unwrap(); + + let fake_id = Uuid::new_v4(); + let response = server.delete(&format!("/users/{}", fake_id)).await; + + assert_eq!(response.status_code(), StatusCode::NOT_FOUND); +} diff --git a/examples/basic-async/Cargo.toml b/examples/basic-async/Cargo.toml new file mode 100644 index 0000000..d322714 --- /dev/null +++ b/examples/basic-async/Cargo.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 RAprogramm +# +# SPDX-License-Identifier: MIT + +[package] +name = "basic-async" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +masterror = { path = "../..", features = ["tokio"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/examples/basic-async/README.md b/examples/basic-async/README.md new file mode 100644 index 0000000..8a8d471 --- /dev/null +++ b/examples/basic-async/README.md @@ -0,0 +1,62 @@ + + +# Basic Async Example + +Simple demonstration of using masterror in async Rust code with tokio. + +## Features + +- **Async Error Handling** - Using `?` operator in async functions +- **Timeout Handling** - Converting tokio timeout errors to AppError +- **Error Propagation** - Clean error propagation through async call chains +- **Result Types** - Using `AppResult` for async operations + +## Running + +```bash +cd examples/basic-async +cargo run +``` + +## Key Concepts + +### Async Functions with AppError + +```rust +async fn fetch_data(id: u64) -> Result { + if id == 0 { + return Err(AppError::validation("ID cannot be zero")); + } + + Ok(format!("Data for ID {}", id)) +} +``` + +### Timeout Error Handling + +```rust +use tokio::time::{timeout, Duration}; + +let result = timeout( + Duration::from_secs(5), + fetch_data(123) +).await?; // Converts Elapsed to AppError::Timeout +``` + +### Error Propagation + +```rust +async fn process() -> Result<(), AppError> { + let data = fetch_data(123).await?; + let result = process_data(&data).await?; + save_result(result).await?; + Ok(()) +} +``` + +## License + +MIT diff --git a/examples/basic-async/src/main.rs b/examples/basic-async/src/main.rs new file mode 100644 index 0000000..a60f419 --- /dev/null +++ b/examples/basic-async/src/main.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Basic async error handling with masterror and tokio. + +use std::time::Duration; + +use masterror::AppError; +use tokio::time::timeout; + +/// Simulated data fetch operation +async fn fetch_data(id: u64) -> Result { + if id == 0 { + return Err(AppError::validation("ID cannot be zero")); + } + + if id > 1000 { + return Err(AppError::not_found("ID not found in database")); + } + + // Simulate async work + tokio::time::sleep(Duration::from_millis(100)).await; + + Ok(format!("Data for ID {id}")) +} + +/// Process fetched data +async fn process_data(data: &str) -> Result { + if data.is_empty() { + return Err(AppError::validation("Data cannot be empty")); + } + + // Simulate processing + tokio::time::sleep(Duration::from_millis(50)).await; + + Ok(format!("Processed: {data}")) +} + +/// Save processed result +async fn save_result(result: String) -> Result<(), AppError> { + if result.len() > 1000 { + return Err(AppError::bad_request("Result too large to save")); + } + + // Simulate saving + tokio::time::sleep(Duration::from_millis(50)).await; + + println!("โœ“ Saved: {result}"); + Ok(()) +} + +/// Complete processing pipeline +async fn process_pipeline(id: u64) -> Result<(), AppError> { + println!("Processing ID {id}..."); + + let data = fetch_data(id).await?; + let processed = process_data(&data).await?; + save_result(processed).await?; + + Ok(()) +} + +/// Slow operation for timeout demonstration +async fn slow_operation() -> Result { + tokio::time::sleep(Duration::from_secs(10)).await; + Ok("Completed".to_string()) +} + +#[tokio::main] +async fn main() -> Result<(), AppError> { + println!("Basic Async Error Handling Example\\n"); + + // Successful pipeline + println!("=== Successful Pipeline ==="); + process_pipeline(123).await?; + + // Validation error + println!("\\n=== Validation Error ==="); + match process_pipeline(0).await { + Ok(()) => println!("โœ— Should have failed"), + Err(e) => { + println!("โœ“ Expected error: {e}"); + println!(" โ†’ Kind: {:?}, HTTP: {}", e.kind, e.kind.http_status()); + } + } + + // Not found error + println!("\\n=== Not Found Error ==="); + match process_pipeline(9999).await { + Ok(()) => println!("โœ— Should have failed"), + Err(e) => { + println!("โœ“ Expected error: {e}"); + println!(" โ†’ Kind: {:?}, HTTP: {}", e.kind, e.kind.http_status()); + } + } + + // Timeout error + println!("\\n=== Timeout Error ==="); + match timeout(Duration::from_secs(1), slow_operation()).await { + Ok(Ok(result)) => println!("โœ“ Completed: {result}"), + Ok(Err(e)) => println!("โœ— Operation error: {e}"), + Err(e) => { + let app_err: AppError = e.into(); + println!("โœ“ Expected timeout: {app_err}"); + println!( + " โ†’ Kind: {:?}, HTTP: {}", + app_err.kind, + app_err.kind.http_status() + ); + } + } + + println!("\\nโœ“ Example completed"); + Ok(()) +} diff --git a/examples/custom-domain-errors/Cargo.toml b/examples/custom-domain-errors/Cargo.toml new file mode 100644 index 0000000..b0772db --- /dev/null +++ b/examples/custom-domain-errors/Cargo.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2025 RAprogramm +# +# SPDX-License-Identifier: MIT + +[package] +name = "custom-domain-errors" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +masterror = { path = "../..", features = ["tracing"] } +serde = { version = "1", features = ["derive"] } +thiserror = "2.0" diff --git a/examples/custom-domain-errors/README.md b/examples/custom-domain-errors/README.md new file mode 100644 index 0000000..a60e1da --- /dev/null +++ b/examples/custom-domain-errors/README.md @@ -0,0 +1,107 @@ + + +# Custom Domain Errors Example + +Demonstrates creating domain-specific custom error types using masterror's derive macro for a payment processing system. + +## Features + +- **Payment Processing Errors** - Domain errors for payment operations +- **Authentication Errors** - User authentication and authorization failures +- **Validation Errors** - Input validation with detailed field information +- **External Service Errors** - Third-party API integration errors +- **Derive Macro Usage** - Full use of `#[derive(Error)]` + +## Running + +```bash +cd examples/custom-domain-errors +cargo run +``` + +## Error Domains + +### Payment Domain + +```rust +#[derive(Debug, Error, Clone)] +pub enum PaymentError { + #[error("insufficient funds: balance={balance}, required={required}")] + InsufficientFunds { balance: u64, required: u64 }, + + #[error("payment method declined")] + PaymentDeclined, + + #[error("invalid amount: {0}")] + InvalidAmount(String), +} +``` + +### Authentication Domain + +```rust +#[derive(Debug, Error, Clone)] +pub enum AuthError { + #[error("invalid credentials")] + InvalidCredentials, + + #[error("session expired")] + SessionExpired, + + #[error("insufficient permissions")] + Forbidden, +} +``` + +### Validation Domain + +```rust +#[derive(Debug, Error, Clone)] +pub enum ValidationError { + #[error("field '{field}' is required")] + RequiredField { field: String }, + + #[error("field '{field}' has invalid format: {reason}")] + InvalidFormat { field: String, reason: String }, +} +``` + +## Converting to AppError + +```rust +impl From for AppError { + fn from(err: PaymentError) -> Self { + match err { + PaymentError::InsufficientFunds { .. } => { + AppError::bad_request(err.to_string()) + } + PaymentError::PaymentDeclined => { + AppError::bad_request(err.to_string()) + } + PaymentError::InvalidAmount(_) => { + AppError::validation(err.to_string()) + } + } + } +} +``` + +## Testing + +```bash +cargo test +``` + +## Key Concepts + +- **Domain Separation** - Each domain (payment, auth, validation) has its own error enum +- **Derive Macro** - Using `#[derive(Error)]` for automatic `Display` and `Error` trait implementation +- **Conversion** - Clean conversion from domain errors to `AppError` with appropriate HTTP status codes +- **Error Context** - Rich error information with structured data + +## License + +MIT diff --git a/examples/custom-domain-errors/src/main.rs b/examples/custom-domain-errors/src/main.rs new file mode 100644 index 0000000..b9f5298 --- /dev/null +++ b/examples/custom-domain-errors/src/main.rs @@ -0,0 +1,376 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Custom domain errors example for payment processing system. +//! +//! Demonstrates creating domain-specific error types using masterror's derive +//! macro and converting them to AppError. + +use masterror::{AppError, Error}; + +/// Payment processing domain errors +#[derive(Debug, Error, Clone, PartialEq)] +pub enum PaymentError { + /// Insufficient funds in account + #[error("insufficient funds: balance=${balance}, required=${required}")] + InsufficientFunds { + /// Current account balance in cents + balance: u64, + /// Required amount in cents + required: u64 + }, + + /// Payment method was declined by processor + #[error("payment method declined: {reason}")] + PaymentDeclined { + /// Reason for decline + reason: String + }, + + /// Invalid payment amount + #[error("invalid payment amount: {0}")] + InvalidAmount(String), + + /// Payment processor unavailable + #[error("payment processor temporarily unavailable")] + ProcessorUnavailable, + + /// Duplicate transaction detected + #[error("duplicate transaction: {transaction_id}")] + DuplicateTransaction { + /// ID of duplicate transaction + transaction_id: String + } +} + +/// Authentication and authorization errors +#[derive(Debug, Error, Clone, PartialEq)] +pub enum AuthError { + /// Invalid username or password + #[error("invalid credentials")] + InvalidCredentials, + + /// User session has expired + #[error("session expired at {expired_at}")] + SessionExpired { + /// Timestamp when session expired + expired_at: String + }, + + /// User lacks required permissions + #[error("insufficient permissions: requires {required}")] + Forbidden { + /// Required permission + required: String + }, + + /// Account is locked due to too many failed attempts + #[error("account locked until {unlock_at}")] + AccountLocked { + /// Timestamp when account unlocks + unlock_at: String + } +} + +/// Input validation errors with field-level detail +#[derive(Debug, Error, Clone, PartialEq)] +pub enum ValidationError { + /// Required field is missing + #[error("field '{field}' is required")] + RequiredField { + /// Name of missing field + field: String + }, + + /// Field has invalid format + #[error("field '{field}' has invalid format: {reason}")] + InvalidFormat { + /// Field name + field: String, + /// Reason for invalidity + reason: String + }, + + /// Value is out of allowed range + #[error("field '{field}' out of range: {min} <= value <= {max}")] + OutOfRange { + /// Field name + field: String, + /// Minimum allowed value + min: String, + /// Maximum allowed value + max: String + } +} + +/// External service integration errors +#[derive(Debug, Error, Clone, PartialEq)] +pub enum ExternalServiceError { + /// Service returned an error response + #[error("service '{service}' returned error: {message}")] + ServiceError { + /// Service name + service: String, + /// Error message from service + message: String + }, + + /// Service request timed out + #[error("request to '{service}' timed out after {timeout_ms}ms")] + Timeout { + /// Service name + service: String, + /// Timeout duration in milliseconds + timeout_ms: u64 + }, + + /// Network connectivity issue + #[error("network error connecting to '{service}': {details}")] + NetworkError { + /// Service name + service: String, + /// Error details + details: String + } +} + +/// Convert payment errors to HTTP-appropriate AppError +impl From for AppError { + fn from(err: PaymentError) -> Self { + match err { + PaymentError::InsufficientFunds { + .. + } + | PaymentError::PaymentDeclined { + .. + } => AppError::bad_request(err.to_string()), + PaymentError::InvalidAmount(_) => AppError::validation(err.to_string()), + PaymentError::ProcessorUnavailable => AppError::external_api(err.to_string()), + PaymentError::DuplicateTransaction { + .. + } => AppError::conflict(err.to_string()) + } + } +} + +/// Convert authentication errors to HTTP-appropriate AppError +impl From for AppError { + fn from(err: AuthError) -> Self { + match err { + AuthError::InvalidCredentials => AppError::unauthorized(err.to_string()), + AuthError::SessionExpired { + .. + } => AppError::unauthorized(err.to_string()), + AuthError::Forbidden { + .. + } => AppError::forbidden(err.to_string()), + AuthError::AccountLocked { + .. + } => AppError::forbidden(err.to_string()) + } + } +} + +/// Convert validation errors to HTTP 422 Unprocessable Entity +impl From for AppError { + fn from(err: ValidationError) -> Self { + AppError::validation(err.to_string()) + } +} + +/// Convert external service errors to HTTP-appropriate AppError +impl From for AppError { + fn from(err: ExternalServiceError) -> Self { + match err { + ExternalServiceError::ServiceError { + .. + } => AppError::external_api(err.to_string()), + ExternalServiceError::Timeout { + .. + } => AppError::timeout(err.to_string()), + ExternalServiceError::NetworkError { + .. + } => AppError::network(err.to_string()) + } + } +} + +/// Simulated payment processing +fn process_payment(amount: u64, balance: u64) -> Result { + if amount == 0 { + return Err(PaymentError::InvalidAmount( + "amount must be greater than 0".to_string() + )); + } + + if amount > balance { + return Err(PaymentError::InsufficientFunds { + balance, + required: amount + }); + } + + Ok(format!("Payment of ${amount} processed successfully")) +} + +/// Simulated authentication check +fn authenticate(username: &str, password: &str) -> Result { + if username.is_empty() || password.is_empty() { + return Err(AuthError::InvalidCredentials); + } + + if username != "admin" || password != "secret" { + return Err(AuthError::InvalidCredentials); + } + + Ok("Authentication successful".to_string()) +} + +/// Simulated input validation +fn validate_email(email: &str) -> Result<(), ValidationError> { + if email.is_empty() { + return Err(ValidationError::RequiredField { + field: "email".to_string() + }); + } + + if !email.contains('@') { + return Err(ValidationError::InvalidFormat { + field: "email".to_string(), + reason: "must contain @ symbol".to_string() + }); + } + + Ok(()) +} + +fn main() { + println!("Custom Domain Errors Example\\n"); + + // Payment errors + println!("=== Payment Processing ==="); + + match process_payment(100, 500) { + Ok(msg) => println!("โœ“ {msg}"), + Err(e) => { + let app_err: AppError = e.into(); + println!("โœ— AppError: {app_err}"); + } + } + + match process_payment(600, 500) { + Ok(msg) => println!("โœ“ {msg}"), + Err(e) => { + println!("โœ— PaymentError: {e}"); + let app_err: AppError = e.into(); + println!( + " โ†’ AppError kind: {:?}, HTTP: {}", + app_err.kind, + app_err.kind.http_status() + ); + } + } + + match process_payment(0, 500) { + Ok(msg) => println!("โœ“ {msg}"), + Err(e) => { + println!("โœ— PaymentError: {e}"); + let app_err: AppError = e.into(); + println!(" โ†’ AppError kind: {:?}", app_err.kind); + } + } + + // Authentication errors + println!("\\n=== Authentication ==="); + + match authenticate("admin", "secret") { + Ok(msg) => println!("โœ“ {msg}"), + Err(e) => println!("โœ— {e}") + } + + match authenticate("user", "wrong") { + Ok(msg) => println!("โœ“ {msg}"), + Err(e) => { + println!("โœ— AuthError: {e}"); + let app_err: AppError = e.into(); + println!( + " โ†’ AppError kind: {:?}, HTTP: {}", + app_err.kind, + app_err.kind.http_status() + ); + } + } + + let expired_err = AuthError::SessionExpired { + expired_at: "2025-01-01T00:00:00Z".to_string() + }; + println!("โœ— AuthError: {expired_err}"); + let app_err: AppError = expired_err.into(); + println!( + " โ†’ AppError kind: {:?}, HTTP: {}", + app_err.kind, + app_err.kind.http_status() + ); + + // Validation errors + println!("\\n=== Validation ==="); + + match validate_email("user@example.com") { + Ok(()) => println!("โœ“ Email is valid"), + Err(e) => println!("โœ— {e}") + } + + match validate_email("invalid-email") { + Ok(()) => println!("โœ“ Email is valid"), + Err(e) => { + println!("โœ— ValidationError: {e}"); + let app_err: AppError = e.into(); + println!( + " โ†’ AppError kind: {:?}, HTTP: {}", + app_err.kind, + app_err.kind.http_status() + ); + } + } + + match validate_email("") { + Ok(()) => println!("โœ“ Email is valid"), + Err(e) => { + println!("โœ— ValidationError: {e}"); + let app_err: AppError = e.into(); + println!( + " โ†’ AppError kind: {:?}, HTTP: {}", + app_err.kind, + app_err.kind.http_status() + ); + } + } + + // External service errors + println!("\\n=== External Service Errors ==="); + + let service_err = ExternalServiceError::Timeout { + service: "payment-gateway".to_string(), + timeout_ms: 5000 + }; + println!("โœ— ExternalServiceError: {service_err}"); + let app_err: AppError = service_err.into(); + println!( + " โ†’ AppError kind: {:?}, HTTP: {}", + app_err.kind, + app_err.kind.http_status() + ); + + let network_err = ExternalServiceError::NetworkError { + service: "fraud-detection".to_string(), + details: "connection refused".to_string() + }; + println!("โœ— ExternalServiceError: {network_err}"); + let app_err: AppError = network_err.into(); + println!( + " โ†’ AppError kind: {:?}, HTTP: {}", + app_err.kind, + app_err.kind.http_status() + ); +} diff --git a/examples/sqlx-database/Cargo.toml b/examples/sqlx-database/Cargo.toml new file mode 100644 index 0000000..0147522 --- /dev/null +++ b/examples/sqlx-database/Cargo.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2025 RAprogramm +# +# SPDX-License-Identifier: MIT + +[package] +name = "sqlx-database" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +masterror = { path = "../..", features = ["sqlx", "tracing"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "sqlite"] } diff --git a/examples/sqlx-database/README.md b/examples/sqlx-database/README.md new file mode 100644 index 0000000..afbac2d --- /dev/null +++ b/examples/sqlx-database/README.md @@ -0,0 +1,87 @@ + + +# SQLx Database Error Handling Example + +Demonstrates comprehensive database error handling patterns using SQLx and masterror. + +## Features + +- **Connection Error Handling** - Database connection failures +- **Query Error Mapping** - SQL errors to domain errors +- **Constraint Violation Handling** - Unique/foreign key violations +- **Transaction Error Patterns** - Rollback and commit handling +- **Row Not Found** - Handling missing data gracefully + +## Running + +```bash +cd examples/sqlx-database +cargo run +``` + +This example uses an in-memory SQLite database, so no external setup is required. + +## Error Scenarios + +### Connection Errors + +```rust +// Mapped to AppError::Database +sqlx::SqlitePool::connect("sqlite::memory:").await? +``` + +### Row Not Found + +```rust +// Mapped to AppError::NotFound +sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") + .bind(id) + .fetch_one(&pool) + .await? +``` + +### Unique Constraint Violation + +```rust +// Mapped to AppError::Conflict +sqlx::query("INSERT INTO users (id, email) VALUES (?, ?)") + .bind(id) + .bind(email) + .execute(&pool) + .await? +``` + +### Transaction Handling + +```rust +let mut tx = pool.begin().await?; + +// Operations... + +tx.commit().await?; // Or tx.rollback() on error +``` + +## Error Conversion + +masterror automatically converts SQLx errors: + +| SQLx Error | AppError Kind | HTTP Status | +|------------|---------------|-------------| +| `RowNotFound` | `NotFound` | 404 | +| `UniqueViolation` | `Conflict` | 409 | +| `ForeignKeyViolation` | `Conflict` | 409 | +| `ConnectionError` | `Database` | 500 | +| Other database errors | `Database` | 500 | + +## Testing + +```bash +cargo test +``` + +## License + +MIT diff --git a/examples/sqlx-database/src/main.rs b/examples/sqlx-database/src/main.rs new file mode 100644 index 0000000..0d1c930 --- /dev/null +++ b/examples/sqlx-database/src/main.rs @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! SQLx database error handling example. +//! +//! Demonstrates various database error scenarios and how masterror converts +//! SQLx errors into appropriate AppError types. + +use masterror::AppError; +use sqlx::{Row, SqlitePool, sqlite::SqliteRow}; + +/// User model +#[derive(Debug, Clone)] +struct User { + id: i64, + email: String, + name: String +} + +/// Initialize database schema +async fn init_database(pool: &SqlitePool) -> Result<(), AppError> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL + )" + ) + .execute(pool) + .await?; + + Ok(()) +} + +/// Insert a new user +async fn create_user(pool: &SqlitePool, email: &str, name: &str) -> Result { + let result = sqlx::query("INSERT INTO users (email, name) VALUES (?, ?)") + .bind(email) + .bind(name) + .execute(pool) + .await?; + + let id = result.last_insert_rowid(); + + Ok(User { + id, + email: email.to_string(), + name: name.to_string() + }) +} + +/// Get user by ID +async fn get_user_by_id(pool: &SqlitePool, id: i64) -> Result { + let row: SqliteRow = sqlx::query("SELECT id, email, name FROM users WHERE id = ?") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(User { + id: row.get(0), + email: row.get(1), + name: row.get(2) + }) +} + +/// Get user by email +async fn get_user_by_email(pool: &SqlitePool, email: &str) -> Result { + let row: SqliteRow = sqlx::query("SELECT id, email, name FROM users WHERE email = ?") + .bind(email) + .fetch_one(pool) + .await?; + + Ok(User { + id: row.get(0), + email: row.get(1), + name: row.get(2) + }) +} + +/// Update user name +async fn update_user(pool: &SqlitePool, id: i64, name: &str) -> Result<(), AppError> { + let result = sqlx::query("UPDATE users SET name = ? WHERE id = ?") + .bind(name) + .bind(id) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::not_found("user not found")); + } + + Ok(()) +} + +/// Delete user +async fn delete_user(pool: &SqlitePool, id: i64) -> Result<(), AppError> { + let result = sqlx::query("DELETE FROM users WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::not_found("user not found")); + } + + Ok(()) +} + +/// Transaction example: transfer operation +async fn transfer_user_data( + pool: &SqlitePool, + from_id: i64, + to_email: &str +) -> Result<(), AppError> { + let mut tx = pool.begin().await?; + + // Get source user + let row: SqliteRow = sqlx::query("SELECT name FROM users WHERE id = ?") + .bind(from_id) + .fetch_one(&mut *tx) + .await?; + + let name: String = row.get(0); + + // Update destination user + let result = sqlx::query("UPDATE users SET name = ? WHERE email = ?") + .bind(&name) + .bind(to_email) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::not_found("destination user not found")); + } + + // Commit transaction + tx.commit().await?; + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), AppError> { + println!("SQLx Database Error Handling Example\\n"); + + // Connect to in-memory SQLite database + let pool = SqlitePool::connect("sqlite::memory:").await?; + println!("โœ“ Connected to database"); + + // Initialize schema + init_database(&pool).await?; + println!("โœ“ Database schema initialized\\n"); + + // Create users + println!("=== Creating Users ==="); + let user1 = create_user(&pool, "alice@example.com", "Alice").await?; + println!("โœ“ Created user: {} (ID: {})", user1.name, user1.id); + + let user2 = create_user(&pool, "bob@example.com", "Bob").await?; + println!("โœ“ Created user: {} (ID: {})", user2.name, user2.id); + + // Try to create duplicate email (Conflict error) + println!("\\n=== Testing Unique Constraint Violation ==="); + match create_user(&pool, "alice@example.com", "Alice Duplicate").await { + Ok(_) => println!("โœ— Should have failed with conflict"), + Err(e) => { + println!("โœ“ Expected error: {}", e); + println!(" โ†’ Kind: {:?}, HTTP: {}", e.kind, e.kind.http_status()); + } + } + + // Get existing user + println!("\\n=== Retrieving User ==="); + let found = get_user_by_email(&pool, "alice@example.com").await?; + println!("โœ“ Found user: {} ({})", found.name, found.email); + + // Try to get non-existent user (NotFound error) + println!("\\n=== Testing Row Not Found ==="); + match get_user_by_id(&pool, 999).await { + Ok(_) => println!("โœ— Should have failed with not found"), + Err(e) => { + println!("โœ“ Expected error: {}", e); + println!(" โ†’ Kind: {:?}, HTTP: {}", e.kind, e.kind.http_status()); + } + } + + // Update user + println!("\\n=== Updating User ==="); + update_user(&pool, user1.id, "Alice Updated").await?; + let updated = get_user_by_id(&pool, user1.id).await?; + println!("โœ“ Updated user name: {}", updated.name); + + // Try to update non-existent user + println!("\\n=== Testing Update on Non-existent User ==="); + match update_user(&pool, 999, "Ghost").await { + Ok(_) => println!("โœ— Should have failed with not found"), + Err(e) => { + println!("โœ“ Expected error: {}", e); + println!(" โ†’ Kind: {:?}, HTTP: {}", e.kind, e.kind.http_status()); + } + } + + // Transaction example + println!("\\n=== Testing Transaction ==="); + transfer_user_data(&pool, user1.id, "bob@example.com").await?; + let bob_updated = get_user_by_email(&pool, "bob@example.com").await?; + println!( + "โœ“ Transaction completed: Bob's name is now '{}'", + bob_updated.name + ); + + // Delete user + println!("\\n=== Deleting User ==="); + delete_user(&pool, user2.id).await?; + println!("โœ“ Deleted user with ID: {}", user2.id); + + // Try to delete again (NotFound) + match delete_user(&pool, user2.id).await { + Ok(_) => println!("โœ— Should have failed with not found"), + Err(e) => { + println!("โœ“ Expected error: {}", e); + println!(" โ†’ Kind: {:?}, HTTP: {}", e.kind, e.kind.http_status()); + } + } + + pool.close().await; + println!("\\nโœ“ Database connection closed"); + + Ok(()) +}